diff --git a/crates/ely_app/src/shell/auth.rs b/crates/ely_app/src/shell/auth.rs index 0d7fe93..66538f2 100644 --- a/crates/ely_app/src/shell/auth.rs +++ b/crates/ely_app/src/shell/auth.rs @@ -15,7 +15,8 @@ use gpui::Context; use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir}; -use super::{ElyShell, ShellState, SyncStateUpdate}; +use super::sync_state::{SyncStateUpdate, sync_platform_label}; +use super::{ElyShell, ShellState}; /// Where the user is in the email OTP form. Tracked on `ElyShell` so /// the Sync settings page can pick the right widget cluster (only the @@ -131,7 +132,7 @@ impl ElyShell { return; }; let profile_dir = profile_data_dir(&profile_root, &active_profile_id); - match SyncEngine::for_profile_dir(&profile_dir, "ELY", super::sync_platform_label()) { + match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) { Ok(mut engine) => { let _ = engine.install_bearer(""); } @@ -210,8 +211,7 @@ fn spawn_verify_otp( } }; let mut engine = - match SyncEngine::for_profile_dir(&profile_dir, "ELY", super::sync_platform_label()) - { + match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) { Ok(engine) => engine, Err(error) => { let _ = tx.send(SyncStateUpdate::AuthError { diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index e1fdb22..41aa9b5 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -16,19 +16,23 @@ mod plugins; mod reading_list; mod render; mod settings_actions; +mod shell_actions; mod shortcut_files; mod sidebar; mod site_permissions; mod space_files; mod spaces; mod splits; +mod sync_state; mod tab_groups; mod tab_lifecycle; mod web_surface; +mod web_surface_cadence; mod web_surface_controller; mod web_surface_frame; mod web_surface_geometry; mod web_surface_keyboard; +mod web_surface_metadata; mod web_surface_permissions; mod web_surface_runtime; mod web_surface_state; @@ -51,14 +55,9 @@ use bookmarks::PendingBookmarkEdit; use downloads::PendingDownloadFileAction; use history::{PendingHistoryDomainClear, PendingHistoryTimeClear}; use plugins::{PendingPluginInstall, PendingPluginUninstall}; +use sync_state::SyncStateUpdate; use web_surface::WebSurfaceStore; -use crate::{ - CloseCurrentTab, DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads, - OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab, - SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut, -}; - enum ShellState { Ready(Box), StartupError(String), @@ -113,36 +112,6 @@ pub struct ElyShell { _translucency_subscription: Subscription, } -/// Messages the off-thread sync workers push back to the shell so -/// `SyncConnectionState` on `BrowserCore` and the in-flight auth -/// form reflect live state without the UI thread ever touching the -/// network. `SignedIn` is the initial-probe state set synchronously -/// on shell startup and does not flow through this channel. -#[derive(Clone, Debug)] -pub(crate) enum SyncStateUpdate { - SignedOut, - AwaitingDeviceApproval, - SyncReady { last_synced_at_secs: u64 }, - SyncError { message: String }, - AuthOtpSent { email: String }, - AuthSucceeded { email: String }, - AuthError { email: String, message: String }, -} - -/// Stable label for the current OS used by the device registration -/// payload. Defined once here so every off-thread call site agrees. -pub(crate) const fn sync_platform_label() -> &'static str { - if cfg!(target_os = "macos") { - "macos" - } else if cfg!(target_os = "windows") { - "windows" - } else if cfg!(target_os = "linux") { - "linux" - } else { - "other" - } -} - impl ElyShell { pub fn new(window: &mut Window, cx: &mut Context) -> Self { Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx) @@ -486,129 +455,6 @@ impl ElyShell { cx.notify(); } } - - fn on_close_current_tab( - &mut self, - _: &CloseCurrentTab, - window: &mut Window, - cx: &mut Context, - ) { - self.close_active_tab(window, cx); - } - - fn on_focus_address_bar( - &mut self, - _: &FocusAddressBar, - window: &mut Window, - cx: &mut Context, - ) { - self.focus_address_bar(window, cx); - } - - fn on_focus_command_mode( - &mut self, - _: &FocusCommandMode, - window: &mut Window, - cx: &mut Context, - ) { - self.focus_command_mode(window, cx); - } - - fn on_open_new_tab(&mut self, _: &OpenNewTab, window: &mut Window, cx: &mut Context) { - self.open_new_tab(window, cx); - } - - fn on_open_downloads( - &mut self, - _: &OpenDownloads, - window: &mut Window, - cx: &mut Context, - ) { - self.open_downloads(window, cx); - } - - fn on_download_current_page( - &mut self, - _: &DownloadCurrentPage, - window: &mut Window, - cx: &mut Context, - ) { - self.download_active_tab(window, cx); - } - - fn on_open_history(&mut self, _: &OpenHistory, window: &mut Window, cx: &mut Context) { - self.open_history(window, cx); - } - - fn on_open_settings(&mut self, _: &OpenSettings, window: &mut Window, cx: &mut Context) { - self.open_settings(window, cx); - } - - fn on_open_task_manager( - &mut self, - _: &OpenTaskManager, - window: &mut Window, - cx: &mut Context, - ) { - self.open_task_manager(window, cx); - } - - fn on_restore_closed_tab( - &mut self, - _: &RestoreClosedTab, - window: &mut Window, - cx: &mut Context, - ) { - self.restore_closed_tab(window, cx); - } - - fn on_reset_zoom(&mut self, _: &ResetZoom, _: &mut Window, cx: &mut Context) { - self.reset_active_tab_zoom(cx); - } - - fn on_select_next_tab( - &mut self, - _: &SelectNextTab, - window: &mut Window, - cx: &mut Context, - ) { - self.select_next_tab(window, cx); - } - - fn on_select_previous_tab( - &mut self, - _: &SelectPreviousTab, - window: &mut Window, - cx: &mut Context, - ) { - self.select_previous_tab(window, cx); - } - - fn on_toggle_favorite_tab( - &mut self, - _: &ToggleFavoriteTab, - _: &mut Window, - cx: &mut Context, - ) { - self.toggle_active_tab_favorite(cx); - } - - fn on_toggle_pinned_tab( - &mut self, - _: &TogglePinnedTab, - _: &mut Window, - cx: &mut Context, - ) { - self.toggle_active_tab_pinned(cx); - } - - fn on_zoom_in(&mut self, _: &ZoomIn, _: &mut Window, cx: &mut Context) { - self.zoom_active_tab_in(cx); - } - - fn on_zoom_out(&mut self, _: &ZoomOut, _: &mut Window, cx: &mut Context) { - self.zoom_active_tab_out(cx); - } } fn start_external_web_surface_timer(cx: &mut Context) { diff --git a/crates/ely_app/src/shell/settings_actions.rs b/crates/ely_app/src/shell/settings_actions.rs index 3cddc22..dec1aea 100644 --- a/crates/ely_app/src/shell/settings_actions.rs +++ b/crates/ely_app/src/shell/settings_actions.rs @@ -9,6 +9,7 @@ use gpui_component::slider::SliderValue; use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir}; +use super::sync_state::{SyncStateUpdate, sync_platform_label}; use super::{ElyShell, ShellState}; impl ElyShell { @@ -316,25 +317,25 @@ fn run_sync_upload( profile_dir: std::path::PathBuf, device_name: String, bytes: Vec, - inbox: std::sync::mpsc::Sender, + inbox: std::sync::mpsc::Sender, ) { let mut engine = match SyncEngine::for_profile_dir( &profile_dir, device_name, - super::sync_platform_label(), + sync_platform_label(), ) { Ok(engine) => engine, Err(error) => { let message = error.to_string(); tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine"); - let _ = inbox.send(super::SyncStateUpdate::SyncError { message }); + let _ = inbox.send(SyncStateUpdate::SyncError { message }); return; } }; match engine.upload_bytes(bytes) { Ok(ely_browser_core::SyncOutcome::SignedOut) => { tracing::info!(target: "ely::sync", "no bearer token on disk; sync skipped"); - let _ = inbox.send(super::SyncStateUpdate::SignedOut); + let _ = inbox.send(SyncStateUpdate::SignedOut); } Ok(ely_browser_core::SyncOutcome::Uploaded { snapshot_id, @@ -354,15 +355,15 @@ fn run_sync_upload( .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let _ = inbox.send(super::SyncStateUpdate::SyncReady { last_synced_at_secs }); + let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs }); } Err(error) => { let message = error.to_string(); tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed"); let update = if message.contains("device_not_approved") { - super::SyncStateUpdate::AwaitingDeviceApproval + SyncStateUpdate::AwaitingDeviceApproval } else { - super::SyncStateUpdate::SyncError { message } + SyncStateUpdate::SyncError { message } }; let _ = inbox.send(update); } diff --git a/crates/ely_app/src/shell/shell_actions.rs b/crates/ely_app/src/shell/shell_actions.rs new file mode 100644 index 0000000..98b70be --- /dev/null +++ b/crates/ely_app/src/shell/shell_actions.rs @@ -0,0 +1,149 @@ +use gpui::{Context, Window}; + +use crate::{ + CloseCurrentTab, DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads, + OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab, + SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut, +}; + +use super::ElyShell; + +impl ElyShell { + pub(super) fn on_close_current_tab( + &mut self, + _: &CloseCurrentTab, + window: &mut Window, + cx: &mut Context, + ) { + self.close_active_tab(window, cx); + } + + pub(super) fn on_focus_address_bar( + &mut self, + _: &FocusAddressBar, + window: &mut Window, + cx: &mut Context, + ) { + self.focus_address_bar(window, cx); + } + + pub(super) fn on_focus_command_mode( + &mut self, + _: &FocusCommandMode, + window: &mut Window, + cx: &mut Context, + ) { + self.focus_command_mode(window, cx); + } + + pub(super) fn on_open_new_tab( + &mut self, + _: &OpenNewTab, + window: &mut Window, + cx: &mut Context, + ) { + self.open_new_tab(window, cx); + } + + pub(super) fn on_open_downloads( + &mut self, + _: &OpenDownloads, + window: &mut Window, + cx: &mut Context, + ) { + self.open_downloads(window, cx); + } + + pub(super) fn on_download_current_page( + &mut self, + _: &DownloadCurrentPage, + window: &mut Window, + cx: &mut Context, + ) { + self.download_active_tab(window, cx); + } + + pub(super) fn on_open_history( + &mut self, + _: &OpenHistory, + window: &mut Window, + cx: &mut Context, + ) { + self.open_history(window, cx); + } + + pub(super) fn on_open_settings( + &mut self, + _: &OpenSettings, + window: &mut Window, + cx: &mut Context, + ) { + self.open_settings(window, cx); + } + + pub(super) fn on_open_task_manager( + &mut self, + _: &OpenTaskManager, + window: &mut Window, + cx: &mut Context, + ) { + self.open_task_manager(window, cx); + } + + pub(super) fn on_restore_closed_tab( + &mut self, + _: &RestoreClosedTab, + window: &mut Window, + cx: &mut Context, + ) { + self.restore_closed_tab(window, cx); + } + + pub(super) fn on_reset_zoom(&mut self, _: &ResetZoom, _: &mut Window, cx: &mut Context) { + self.reset_active_tab_zoom(cx); + } + + pub(super) fn on_select_next_tab( + &mut self, + _: &SelectNextTab, + window: &mut Window, + cx: &mut Context, + ) { + self.select_next_tab(window, cx); + } + + pub(super) fn on_select_previous_tab( + &mut self, + _: &SelectPreviousTab, + window: &mut Window, + cx: &mut Context, + ) { + self.select_previous_tab(window, cx); + } + + pub(super) fn on_toggle_favorite_tab( + &mut self, + _: &ToggleFavoriteTab, + _: &mut Window, + cx: &mut Context, + ) { + self.toggle_active_tab_favorite(cx); + } + + pub(super) fn on_toggle_pinned_tab( + &mut self, + _: &TogglePinnedTab, + _: &mut Window, + cx: &mut Context, + ) { + self.toggle_active_tab_pinned(cx); + } + + pub(super) fn on_zoom_in(&mut self, _: &ZoomIn, _: &mut Window, cx: &mut Context) { + self.zoom_active_tab_in(cx); + } + + pub(super) fn on_zoom_out(&mut self, _: &ZoomOut, _: &mut Window, cx: &mut Context) { + self.zoom_active_tab_out(cx); + } +} diff --git a/crates/ely_app/src/shell/sync_state.rs b/crates/ely_app/src/shell/sync_state.rs new file mode 100644 index 0000000..9ef8915 --- /dev/null +++ b/crates/ely_app/src/shell/sync_state.rs @@ -0,0 +1,29 @@ +/// Messages the off-thread sync workers push back to the shell so +/// `SyncConnectionState` on `BrowserCore` and the in-flight auth +/// form reflect live state without the UI thread ever touching the +/// network. `SignedIn` is the initial-probe state set synchronously +/// on shell startup and does not flow through this channel. +#[derive(Clone, Debug)] +pub(crate) enum SyncStateUpdate { + SignedOut, + AwaitingDeviceApproval, + SyncReady { last_synced_at_secs: u64 }, + SyncError { message: String }, + AuthOtpSent { email: String }, + AuthSucceeded { email: String }, + AuthError { email: String, message: String }, +} + +/// Stable label for the current OS used by the device registration +/// payload. Defined once here so every off-thread call site agrees. +pub(crate) const fn sync_platform_label() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "other" + } +} diff --git a/crates/ely_app/src/shell/web_surface.rs b/crates/ely_app/src/shell/web_surface.rs index 4d86ad6..e7cd41d 100644 --- a/crates/ely_app/src/shell/web_surface.rs +++ b/crates/ely_app/src/shell/web_surface.rs @@ -1,10 +1,11 @@ -use std::collections::BTreeMap; +use std::{collections::BTreeMap, time::Instant}; use ely_domain::{BrowserTab, TabId}; use gpui::{Bounds, Pixels, Point}; use crate::services::ProfileDataMode; +use super::web_surface_metadata::WebSurfacePageMetadata; use super::{ web_surface_frame::WebSurfaceFrame, web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceSize}, @@ -17,33 +18,6 @@ use super::{ }, }; -/// One page's worth of metadata observed in a Ready frame. The -/// controller applies these to the `BrowserTab` (title / favicon_key) -/// after the frame has been swapped into the surface state. Title and -/// favicon are independent — a navigation typically settles the URL -/// first, then Servo emits a title change a frame or two later, and -/// the favicon URL is derived from the loaded URL. -#[derive(Clone, Debug, Eq, PartialEq)] -pub(super) struct WebSurfacePageMetadata { - pub(super) tab_id: TabId, - pub(super) title: Option, - pub(super) favicon_url: Option, -} - -impl WebSurfacePageMetadata { - fn from_frame(tab_id: &TabId, frame: &WebSurfaceFrame) -> Option { - let title = frame.title().map(str::to_string); - let favicon_url = frame - .loaded_url() - .and_then(|loaded| ely_domain::UrlText::parse(loaded).ok()) - .and_then(|url| url.favicon_url()); - if title.is_none() && favicon_url.is_none() { - return None; - } - Some(Self { tab_id: tab_id.clone(), title, favicon_url }) - } -} - pub(super) struct WebSurfaceStore { runtime: WebSurfaceRuntime, /// Single owner of every per-tab invariant. See [`PerTabSurface`]. @@ -125,11 +99,6 @@ impl WebSurfaceStore { match self.initial_display_gate_message(&tab_id, &frame, had_ready) { Ok(()) => {} Err(message) => { - // Only transition to Failed when there is - // nothing on screen yet — once a real frame - // has rendered, transient gate failures - // (e.g. a stray empty-paint pass) must not - // wipe it out. if !had_ready { self.surface_mut(&tab_id).state = Some(WebSurfaceState::Failed { message }); @@ -163,12 +132,6 @@ impl WebSurfaceStore { Some(WebSurfaceState::Ready(_)) ); if had_ready { - // Keep the last good frame on screen — the - // worker emits Failed for any transient ensure - // / poll error (parse glitch, momentary IPC - // hiccup) and downgrading every one of them - // strobes the page. The error still surfaces - // through `tracing` for diagnostics. tracing::warn!( target: "ely::web_surface", tab_id = %tab_id, @@ -238,12 +201,6 @@ impl WebSurfaceStore { }); surface.pending_scroll_point = Some(point); surface.mark_pending_input_started(); - // Drop any buffered click — its viewport coordinates were - // captured against the pre-scroll page, so applying it after - // the scroll would land on the wrong DOM element. Keep - // `keyboard_focus` and `typed_text` though: Servo maintains - // its own DOM focus across scrolls, so a focused input keeps - // accepting the user's keystrokes after they wheel-scroll. surface.click_point = None; WebSurfaceInputOutcome::Applied } @@ -278,6 +235,16 @@ impl WebSurfaceStore { tab_id: &TabId, position: Point, scale_factor: f32, + ) -> WebSurfaceInputOutcome { + self.record_hover_point_at(tab_id, position, scale_factor, Instant::now()) + } + + fn record_hover_point_at( + &mut self, + tab_id: &TabId, + position: Point, + scale_factor: f32, + now: Instant, ) -> WebSurfaceInputOutcome { let Some(surface) = self.surfaces.get_mut(tab_id) else { return WebSurfaceInputOutcome::DroppedNoViewportBounds; @@ -290,7 +257,14 @@ impl WebSurfaceStore { else { return WebSurfaceInputOutcome::DroppedOutOfBounds; }; + if surface.hover_point == Some(point) { + return WebSurfaceInputOutcome::NoChange; + } + if surface.hover_is_throttled(now) { + return WebSurfaceInputOutcome::NoChange; + } surface.hover_point = Some(point); + surface.mark_hover_enqueued(now); WebSurfaceInputOutcome::Applied } diff --git a/crates/ely_app/src/shell/web_surface_cadence.rs b/crates/ely_app/src/shell/web_surface_cadence.rs new file mode 100644 index 0000000..0ca8c91 --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_cadence.rs @@ -0,0 +1,172 @@ +use std::time::{Duration, Instant}; + +const ACTIVE_POLL_INTERVAL: Duration = Duration::from_millis(8); +const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(80); +const LOAD_BOOST_WINDOW: Duration = Duration::from_secs(5); +const INPUT_BOOST_WINDOW: Duration = Duration::from_millis(600); +const HOVER_BOOST_WINDOW: Duration = Duration::from_millis(120); +const FRAME_SETTLE_WINDOW: Duration = Duration::from_millis(250); + +#[derive(Clone, Debug, Default)] +pub(super) struct WebSurfacePollCadence { + next_poll_at: Option, + active_until: Option, + last_render_phase: Option, +} + +impl WebSurfacePollCadence { + pub(super) fn note_ensure( + &mut self, + input_kind: WebSurfaceInputKind, + started_loading: bool, + now: Instant, + ) { + if started_loading { + self.last_render_phase = None; + self.boost_until(now + LOAD_BOOST_WINDOW); + } + match input_kind { + WebSurfaceInputKind::Idle => {} + WebSurfaceInputKind::Hover => self.boost_until(now + HOVER_BOOST_WINDOW), + WebSurfaceInputKind::Scroll + | WebSurfaceInputKind::Click + | WebSurfaceInputKind::Text => { + self.boost_until(now + INPUT_BOOST_WINDOW); + } + } + } + + pub(super) fn note_frame(&mut self, render_state: &str, now: Instant) { + let phase = WebSurfaceRenderPhase::from_render_state(render_state); + match phase { + WebSurfaceRenderPhase::Created | WebSurfaceRenderPhase::Loading => { + self.boost_until(now + LOAD_BOOST_WINDOW); + } + WebSurfaceRenderPhase::Complete if self.last_render_phase != Some(phase) => { + self.boost_until(now + FRAME_SETTLE_WINDOW); + } + WebSurfaceRenderPhase::Complete => {} + WebSurfaceRenderPhase::Other => { + self.boost_until(now + INPUT_BOOST_WINDOW); + } + } + self.last_render_phase = Some(phase); + } + + pub(super) fn should_poll(&self, now: Instant) -> bool { + self.next_poll_at.is_none_or(|next| now >= next) + } + + pub(super) fn note_poll_submitted(&mut self, now: Instant) { + self.next_poll_at = Some(now + self.current_interval(now)); + } + + fn current_interval(&self, now: Instant) -> Duration { + if self.active_until.is_some_and(|deadline| now < deadline) { + ACTIVE_POLL_INTERVAL + } else { + IDLE_POLL_INTERVAL + } + } + + fn boost_until(&mut self, deadline: Instant) { + if self.active_until.is_none_or(|current| deadline > current) { + self.active_until = Some(deadline); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WebSurfaceRenderPhase { + Created, + Loading, + Complete, + Other, +} + +impl WebSurfaceRenderPhase { + fn from_render_state(render_state: &str) -> Self { + match render_state { + "created" => Self::Created, + "loading" => Self::Loading, + "complete" => Self::Complete, + _ => Self::Other, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum WebSurfaceInputKind { + Idle, + Scroll, + Click, + Hover, + Text, +} + +impl WebSurfaceInputKind { + pub(super) fn label(self) -> &'static str { + match self { + Self::Idle => "idle", + Self::Scroll => "scroll", + Self::Click => "click", + Self::Hover => "hover", + Self::Text => "text", + } + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::{WebSurfaceInputKind, WebSurfacePollCadence}; + + #[test] + fn idle_poll_uses_low_frequency_after_submission() { + let start = Instant::now(); + let mut cadence = WebSurfacePollCadence::default(); + + cadence.note_poll_submitted(start); + + assert!(!cadence.should_poll(start + Duration::from_millis(79))); + assert!(cadence.should_poll(start + Duration::from_millis(80))); + } + + #[test] + fn scroll_input_uses_active_frame_cadence() { + let start = Instant::now(); + let mut cadence = WebSurfacePollCadence::default(); + + cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start); + cadence.note_poll_submitted(start); + + assert!(!cadence.should_poll(start + Duration::from_millis(7))); + assert!(cadence.should_poll(start + Duration::from_millis(8))); + } + + #[test] + fn complete_frames_settle_then_return_to_idle_cadence() { + let start = Instant::now(); + let mut cadence = WebSurfacePollCadence::default(); + + cadence.note_frame("complete", start); + cadence.note_poll_submitted(start + Duration::from_millis(300)); + + assert!(!cadence.should_poll(start + Duration::from_millis(379))); + assert!(cadence.should_poll(start + Duration::from_millis(380))); + } + + #[test] + fn repeated_complete_frames_do_not_extend_settle_window() { + let start = Instant::now(); + let mut cadence = WebSurfacePollCadence::default(); + + cadence.note_frame("complete", start); + cadence.note_frame("complete", start + Duration::from_millis(200)); + cadence.note_poll_submitted(start + Duration::from_millis(260)); + + assert!(!cadence.should_poll(start + Duration::from_millis(339))); + assert!(cadence.should_poll(start + Duration::from_millis(340))); + } +} diff --git a/crates/ely_app/src/shell/web_surface_controller.rs b/crates/ely_app/src/shell/web_surface_controller.rs index b8b67c2..14292ff 100644 --- a/crates/ely_app/src/shell/web_surface_controller.rs +++ b/crates/ely_app/src/shell/web_surface_controller.rs @@ -6,7 +6,7 @@ use crate::services::ProfileDataMode; use super::{ ElyShell, - web_surface::WebSurfacePageMetadata, + web_surface_metadata::WebSurfacePageMetadata, web_surface_permissions::web_surface_site_permissions_for_tab, web_surface_runtime::{WebSurfaceUrlChange, WebSurfaceUrlChangeKind}, web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState}, diff --git a/crates/ely_app/src/shell/web_surface_metadata.rs b/crates/ely_app/src/shell/web_surface_metadata.rs new file mode 100644 index 0000000..2b3368f --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_metadata.rs @@ -0,0 +1,29 @@ +use ely_domain::TabId; + +use super::web_surface_frame::WebSurfaceFrame; + +/// One page's worth of metadata observed in a Ready frame. The +/// controller applies these to the `BrowserTab` after the frame has +/// been swapped into the surface state. Title and favicon are +/// independent: navigation often settles the URL first, then Servo +/// emits a title change a frame or two later. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct WebSurfacePageMetadata { + pub(super) tab_id: TabId, + pub(super) title: Option, + pub(super) favicon_url: Option, +} + +impl WebSurfacePageMetadata { + pub(super) fn from_frame(tab_id: &TabId, frame: &WebSurfaceFrame) -> Option { + let title = frame.title().map(str::to_string); + let favicon_url = frame + .loaded_url() + .and_then(|loaded| ely_domain::UrlText::parse(loaded).ok()) + .and_then(|url| url.favicon_url()); + if title.is_none() && favicon_url.is_none() { + return None; + } + Some(Self { tab_id: tab_id.clone(), title, favicon_url }) + } +} diff --git a/crates/ely_app/src/shell/web_surface_runtime.rs b/crates/ely_app/src/shell/web_surface_runtime.rs index 6ad789d..8cd2d6f 100644 --- a/crates/ely_app/src/shell/web_surface_runtime.rs +++ b/crates/ely_app/src/shell/web_surface_runtime.rs @@ -9,6 +9,7 @@ use crate::services::{ }; use super::{ + web_surface_cadence::{WebSurfaceInputKind, WebSurfacePollCadence}, web_surface_frame::WebSurfaceFrame, web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize}, web_surface_permissions::WebSurfaceSitePermission, @@ -69,6 +70,7 @@ impl WebSurfaceRuntime { session.size = size; session.zoom_percent = zoom_percent; session.scroll_offset = next_scroll_offset; + session.cadence.note_ensure(input_kind, started_loading, Instant::now()); started_loading }; @@ -96,29 +98,16 @@ impl WebSurfaceRuntime { return Err("Servo worker was created but is no longer registered".to_string()); }; scoped.worker.submit_ensure(request); - log_ensure_submitted(tab, size, input_kind, enqueued_at, started_loading); + log_ensure_submitted(tab, size, input_kind.label(), enqueued_at, started_loading); Ok(WebSurfaceEnsureResult { requested_url, started_loading }) } pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec { - // Submit a Poll for every visible tab whose session is live so - // animations / JS-driven content keep advancing without user - // input. The worker coalesces — a Poll never overrides a - // pending Ensure — so this stays cheap even at 120 Hz. - for tab_id in visible_tab_ids { - let Some(session) = self.sessions.get(tab_id) else { - continue; - }; - let Some(scoped) = self.workers.get(&session.scope) else { - continue; - }; - scoped.worker.submit_poll(tab_id.as_str().to_string()); - } - let mut frames = Vec::new(); let mut dead_scopes = Vec::new(); let scopes: Vec = self.workers.keys().cloned().collect(); + let now = Instant::now(); for scope in scopes { let responses = self .workers @@ -138,6 +127,7 @@ impl WebSurfaceRuntime { let requested_url = session.requested_url.clone(); let scroll_offset = session.scroll_offset; let zoom_percent = session.zoom_percent; + session.cadence.note_frame(frame.render_state(), now); match WebSurfaceFrame::from_live_frame( requested_url.clone(), scroll_offset, @@ -176,6 +166,22 @@ impl WebSurfaceRuntime { self.workers.remove(&scope); } + let poll_now = Instant::now(); + for tab_id in visible_tab_ids { + let Some(session) = self.sessions.get_mut(tab_id) else { + continue; + }; + if !session.cadence.should_poll(poll_now) { + continue; + } + let Some(scoped) = self.workers.get(&session.scope) else { + continue; + }; + if scoped.worker.submit_poll(tab_id.as_str().to_string()) { + session.cadence.note_poll_submitted(poll_now); + } + } + frames } @@ -269,6 +275,7 @@ pub(super) struct WebSurfaceSession { pub(super) zoom_percent: u16, pub(super) scroll_offset: WebSurfaceScrollOffset, pub(super) pending_user_navigation: bool, + pub(super) cadence: WebSurfacePollCadence, } impl WebSurfaceSession { @@ -280,6 +287,7 @@ impl WebSurfaceSession { zoom_percent: 0, scroll_offset: WebSurfaceScrollOffset::default(), pending_user_navigation: false, + cadence: WebSurfacePollCadence::default(), } } @@ -394,17 +402,17 @@ fn input_requests_history_navigation(input: &WebSurfacePendingInput) -> bool { || input.typed_text.as_deref().is_some_and(|text| text.contains('\n')) } -fn pending_input_kind(input: &WebSurfacePendingInput) -> &'static str { +fn pending_input_kind(input: &WebSurfacePendingInput) -> WebSurfaceInputKind { if input.scroll_delta.is_some() { - "scroll" + WebSurfaceInputKind::Scroll } else if input.click_point.is_some() { - "click" + WebSurfaceInputKind::Click } else if input.typed_text.is_some() { - "text" + WebSurfaceInputKind::Text } else if input.hover_point.is_some() { - "hover" + WebSurfaceInputKind::Hover } else { - "idle" + WebSurfaceInputKind::Idle } } diff --git a/crates/ely_app/src/shell/web_surface_state.rs b/crates/ely_app/src/shell/web_surface_state.rs index 60ad4fc..e915013 100644 --- a/crates/ely_app/src/shell/web_surface_state.rs +++ b/crates/ely_app/src/shell/web_surface_state.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use ely_domain::TabId; use gpui::{Bounds, Pixels}; @@ -116,6 +116,7 @@ pub(super) struct PerTabSurface { pub(super) viewport_size: Option, pub(super) last_ensure_key: Option, pub(super) hover_point: Option, + last_hover_enqueued_at: Option, pub(super) click_point: Option, pub(super) pending_scroll_delta: Option, pub(super) pending_scroll_point: Option, @@ -132,6 +133,7 @@ impl PerTabSurface { viewport_size: None, last_ensure_key: None, hover_point: None, + last_hover_enqueued_at: None, click_point: None, pending_scroll_delta: None, pending_scroll_point: None, @@ -146,6 +148,15 @@ impl PerTabSurface { 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 should_ensure(&self, key: &WebSurfaceEnsureKey) -> bool { self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input() } @@ -171,6 +182,8 @@ impl PerTabSurface { } } +const HOVER_INPUT_MIN_INTERVAL: Duration = Duration::from_millis(32); + #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct WebSurfaceEnsureKey { requested_url: String, diff --git a/crates/ely_app/src/shell/web_surface_tests.rs b/crates/ely_app/src/shell/web_surface_tests.rs index dcbe829..698fc88 100644 --- a/crates/ely_app/src/shell/web_surface_tests.rs +++ b/crates/ely_app/src/shell/web_surface_tests.rs @@ -1,4 +1,4 @@ -use std::error::Error; +use std::{error::Error, time::Duration}; use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText}; use gpui::{Bounds, point, px, size}; @@ -218,6 +218,36 @@ fn zero_wheel_delta_reports_zero_delta() -> Result<(), Box> { Ok(()) } +#[test] +fn hover_input_is_rate_limited() -> Result<(), Box> { + let mut store = WebSurfaceStore::new(); + let tab = web_tab("https://example.com/hover")?; + let start = std::time::Instant::now(); + + assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0)); + assert_applied(store.record_hover_point_at(tab.id(), point(px(10.0), px(10.0)), 1.0, start)); + assert_eq!( + store.record_hover_point_at( + tab.id(), + point(px(12.0), px(12.0)), + 1.0, + start + Duration::from_millis(8), + ), + WebSurfaceInputOutcome::NoChange, + ); + assert_applied(store.record_hover_point_at( + tab.id(), + point(px(44.0), px(45.0)), + 1.0, + start + Duration::from_millis(33), + )); + + let input = store.take_pending_input(tab.id(), tab.url().as_str()); + + assert_eq!(input.hover_point.map(|point| (point.x(), point.y())), Some((44, 45))); + Ok(()) +} + /// Pinning the per-tab isolation invariant. A click recorded against /// tab A must not be drained by, dropped by, or overwritten by any /// state mutation routed to tab B. The store keys every click on its diff --git a/crates/ely_app/src/shell/web_surface_worker.rs b/crates/ely_app/src/shell/web_surface_worker.rs index c98996f..e66ef6c 100644 --- a/crates/ely_app/src/shell/web_surface_worker.rs +++ b/crates/ely_app/src/shell/web_surface_worker.rs @@ -162,20 +162,27 @@ impl LiveRuntimeWorker { cvar.notify_one(); } - pub(super) fn submit_poll(&self, tab_id: String) { + pub(super) fn submit_poll(&self, tab_id: String) -> bool { let (lock, cvar) = &*self.queue; let mut q = match lock.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), }; if q.shutdown { - return; + return false; } // A pending Ensure already produces the latest frame after its // run; don't downgrade it to a Poll. Only insert if nothing is // queued. - q.pending.entry(tab_id.clone()).or_insert(WorkerRequest::Poll { tab_id }); + let inserted = match q.pending.entry(tab_id.clone()) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(WorkerRequest::Poll { tab_id }); + true + } + std::collections::btree_map::Entry::Occupied(_) => false, + }; cvar.notify_one(); + inserted } pub(super) fn submit_close(&self, tab_id: String) {