diff --git a/crates/ely_app/src/main.rs b/crates/ely_app/src/main.rs index aea7889..0d92881 100644 --- a/crates/ely_app/src/main.rs +++ b/crates/ely_app/src/main.rs @@ -36,6 +36,7 @@ actions!( OpenTaskManager, Quit, RestoreClosedTab, + ResetZoom, SelectNextSpace, SelectNextTab, SelectPreviousSpace, @@ -44,6 +45,8 @@ actions!( ToggleFavoriteTab, TogglePinnedTab, ToggleSidebar, + ZoomIn, + ZoomOut, ] ); @@ -78,6 +81,10 @@ fn main() { MenuItem::action("Command Mode", FocusCommandMode), MenuItem::action("Toggle Sidebar", ToggleSidebar), MenuItem::separator(), + MenuItem::action("Zoom In", ZoomIn), + MenuItem::action("Zoom Out", ZoomOut), + MenuItem::action("Reset Zoom", ResetZoom), + MenuItem::separator(), MenuItem::action("Close Tab", CloseCurrentTab), MenuItem::separator(), MenuItem::action("Restore Closed Tab", RestoreClosedTab), diff --git a/crates/ely_app/src/services/servo_sidecar.rs b/crates/ely_app/src/services/servo_sidecar.rs index 092278b..ea55e51 100644 --- a/crates/ely_app/src/services/servo_sidecar.rs +++ b/crates/ely_app/src/services/servo_sidecar.rs @@ -165,7 +165,9 @@ fn append_snapshot_args( .arg("--scroll-x") .arg(request.scroll_x.to_string()) .arg("--scroll-y") - .arg(request.scroll_y.to_string()); + .arg(request.scroll_y.to_string()) + .arg("--page-zoom-percent") + .arg(request.page_zoom_percent.to_string()); } impl SidecarSnapshotRequest { diff --git a/crates/ely_app/src/services/servo_sidecar_request.rs b/crates/ely_app/src/services/servo_sidecar_request.rs index 70bc510..2807711 100644 --- a/crates/ely_app/src/services/servo_sidecar_request.rs +++ b/crates/ely_app/src/services/servo_sidecar_request.rs @@ -1,4 +1,7 @@ -use ely_domain::{ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText}; +use ely_domain::{ + DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, + UrlText, +}; use super::ProfileDataMode; @@ -11,6 +14,7 @@ pub struct SidecarSnapshotRequest { pub(in crate::services) height: u32, pub(in crate::services) scroll_x: i32, pub(in crate::services) scroll_y: i32, + pub(in crate::services) page_zoom_percent: u16, pub(in crate::services) click_point: Option, pub(in crate::services) typed_text: Option, pub(in crate::services) site_permissions: Vec, @@ -27,6 +31,7 @@ impl SidecarSnapshotRequest { height, scroll_x: 0, scroll_y: 0, + page_zoom_percent: DEFAULT_ZOOM_PERCENT, click_point: None, typed_text: None, site_permissions: Vec::new(), @@ -46,6 +51,12 @@ impl SidecarSnapshotRequest { self } + #[must_use] + pub fn with_page_zoom_percent(mut self, page_zoom_percent: u16) -> Self { + self.page_zoom_percent = page_zoom_percent; + self + } + #[must_use] pub fn with_click_point(mut self, x: u32, y: u32) -> Self { self.click_point = Some(SidecarClickPoint { x, y }); @@ -78,6 +89,11 @@ impl SidecarSnapshotRequest { pub(crate) fn profile_data_mode_for_test(&self) -> ProfileDataMode { self.profile_data_mode } + + #[cfg(test)] + pub(crate) fn page_zoom_percent_for_test(&self) -> u16 { + self.page_zoom_percent + } } #[derive(Clone, Copy, Debug)] diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 54c13f9..841f697 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -45,8 +45,8 @@ use web_surface::WebSurfaceStore; use crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, - OpenSettings, OpenTaskManager, RestoreClosedTab, SelectNextTab, SelectPreviousTab, - ToggleFavoriteTab, TogglePinnedTab, + OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab, SelectNextTab, SelectPreviousTab, + ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut, }; enum ShellState { @@ -284,6 +284,30 @@ impl ElyShell { } } + fn zoom_active_tab_in(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.zoom_active_tab_in().is_ok() + { + cx.notify(); + } + } + + fn zoom_active_tab_out(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.zoom_active_tab_out().is_ok() + { + cx.notify(); + } + } + + fn reset_active_tab_zoom(&mut self, cx: &mut Context) { + if let ShellState::Ready(core) = &mut self.state + && core.reset_active_tab_zoom().is_ok() + { + cx.notify(); + } + } + fn on_close_current_tab( &mut self, _: &CloseCurrentTab, @@ -350,6 +374,10 @@ impl ElyShell { 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, @@ -385,4 +413,12 @@ impl ElyShell { ) { 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); + } } diff --git a/crates/ely_app/src/shell/render.rs b/crates/ely_app/src/shell/render.rs index 0fa7dbb..0a272a8 100644 --- a/crates/ely_app/src/shell/render.rs +++ b/crates/ely_app/src/shell/render.rs @@ -51,6 +51,7 @@ impl ElyShell { .on_action(cx.listener(Self::on_open_new_tab)) .on_action(cx.listener(Self::on_open_settings)) .on_action(cx.listener(Self::on_open_task_manager)) + .on_action(cx.listener(Self::on_reset_zoom)) .on_action(cx.listener(Self::on_restore_closed_tab)) .on_action(cx.listener(Self::on_select_next_space)) .on_action(cx.listener(Self::on_select_next_tab)) @@ -60,6 +61,8 @@ impl ElyShell { .on_action(cx.listener(Self::on_toggle_favorite_tab)) .on_action(cx.listener(Self::on_toggle_pinned_tab)) .on_action(cx.listener(Self::on_toggle_sidebar)) + .on_action(cx.listener(Self::on_zoom_in)) + .on_action(cx.listener(Self::on_zoom_out)) .bg(rgb(ELY_THEME.canvas)) .text_color(rgb(ELY_THEME.ink)) .flex() diff --git a/crates/ely_app/src/shell/web_surface.rs b/crates/ely_app/src/shell/web_surface.rs index 7d730d5..d0c9b68 100644 --- a/crates/ely_app/src/shell/web_surface.rs +++ b/crates/ely_app/src/shell/web_surface.rs @@ -12,7 +12,7 @@ use super::{ }, web_surface_state::{ WebSurfaceClickState, WebSurfaceClient, WebSurfaceKeyboardFocusState, WebSurfaceRequest, - WebSurfaceScrollState, WebSurfaceState, WebSurfaceTextInputState, + WebSurfaceScrollState, WebSurfaceState, WebSurfaceStateKey, WebSurfaceTextInputState, }, }; @@ -59,20 +59,22 @@ impl WebSurfaceStore { let size = self.viewport_sizes.get(tab.id()).copied()?; let requested_url = tab.url().as_str().to_string(); let scroll_offset = self.scroll_offset_for(tab.id(), requested_url.as_str()); + let zoom_percent = tab.zoom_percent(); let click_point = self.click_point_for(tab.id(), requested_url.as_str(), scroll_offset); let typed_text = self.typed_text_for(tab.id(), requested_url.as_str(), scroll_offset, click_point); if self.is_loading_requested_url(tab.id(), requested_url.as_str()) { return None; } - if self.has_current_state( - tab.id(), - &requested_url, + let state_key = WebSurfaceStateKey { + requested_url: &requested_url, size, scroll_offset, + zoom_percent, click_point, - typed_text.as_deref(), - ) { + typed_text: typed_text.as_deref(), + }; + if self.has_current_state(tab.id(), state_key) { return None; } @@ -85,6 +87,7 @@ impl WebSurfaceStore { requested_url, size, scroll_offset, + zoom_percent, click_point, typed_text: typed_text.clone(), message: message.clone(), @@ -100,9 +103,14 @@ impl WebSurfaceStore { requested_url: requested_url.clone(), size, scroll_offset, + zoom_percent, click_point, typed_text: typed_text.clone(), - previous_frame: self.previous_ready_frame(tab.id(), requested_url.as_str()), + previous_frame: self.previous_ready_frame( + tab.id(), + requested_url.as_str(), + zoom_percent, + ), }, ); @@ -113,7 +121,8 @@ impl WebSurfaceStore { size.height, ) .with_profile_data_mode(profile_data_mode) - .with_scroll_offset(scroll_offset.x(), scroll_offset.y()); + .with_scroll_offset(scroll_offset.x(), scroll_offset.y()) + .with_page_zoom_percent(zoom_percent); if let Some(click_point) = click_point { snapshot_request = snapshot_request.with_click_point(click_point.x(), click_point.y()); } @@ -126,6 +135,7 @@ impl WebSurfaceStore { requested_url, size, scroll_offset, + zoom_percent, click_point, typed_text, client, @@ -133,79 +143,70 @@ impl WebSurfaceStore { }) } - fn has_current_state( - &self, - tab_id: &TabId, - requested_url: &str, - size: WebSurfaceSize, - scroll_offset: WebSurfaceScrollOffset, - click_point: Option, - typed_text: Option<&str>, - ) -> bool { + fn has_current_state(&self, tab_id: &TabId, key: WebSurfaceStateKey<'_>) -> bool { match self.states.get(tab_id) { Some(WebSurfaceState::Loading { requested_url: current_url, size: current_size, scroll_offset: current_scroll_offset, + zoom_percent: current_zoom_percent, click_point: current_click_point, typed_text: current_typed_text, .. }) => { - current_url == requested_url - && *current_size == size - && *current_scroll_offset == scroll_offset - && *current_click_point == click_point - && current_typed_text.as_deref() == typed_text + current_url == key.requested_url + && *current_size == key.size + && *current_scroll_offset == key.scroll_offset + && *current_zoom_percent == key.zoom_percent + && *current_click_point == key.click_point + && current_typed_text.as_deref() == key.typed_text } Some(WebSurfaceState::Ready(frame)) => { - frame.requested_url == requested_url - && frame.size() == size - && frame.scroll_offset() == scroll_offset - && frame.click_point() == click_point - && frame.typed_text() == typed_text + frame.requested_url == key.requested_url + && frame.size() == key.size + && frame.scroll_offset() == key.scroll_offset + && frame.zoom_percent() == key.zoom_percent + && frame.click_point() == key.click_point + && frame.typed_text() == key.typed_text } Some(WebSurfaceState::Failed { requested_url: current_url, size: current_size, scroll_offset: current_scroll_offset, + zoom_percent: current_zoom_percent, click_point: current_click_point, typed_text: current_typed_text, .. }) => { - current_url == requested_url - && *current_size == size - && *current_scroll_offset == scroll_offset - && *current_click_point == click_point - && current_typed_text.as_deref() == typed_text + current_url == key.requested_url + && *current_size == key.size + && *current_scroll_offset == key.scroll_offset + && *current_zoom_percent == key.zoom_percent + && *current_click_point == key.click_point + && current_typed_text.as_deref() == key.typed_text } None => false, } } - pub(super) fn is_loading( - &self, - tab_id: &TabId, - requested_url: &str, - size: WebSurfaceSize, - scroll_offset: WebSurfaceScrollOffset, - click_point: Option, - typed_text: Option<&str>, - ) -> bool { + pub(super) fn is_loading(&self, tab_id: &TabId, key: WebSurfaceStateKey<'_>) -> bool { matches!( self.states.get(tab_id), Some(WebSurfaceState::Loading { requested_url: current_url, size: current_size, scroll_offset: current_scroll_offset, + zoom_percent: current_zoom_percent, click_point: current_click_point, typed_text: current_typed_text, .. }) - if current_url == requested_url - && *current_size == size - && *current_scroll_offset == scroll_offset - && *current_click_point == click_point - && current_typed_text.as_deref() == typed_text + if current_url == key.requested_url + && *current_size == key.size + && *current_scroll_offset == key.scroll_offset + && *current_zoom_percent == key.zoom_percent + && *current_click_point == key.click_point + && current_typed_text.as_deref() == key.typed_text ) } @@ -217,14 +218,24 @@ impl WebSurfaceStore { ) } - fn previous_ready_frame(&self, tab_id: &TabId, requested_url: &str) -> Option { + fn previous_ready_frame( + &self, + tab_id: &TabId, + requested_url: &str, + zoom_percent: u16, + ) -> Option { match self.states.get(tab_id) { - Some(WebSurfaceState::Ready(frame)) if frame.requested_url == requested_url => { + 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.clone(), + }) if current_url == requested_url => previous_frame + .as_ref() + .filter(|frame| frame.zoom_percent() == zoom_percent) + .cloned(), _ => None, } } @@ -409,68 +420,8 @@ pub(super) fn is_external_web_url(url: &str) -> bool { } #[cfg(test)] -mod tests { - use std::error::Error; - - use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText}; - use gpui::{Bounds, point, px, size}; - - use super::{ProfileDataMode, WebSurfaceStore}; - - #[test] - fn typed_text_enters_snapshot_request_after_clicked_viewport() -> Result<(), Box> { - let mut store = WebSurfaceStore::new(); - let tab = web_tab("https://example.com/form")?; - - let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))); - assert!(store.record_viewport_size(tab.id(), bounds)); - assert!(store.record_click_point( - tab.id(), - tab.url().as_str(), - point(px(160.0), px(120.0)) - )); - assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "e")); - assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "l")); - - let request = store - .prepare_request(&tab, ProfileDataMode::Persistent) - .ok_or("missing web surface request")?; - - assert_eq!(request.typed_text.as_deref(), Some("el")); - assert_eq!(request.snapshot_request.typed_text_for_test(), Some("el")); - assert_eq!(request.snapshot_request.profile_id_for_test(), tab.profile_id()); - Ok(()) - } - - #[test] - fn private_profile_enters_transient_snapshot_request() -> Result<(), Box> { - let mut store = WebSurfaceStore::new(); - let tab = web_tab("https://example.com/private")?; - - let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))); - assert!(store.record_viewport_size(tab.id(), bounds)); - - let request = store - .prepare_request(&tab, ProfileDataMode::Transient) - .ok_or("missing web surface request")?; - - assert_eq!( - request.snapshot_request.profile_data_mode_for_test(), - ProfileDataMode::Transient - ); - Ok(()) - } - - fn web_tab(url: &str) -> Result> { - Ok(BrowserTab::new( - TabId::new(), - SpaceId::new(), - ProfileId::new(), - "Web", - UrlText::parse(url)?, - )) - } -} +#[path = "web_surface_tests.rs"] +mod tests; #[cfg(all(test, feature = "live-site-smoke"))] #[path = "web_surface_live_site_tests.rs"] diff --git a/crates/ely_app/src/shell/web_surface_controller.rs b/crates/ely_app/src/shell/web_surface_controller.rs index 4e96206..70a08b3 100644 --- a/crates/ely_app/src/shell/web_surface_controller.rs +++ b/crates/ely_app/src/shell/web_surface_controller.rs @@ -12,7 +12,7 @@ use super::{ web_surface_frame::WebSurfaceFrame, web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset, WebSurfaceSize}, web_surface_permissions::sidecar_site_permissions_for_tab, - web_surface_state::{WebSurfaceRequest, WebSurfaceState}, + web_surface_state::{WebSurfaceRequest, WebSurfaceState, WebSurfaceStateKey}, web_surface_view::{ render_failed_web_surface, render_loading_web_surface, render_ready_web_surface, }, @@ -23,6 +23,7 @@ struct PendingWebSurfaceFrame { requested_url: String, size: WebSurfaceSize, scroll_offset: WebSurfaceScrollOffset, + zoom_percent: u16, click_point: Option, typed_text: Option, } @@ -74,6 +75,7 @@ impl ElyShell { requested_url, size, scroll_offset, + zoom_percent, click_point, typed_text, client, @@ -85,6 +87,7 @@ impl ElyShell { requested_url, size, scroll_offset, + zoom_percent, click_point, typed_text, }; @@ -112,17 +115,19 @@ impl ElyShell { requested_url, size, scroll_offset, + zoom_percent, click_point, typed_text, } = pending_frame; - if !self.web_surfaces.is_loading( - &tab_id, - requested_url.as_str(), + let state_key = WebSurfaceStateKey { + requested_url: requested_url.as_str(), size, scroll_offset, + zoom_percent, click_point, - typed_text.as_deref(), - ) { + typed_text: typed_text.as_deref(), + }; + if !self.web_surfaces.is_loading(&tab_id, state_key) { return; } @@ -130,6 +135,7 @@ impl ElyShell { Ok(snapshot) => match WebSurfaceFrame::from_snapshot( requested_url.clone(), scroll_offset, + zoom_percent, click_point, typed_text.clone(), snapshot, @@ -139,6 +145,7 @@ impl ElyShell { requested_url, size, scroll_offset, + zoom_percent, click_point, typed_text, message: error.to_string(), @@ -148,6 +155,7 @@ impl ElyShell { requested_url, size, scroll_offset, + zoom_percent, click_point, typed_text, message: error.to_string(), diff --git a/crates/ely_app/src/shell/web_surface_frame.rs b/crates/ely_app/src/shell/web_surface_frame.rs index c8f0d67..b661d85 100644 --- a/crates/ely_app/src/shell/web_surface_frame.rs +++ b/crates/ely_app/src/shell/web_surface_frame.rs @@ -20,6 +20,7 @@ pub(super) struct WebSurfaceFrame { width: u32, height: u32, scroll_offset: WebSurfaceScrollOffset, + zoom_percent: u16, click_point: Option, typed_text: Option, #[cfg(all(test, feature = "live-site-smoke"))] @@ -35,6 +36,7 @@ impl WebSurfaceFrame { pub(super) fn from_snapshot( requested_url: String, scroll_offset: WebSurfaceScrollOffset, + zoom_percent: u16, click_point: Option, typed_text: Option, snapshot: SidecarSnapshot, @@ -66,6 +68,7 @@ impl WebSurfaceFrame { width, height, scroll_offset, + zoom_percent, click_point, typed_text, #[cfg(all(test, feature = "live-site-smoke"))] @@ -89,6 +92,9 @@ impl WebSurfaceFrame { pub(super) fn detail_label(&self) -> String { let mut detail = format!("{} {}", self.render_state(), self.scroll_offset.detail_label(self.size())); + if self.zoom_percent != ely_domain::DEFAULT_ZOOM_PERCENT { + detail = format!("{detail} zoom={}%", self.zoom_percent); + } if let Some(click_point) = self.click_point { detail = format!("{detail} {}", click_point.detail_label()); } @@ -110,6 +116,10 @@ impl WebSurfaceFrame { self.scroll_offset } + pub(super) fn zoom_percent(&self) -> u16 { + self.zoom_percent + } + pub(super) fn click_point(&self) -> Option { self.click_point } diff --git a/crates/ely_app/src/shell/web_surface_live_site_tests.rs b/crates/ely_app/src/shell/web_surface_live_site_tests.rs index 44bb8bb..09ddc77 100644 --- a/crates/ely_app/src/shell/web_surface_live_site_tests.rs +++ b/crates/ely_app/src/shell/web_surface_live_site_tests.rs @@ -61,6 +61,7 @@ fn render_web_surface_frame(case: &LiveSiteCase) -> Result { + pub(super) requested_url: &'a str, + pub(super) size: WebSurfaceSize, + pub(super) scroll_offset: WebSurfaceScrollOffset, + pub(super) zoom_percent: u16, + pub(super) click_point: Option, + pub(super) typed_text: Option<&'a str>, +} + pub(super) enum WebSurfaceClient { Ready(ServoSidecarClient), Unavailable(String), @@ -59,6 +69,7 @@ pub(super) struct WebSurfaceRequest { pub(super) requested_url: String, pub(super) size: WebSurfaceSize, pub(super) scroll_offset: WebSurfaceScrollOffset, + pub(super) zoom_percent: u16, pub(super) click_point: Option, pub(super) typed_text: Option, pub(super) client: ServoSidecarClient, @@ -70,6 +81,7 @@ pub(super) enum WebSurfaceState { requested_url: String, size: WebSurfaceSize, scroll_offset: WebSurfaceScrollOffset, + zoom_percent: u16, click_point: Option, typed_text: Option, previous_frame: Option, @@ -79,6 +91,7 @@ pub(super) enum WebSurfaceState { requested_url: String, size: WebSurfaceSize, scroll_offset: WebSurfaceScrollOffset, + zoom_percent: u16, click_point: Option, typed_text: Option, message: String, diff --git a/crates/ely_app/src/shell/web_surface_tests.rs b/crates/ely_app/src/shell/web_surface_tests.rs new file mode 100644 index 0000000..b893c71 --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_tests.rs @@ -0,0 +1,70 @@ +use std::error::Error; + +use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText}; +use gpui::{Bounds, point, px, size}; + +use super::{ProfileDataMode, WebSurfaceStore}; + +#[test] +fn typed_text_enters_snapshot_request_after_clicked_viewport() -> Result<(), Box> { + let mut store = WebSurfaceStore::new(); + let tab = web_tab("https://example.com/form")?; + + let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))); + assert!(store.record_viewport_size(tab.id(), bounds)); + assert!(store.record_click_point(tab.id(), tab.url().as_str(), point(px(160.0), px(120.0)))); + assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "e")); + assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "l")); + + let request = store + .prepare_request(&tab, ProfileDataMode::Persistent) + .ok_or("missing web surface request")?; + + assert_eq!(request.typed_text.as_deref(), Some("el")); + assert_eq!(request.snapshot_request.typed_text_for_test(), Some("el")); + assert_eq!(request.snapshot_request.profile_id_for_test(), tab.profile_id()); + assert_eq!(request.zoom_percent, ely_domain::DEFAULT_ZOOM_PERCENT); + assert_eq!( + request.snapshot_request.page_zoom_percent_for_test(), + ely_domain::DEFAULT_ZOOM_PERCENT + ); + Ok(()) +} + +#[test] +fn private_profile_enters_transient_snapshot_request() -> Result<(), Box> { + let mut store = WebSurfaceStore::new(); + let tab = web_tab("https://example.com/private")?; + + let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))); + assert!(store.record_viewport_size(tab.id(), bounds)); + + let request = store + .prepare_request(&tab, ProfileDataMode::Transient) + .ok_or("missing web surface request")?; + + assert_eq!(request.snapshot_request.profile_data_mode_for_test(), ProfileDataMode::Transient); + Ok(()) +} + +#[test] +fn tab_zoom_enters_snapshot_request() -> Result<(), Box> { + let mut store = WebSurfaceStore::new(); + let mut tab = web_tab("https://example.com/zoom")?; + tab.set_zoom_percent(125)?; + + let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))); + assert!(store.record_viewport_size(tab.id(), bounds)); + + let request = store + .prepare_request(&tab, ProfileDataMode::Persistent) + .ok_or("missing web surface request")?; + + assert_eq!(request.zoom_percent, 125); + assert_eq!(request.snapshot_request.page_zoom_percent_for_test(), 125); + Ok(()) +} + +fn web_tab(url: &str) -> Result> { + Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?)) +} diff --git a/crates/ely_app/src/shortcuts.rs b/crates/ely_app/src/shortcuts.rs index 171e939..d64d895 100644 --- a/crates/ely_app/src/shortcuts.rs +++ b/crates/ely_app/src/shortcuts.rs @@ -10,9 +10,9 @@ pub(crate) use profile::{ use crate::{ CloseCurrentTab, FocusAddressBar, FocusCommandMode, OpenDownloads, OpenHistory, OpenNewTab, - OpenPrivateWindow, OpenSettings, OpenTaskManager, Quit, RestoreClosedTab, SelectNextSpace, - SelectNextTab, SelectPreviousSpace, SelectPreviousTab, SplitRight, ToggleFavoriteTab, - ToggleSidebar, + OpenPrivateWindow, OpenSettings, OpenTaskManager, Quit, ResetZoom, RestoreClosedTab, + SelectNextSpace, SelectNextTab, SelectPreviousSpace, SelectPreviousTab, SplitRight, + ToggleFavoriteTab, ToggleSidebar, ZoomIn, ZoomOut, }; #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] @@ -47,6 +47,9 @@ pub(crate) enum ShortcutAction { SplitRight, ToggleSidebar, ToggleFavoriteTab, + ZoomIn, + ZoomOut, + ResetZoom, OpenDownloads, OpenHistory, OpenSettings, @@ -70,6 +73,9 @@ impl ShortcutAction { Self::SplitRight => "Split Right", Self::ToggleSidebar => "Toggle Sidebar", Self::ToggleFavoriteTab => "Toggle Favorite", + Self::ZoomIn => "Zoom In", + Self::ZoomOut => "Zoom Out", + Self::ResetZoom => "Reset Zoom", Self::OpenDownloads => "Open Downloads", Self::OpenHistory => "Open History", Self::OpenSettings => "Open Settings", @@ -91,7 +97,10 @@ impl ShortcutAction { | Self::SelectPreviousTab | Self::SplitRight | Self::ToggleSidebar - | Self::ToggleFavoriteTab => "Tabs", + | Self::ToggleFavoriteTab + | Self::ZoomIn + | Self::ZoomOut + | Self::ResetZoom => "Tabs", Self::OpenDownloads | Self::OpenHistory => "Library", Self::OpenSettings | Self::OpenTaskManager => "System", Self::Quit => "Application", @@ -113,6 +122,9 @@ impl ShortcutAction { Self::SplitRight => Some(">split-right"), Self::ToggleSidebar => None, Self::ToggleFavoriteTab => Some(">favorite"), + Self::ZoomIn => Some(">zoom-in"), + Self::ZoomOut => Some(">zoom-out"), + Self::ResetZoom => Some(">reset-zoom"), Self::OpenDownloads => Some(">open-downloads"), Self::OpenHistory => Some(">open-history"), Self::OpenSettings => Some(">open-settings"), @@ -164,6 +176,9 @@ pub(crate) const SHORTCUT_ACTIONS: &[ShortcutAction] = &[ ShortcutAction::SplitRight, ShortcutAction::ToggleSidebar, ShortcutAction::ToggleFavoriteTab, + ShortcutAction::ZoomIn, + ShortcutAction::ZoomOut, + ShortcutAction::ResetZoom, ShortcutAction::OpenDownloads, ShortcutAction::OpenHistory, ShortcutAction::OpenSettings, @@ -196,6 +211,12 @@ pub(crate) const SHORTCUT_BINDINGS: &[ShortcutBinding] = &[ shortcut(ShortcutAction::RestoreClosedTab, ShortcutPlatform::WindowsLinux, "ctrl-shift-t"), shortcut(ShortcutAction::ToggleFavoriteTab, ShortcutPlatform::Macos, "cmd-shift-f"), shortcut(ShortcutAction::ToggleFavoriteTab, ShortcutPlatform::WindowsLinux, "ctrl-shift-f"), + shortcut(ShortcutAction::ZoomIn, ShortcutPlatform::Macos, "cmd-="), + shortcut(ShortcutAction::ZoomIn, ShortcutPlatform::WindowsLinux, "ctrl-="), + shortcut(ShortcutAction::ZoomOut, ShortcutPlatform::Macos, "cmd--"), + shortcut(ShortcutAction::ZoomOut, ShortcutPlatform::WindowsLinux, "ctrl--"), + shortcut(ShortcutAction::ResetZoom, ShortcutPlatform::Macos, "cmd-0"), + shortcut(ShortcutAction::ResetZoom, ShortcutPlatform::WindowsLinux, "ctrl-0"), shortcut(ShortcutAction::FocusCommandMode, ShortcutPlatform::Macos, "cmd-shift-p"), shortcut(ShortcutAction::FocusCommandMode, ShortcutPlatform::WindowsLinux, "ctrl-shift-p"), shortcut(ShortcutAction::SelectNextSpace, ShortcutPlatform::Macos, "cmd-alt-right"), @@ -243,6 +264,7 @@ pub(crate) fn key_binding_for_action(action: ShortcutAction, keystroke: &str) -> ShortcutAction::OpenSettings => KeyBinding::new(keystroke, OpenSettings, None), ShortcutAction::OpenTaskManager => KeyBinding::new(keystroke, OpenTaskManager, None), ShortcutAction::Quit => KeyBinding::new(keystroke, Quit, None), + ShortcutAction::ResetZoom => KeyBinding::new(keystroke, ResetZoom, None), ShortcutAction::RestoreClosedTab => KeyBinding::new(keystroke, RestoreClosedTab, None), ShortcutAction::SelectNextSpace => KeyBinding::new(keystroke, SelectNextSpace, None), ShortcutAction::SelectNextTab => KeyBinding::new(keystroke, SelectNextTab, None), @@ -253,6 +275,8 @@ pub(crate) fn key_binding_for_action(action: ShortcutAction, keystroke: &str) -> ShortcutAction::SplitRight => KeyBinding::new(keystroke, SplitRight, None), ShortcutAction::ToggleFavoriteTab => KeyBinding::new(keystroke, ToggleFavoriteTab, None), ShortcutAction::ToggleSidebar => KeyBinding::new(keystroke, ToggleSidebar, None), + ShortcutAction::ZoomIn => KeyBinding::new(keystroke, ZoomIn, None), + ShortcutAction::ZoomOut => KeyBinding::new(keystroke, ZoomOut, None), } } diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 09854a7..f941fa0 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -133,6 +133,10 @@ impl BrowserCore { self.archive_idle_tabs(SystemTime::now())?; return Ok(true); } + if let Some(percent) = zoom_percent(command)? { + self.set_active_tab_zoom_percent(percent)?; + return Ok(true); + } if let Some(percent) = reading_progress_percent(command)? { self.set_active_tab_reading_progress(percent)?; return Ok(true); @@ -167,6 +171,18 @@ impl BrowserCore { self.open_new_tab()?; Ok(true) } + "zoom-in" | "zoom in" => { + self.zoom_active_tab_in()?; + Ok(true) + } + "zoom-out" | "zoom out" => { + self.zoom_active_tab_out()?; + Ok(true) + } + "reset-zoom" | "reset zoom" | "actual-size" | "actual size" => { + self.reset_active_tab_zoom()?; + Ok(true) + } "move-tab-up" | "move tab up" | "tab-up" | "tab up" => self.move_active_tab_up(), "move-tab-down" | "move tab down" | "tab-down" | "tab down" => { self.move_active_tab_down() @@ -446,3 +462,19 @@ impl BrowserCore { .transpose() } } + +fn zoom_percent(command: &str) -> Result, CoreError> { + let command = command.trim(); + let lowercased = command.to_ascii_lowercase(); + let value = ["zoom ", "set-zoom ", "set zoom "] + .into_iter() + .find_map(|prefix| lowercased.starts_with(prefix).then(|| &command[prefix.len()..])); + let Some(value) = value else { return Ok(None) }; + + let percent = value + .trim() + .trim_end_matches('%') + .parse::() + .map_err(|_| CoreError::Domain(ely_domain::DomainError::InvalidCommand))?; + Ok(Some(ely_domain::validate_zoom_percent(percent)?)) +} diff --git a/crates/ely_browser_core/src/state/tabs.rs b/crates/ely_browser_core/src/state/tabs.rs index 9b7d908..62cb081 100644 --- a/crates/ely_browser_core/src/state/tabs.rs +++ b/crates/ely_browser_core/src/state/tabs.rs @@ -220,6 +220,30 @@ impl BrowserCore { Ok(next_pinned) } + pub fn set_active_tab_zoom_percent(&mut self, zoom_percent: u16) -> Result { + let active_tab = self.active_tab_mut()?; + active_tab.set_zoom_percent(zoom_percent)?; + Ok(active_tab.zoom_percent()) + } + + pub fn zoom_active_tab_in(&mut self) -> Result { + let active_tab = self.active_tab_mut()?; + active_tab.zoom_in(); + Ok(active_tab.zoom_percent()) + } + + pub fn zoom_active_tab_out(&mut self) -> Result { + let active_tab = self.active_tab_mut()?; + active_tab.zoom_out(); + Ok(active_tab.zoom_percent()) + } + + pub fn reset_active_tab_zoom(&mut self) -> Result { + let active_tab = self.active_tab_mut()?; + active_tab.reset_zoom(); + Ok(active_tab.zoom_percent()) + } + pub fn set_tab_sync_enabled( &mut self, tab_id: &TabId, @@ -337,6 +361,11 @@ impl BrowserCore { .ok_or(CoreError::MissingActiveTab) } + fn active_tab_mut(&mut self) -> Result<&mut BrowserTab, CoreError> { + let active_index = self.active_tab_index()?; + self.tabs.get_mut(active_index).ok_or(CoreError::MissingActiveTab) + } + pub(super) fn build_tab(&self, url: UrlText) -> BrowserTab { self.build_tab_for(self.active_space_id.clone(), self.active_profile_id.clone(), url) .with_parent_tab_id(self.active_tab_id.clone()) diff --git a/crates/ely_browser_core/tests/tabs.rs b/crates/ely_browser_core/tests/tabs.rs index 5c29991..474f840 100644 --- a/crates/ely_browser_core/tests/tabs.rs +++ b/crates/ely_browser_core/tests/tabs.rs @@ -1,7 +1,10 @@ use std::error::Error; use ely_browser_core::{BrowserCore, CoreError, InitialBrowserConfig}; -use ely_domain::{CommandIntent, CommandScope, NewTabDestination, SearchEngine, TabState, UrlText}; +use ely_domain::{ + CommandIntent, CommandScope, DEFAULT_ZOOM_PERCENT, DomainError, MAX_ZOOM_PERCENT, + MIN_ZOOM_PERCENT, NewTabDestination, SearchEngine, TabState, UrlText, +}; #[test] fn opens_new_tab_below_active_tab() -> Result<(), Box> { @@ -357,6 +360,71 @@ fn new_tab_command_uses_selected_destination() -> Result<(), Box> { Ok(()) } +#[test] +fn active_tab_zoom_updates_tab_state() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + assert_eq!(core.active_tab()?.zoom_percent(), DEFAULT_ZOOM_PERCENT); + assert_eq!(core.zoom_active_tab_in()?, DEFAULT_ZOOM_PERCENT + 10); + assert_eq!(core.zoom_active_tab_out()?, DEFAULT_ZOOM_PERCENT); + assert_eq!(core.set_active_tab_zoom_percent(125)?, 125); + assert_eq!(core.active_tab()?.zoom_factor(), 1.25); + assert_eq!(core.reset_active_tab_zoom()?, DEFAULT_ZOOM_PERCENT); + Ok(()) +} + +#[test] +fn active_tab_zoom_clamps_incremental_commands() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_active_tab_zoom_percent(MAX_ZOOM_PERCENT)?; + assert_eq!(core.zoom_active_tab_in()?, MAX_ZOOM_PERCENT); + + core.set_active_tab_zoom_percent(MIN_ZOOM_PERCENT)?; + assert_eq!(core.zoom_active_tab_out()?, MIN_ZOOM_PERCENT); + Ok(()) +} + +#[test] +fn active_tab_zoom_rejects_out_of_range_percent() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + let error = match core.set_active_tab_zoom_percent(5) { + Err(error) => error, + Ok(_) => return Err("zoom should require an in-range percent".into()), + }; + + assert_eq!( + error, + CoreError::Domain(DomainError::InvalidZoomPercent { + value: 5, + min: MIN_ZOOM_PERCENT, + max: MAX_ZOOM_PERCENT, + }) + ); + Ok(()) +} + +#[test] +fn zoom_commands_update_active_tab() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">zoom-in"); + let intent = core.submit_command()?; + assert_eq!(intent, Some(CommandIntent::Command("zoom-in".to_string()))); + assert_eq!(core.active_tab()?.zoom_percent(), DEFAULT_ZOOM_PERCENT + 10); + + core.set_command_query(">zoom 125%"); + core.submit_command()?; + assert_eq!(core.active_tab()?.zoom_percent(), 125); + + core.set_command_query(">actual-size"); + core.submit_command()?; + assert_eq!(core.active_tab()?.zoom_percent(), DEFAULT_ZOOM_PERCENT); + assert_eq!(core.command_query(), ""); + Ok(()) +} + #[test] fn switching_spaces_restores_each_space_active_tab() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index f8a544e..9a57079 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -41,6 +41,9 @@ pub enum DomainError { #[error("invalid reading progress percent: {value}")] InvalidReadingProgressPercent { value: String }, + #[error("invalid tab zoom percent: {value} (expected {min}-{max})")] + InvalidZoomPercent { value: u16, min: u16, max: u16 }, + #[error("invalid plugin manifest: {reason}")] InvalidPluginManifest { reason: String }, diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 250353b..85cf812 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -60,7 +60,10 @@ pub use sync::{ SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus, SyncStatus, }; -pub use tab::{BrowserTab, TabFlags, TabState}; +pub use tab::{ + BrowserTab, DEFAULT_ZOOM_PERCENT, MAX_ZOOM_PERCENT, MIN_ZOOM_PERCENT, TabFlags, TabState, + ZOOM_PERCENT_STEP, validate_zoom_percent, +}; pub use tab_group::TabGroup; pub use update::UpdatePolicy; pub use url_text::UrlText; diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index b970f0b..44a3b9e 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -2,6 +2,11 @@ use std::time::SystemTime; use crate::{DomainError, ProfileId, SpaceId, SplitId, TabGroupId, TabId, UrlText}; +pub const DEFAULT_ZOOM_PERCENT: u16 = 100; +pub const MIN_ZOOM_PERCENT: u16 = 25; +pub const MAX_ZOOM_PERCENT: u16 = 500; +pub const ZOOM_PERCENT_STEP: u16 = 10; + #[derive(Clone, Debug, Eq, PartialEq)] pub enum TabState { Loading, @@ -34,6 +39,7 @@ pub struct BrowserTab { split_id: Option, sort_key: u64, sync_enabled: bool, + zoom_percent: u16, created_at: SystemTime, last_active_at: SystemTime, } @@ -62,6 +68,7 @@ impl BrowserTab { split_id: None, sort_key: 0, sync_enabled: true, + zoom_percent: DEFAULT_ZOOM_PERCENT, created_at, last_active_at: created_at, } @@ -209,6 +216,35 @@ impl BrowserTab { self.sync_enabled } + #[must_use] + pub fn zoom_percent(&self) -> u16 { + self.zoom_percent + } + + #[must_use] + pub fn zoom_factor(&self) -> f32 { + f32::from(self.zoom_percent) / f32::from(DEFAULT_ZOOM_PERCENT) + } + + pub fn set_zoom_percent(&mut self, zoom_percent: u16) -> Result<(), DomainError> { + self.zoom_percent = validate_zoom_percent(zoom_percent)?; + Ok(()) + } + + pub fn zoom_in(&mut self) { + self.zoom_percent = + self.zoom_percent.saturating_add(ZOOM_PERCENT_STEP).min(MAX_ZOOM_PERCENT); + } + + pub fn zoom_out(&mut self) { + self.zoom_percent = + self.zoom_percent.saturating_sub(ZOOM_PERCENT_STEP).max(MIN_ZOOM_PERCENT); + } + + pub fn reset_zoom(&mut self) { + self.zoom_percent = DEFAULT_ZOOM_PERCENT; + } + pub fn set_split_id(&mut self, split_id: SplitId) { self.split_id = Some(split_id); } @@ -230,3 +266,11 @@ impl BrowserTab { self.sync_enabled = sync_enabled; } } + +pub fn validate_zoom_percent(value: u16) -> Result { + if (MIN_ZOOM_PERCENT..=MAX_ZOOM_PERCENT).contains(&value) { + return Ok(value); + } + + Err(DomainError::InvalidZoomPercent { value, min: MIN_ZOOM_PERCENT, max: MAX_ZOOM_PERCENT }) +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs index 3aa591e..8cba947 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -6,9 +6,9 @@ use std::{ use ely_domain::TabId; use ely_servo_host::{ - KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PermissionRequest, - ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, - WebViewSnapshot, WebViewState, + KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest, + PermissionRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, + SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState, }; use thiserror::Error; @@ -59,6 +59,10 @@ fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> { let tab_id = TabId::new(); let webview_id = host.create_webview(tab_id.clone(), args.profile_id.clone())?; apply_site_permissions(&mut host, &webview_id, &args)?; + host.set_page_zoom(PageZoomRequest { + webview_id: webview_id.clone(), + zoom_factor: f32::from(args.page_zoom_percent) / 100.0, + })?; host.navigate(NavigationRequest { webview_id: webview_id.clone(), diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs index 19c83da..4767914 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs @@ -1,6 +1,9 @@ use std::{env, num::ParseIntError, path::PathBuf}; -use ely_domain::{ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText}; +use ely_domain::{ + DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, + UrlText, validate_zoom_percent, +}; use serde::Deserialize; use thiserror::Error; @@ -17,6 +20,7 @@ pub(super) struct SnapshotArgs { pub(super) height: u32, pub(super) scroll_x: i32, pub(super) scroll_y: i32, + pub(super) page_zoom_percent: u16, pub(super) click_point: Option, pub(super) drag_points: Option, pub(super) touch_point: Option, @@ -122,6 +126,7 @@ fn parse_snapshot_args( let mut height = None; let mut scroll_x = 0; let mut scroll_y = 0; + let mut page_zoom_percent = DEFAULT_ZOOM_PERCENT; let mut click_x = None; let mut click_y = None; let mut drag_from_x = None; @@ -162,6 +167,12 @@ fn parse_snapshot_args( scroll_y = parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)? } + "--page-zoom-percent" => { + page_zoom_percent = parse_zoom_percent( + "--page-zoom-percent", + next_argument(&mut args, "--page-zoom-percent")?, + )? + } "--click-x" => { click_x = Some(parse_click_coordinate( "--click-x", @@ -248,6 +259,7 @@ fn parse_snapshot_args( height: height.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--height" })?, scroll_x, scroll_y, + page_zoom_percent, click_point, drag_points, touch_point, @@ -302,6 +314,15 @@ fn parse_click_coordinate(name: &'static str, value: String) -> Result().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source }) } +fn parse_zoom_percent(name: &'static str, value: String) -> Result { + let percent = value.parse::().map_err(|source| SidecarArgsError::InvalidInteger { + name, + value, + source, + })?; + Ok(validate_zoom_percent(percent)?) +} + fn parse_path(name: &'static str, value: String) -> Result { if value.trim().is_empty() { return Err(SidecarArgsError::EmptyPath { name }); @@ -315,7 +336,9 @@ mod tests { use std::{env, path::PathBuf}; use super::{SidecarArgsError, SidecarCommand, parse_command}; - use ely_domain::{DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature}; + use ely_domain::{ + DEFAULT_ZOOM_PERCENT, DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature, + }; #[test] fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> { @@ -326,6 +349,7 @@ mod tests { let SidecarCommand::Snapshot(args) = command; assert_eq!(args.profile_id, profile_id); assert_eq!(args.profile_data_dir, profile_data_dir); + assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT); Ok(()) } @@ -378,6 +402,33 @@ mod tests { Ok(()) } + #[test] + fn parses_snapshot_page_zoom_percent() -> Result<(), SidecarArgsError> { + let profile_id = ProfileId::new(); + let profile_data_dir = env::temp_dir().join(profile_id.as_str()); + let mut command = snapshot_command_args(&profile_id, profile_data_dir); + command.push("--page-zoom-percent".to_string()); + command.push("125".to_string()); + + let SidecarCommand::Snapshot(args) = parse_command(command)?; + assert_eq!(args.page_zoom_percent, 125); + Ok(()) + } + + #[test] + fn rejects_out_of_range_snapshot_page_zoom_percent() { + let profile_id = ProfileId::new(); + let profile_data_dir = env::temp_dir().join(profile_id.as_str()); + let mut command = snapshot_command_args(&profile_id, profile_data_dir); + command.push("--page-zoom-percent".to_string()); + command.push("5".to_string()); + + assert!(matches!( + parse_command(command), + Err(SidecarArgsError::Domain(DomainError::InvalidZoomPercent { value: 5, .. })) + )); + } + fn parse_snapshot_command( profile_id: &ProfileId, profile_data_dir: PathBuf, diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs index ca7adaf..9584d7b 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/report.rs @@ -28,6 +28,7 @@ pub(super) struct SnapshotReport { sample_hash: u64, scroll_x: i32, scroll_y: i32, + page_zoom_percent: u16, scroll_changed_frame: bool, click_x: Option, click_y: Option, @@ -67,6 +68,7 @@ impl SnapshotReport { sample_hash: frame.sample_hash(), scroll_x: args.scroll_x, scroll_y: args.scroll_y, + page_zoom_percent: args.page_zoom_percent, scroll_changed_frame: changes.scroll, click_x: args.click_point.map(|point| point.x), click_y: args.click_point.map(|point| point.y), diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index 7e00c6c..639354b 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -229,6 +229,12 @@ pub struct ResizeRequest { pub height: u32, } +#[derive(Clone, Debug, PartialEq)] +pub struct PageZoomRequest { + pub webview_id: WebViewId, + pub zoom_factor: f32, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct MouseClickRequest { pub webview_id: WebViewId, @@ -301,6 +307,8 @@ pub trait ServoHost { fn resize(&mut self, request: ResizeRequest) -> Result<(), ServoHostError>; + fn set_page_zoom(&mut self, request: PageZoomRequest) -> Result<(), ServoHostError>; + fn click(&mut self, request: MouseClickRequest) -> Result<(), ServoHostError>; fn drag(&mut self, request: MouseDragRequest) -> Result<(), ServoHostError>; diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index c0455f3..3aa6b12 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -10,10 +10,12 @@ mod runtime_input; mod runtime_permissions; #[cfg(feature = "servo-engine")] mod runtime_waker; +#[cfg(feature = "servo-engine")] +mod runtime_webview; pub use error::ServoHostError; pub use host::{ - KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, + KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame, RenderedFrameSummary, ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, TouchTapRequest, WebViewSnapshot, WebViewState, }; diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index 3272903..bc9de07 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -1,5 +1,5 @@ use std::{ - cell::{Cell, RefCell}, + cell::RefCell, collections::HashMap, path::PathBuf, rc::Rc, @@ -14,21 +14,19 @@ use std::{ use dpi::PhysicalSize; use ely_domain::{ProfileId, TabId, WebViewId}; use servo::{ - DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePoint, DeviceVector2D, LoadStatus, Opts, - RenderingContext, Scroll, Servo, ServoBuilder, WebView, WebViewBuilder, WebViewDelegate, - WebViewPoint, WebViewVector, + DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePoint, DeviceVector2D, Opts, + RenderingContext, Scroll, Servo, ServoBuilder, WebViewBuilder, WebViewPoint, WebViewVector, }; use url::Url; use crate::{ - KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, + 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_permissions::{ - PermissionStore, permission_decision_for_webview, set_permission_decision, - }, + runtime_permissions::{PermissionStore, set_permission_decision}, runtime_waker::ServoWakeFlag, + runtime_webview::{HostWebView, HostWebViewDelegate}, }; static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false); @@ -194,6 +192,16 @@ impl ServoHost for SoftwareServoHost { Ok(()) } + fn set_page_zoom(&mut self, request: PageZoomRequest) -> Result<(), ServoHostError> { + let webview = self + .webviews + .get(&request.webview_id) + .ok_or_else(|| ServoHostError::WebViewNotFound { id: request.webview_id.clone() })?; + + webview.webview.set_page_zoom(request.zoom_factor); + Ok(()) + } + fn click(&mut self, request: MouseClickRequest) -> Result<(), ServoHostError> { let webview = self .webviews @@ -356,141 +364,3 @@ impl SoftwareServoHost { Ok(RenderedFrame::from_rgba_bytes(size.width, size.height, image.into_raw())) } } - -struct HostWebView { - tab_id: TabId, - profile_id: ProfileId, - webview: WebView, - delegate: Rc, - requested_url: Option, -} - -impl HostWebView { - fn snapshot(&self, webview_id: &WebViewId) -> WebViewSnapshot { - WebViewSnapshot::new( - webview_id.clone(), - self.tab_id.clone(), - self.profile_id.clone(), - self.state(), - self.current_url(), - self.current_title(), - self.delegate.has_pending_frame(), - ) - } - - fn state(&self) -> WebViewState { - let state = self.delegate.state(); - if matches!(state, WebViewState::Crashed | WebViewState::Sleeping) { - return state; - } - - if let Some(requested_url) = &self.requested_url - && self.current_url().as_deref() != Some(requested_url.as_str()) - { - return WebViewState::Loading; - } - - state - } - - fn current_url(&self) -> Option { - self.webview.url().map(|url| url.to_string()).or_else(|| self.delegate.url()) - } - - fn current_title(&self) -> Option { - self.webview.page_title().or_else(|| self.delegate.title()) - } -} - -struct HostWebViewDelegate { - profile_id: ProfileId, - permissions: PermissionStore, - state: RefCell, - url: RefCell>, - title: RefCell>, - has_pending_frame: Cell, -} - -impl HostWebViewDelegate { - fn new(profile_id: ProfileId, permissions: PermissionStore) -> Self { - Self { - profile_id, - permissions, - state: RefCell::new(WebViewState::Created), - url: RefCell::new(None), - title: RefCell::new(None), - has_pending_frame: Cell::new(false), - } - } - - fn set_state(&self, state: WebViewState) { - self.state.replace(state); - } - - fn state(&self) -> WebViewState { - self.state.borrow().clone() - } - - fn url(&self) -> Option { - self.url.borrow().clone() - } - - fn title(&self) -> Option { - self.title.borrow().clone() - } - - fn has_pending_frame(&self) -> bool { - self.has_pending_frame.get() - } - - fn mark_frame_presented(&self) { - self.has_pending_frame.set(false); - } -} - -impl WebViewDelegate for HostWebViewDelegate { - fn notify_url_changed(&self, _webview: WebView, url: Url) { - self.url.replace(Some(url.to_string())); - } - - fn notify_page_title_changed(&self, _webview: WebView, title: Option) { - self.title.replace(title); - } - - fn notify_load_status_changed(&self, _webview: WebView, status: LoadStatus) { - let state = match status { - LoadStatus::Started | LoadStatus::HeadParsed => WebViewState::Loading, - LoadStatus::Complete => WebViewState::Complete, - }; - self.set_state(state); - } - - fn notify_new_frame_ready(&self, _webview: WebView) { - self.has_pending_frame.set(true); - } - - fn notify_crashed(&self, _webview: WebView, _reason: String, _backtrace: Option) { - self.set_state(WebViewState::Crashed); - } - - fn request_navigation(&self, _webview: WebView, navigation_request: servo::NavigationRequest) { - navigation_request.allow(); - } - - fn request_permission(&self, webview: WebView, permission_request: servo::PermissionRequest) { - match permission_decision_for_webview( - &self.permissions, - &self.profile_id, - &webview, - self.url(), - permission_request.feature(), - ) { - Some(PermissionDecision::AllowOnce | PermissionDecision::AllowAlways) => { - permission_request.allow(); - } - Some(PermissionDecision::DenyAlways) | None => { - permission_request.deny(); - } - } - } -} diff --git a/crates/ely_servo_host/src/runtime_webview.rs b/crates/ely_servo_host/src/runtime_webview.rs new file mode 100644 index 0000000..d232d45 --- /dev/null +++ b/crates/ely_servo_host/src/runtime_webview.rs @@ -0,0 +1,148 @@ +use std::{cell::Cell, cell::RefCell, rc::Rc}; + +use ely_domain::{ProfileId, TabId, WebViewId}; +use servo::{LoadStatus, WebView, WebViewDelegate}; +use url::Url; + +use crate::{ + PermissionDecision, WebViewSnapshot, WebViewState, + runtime_permissions::{PermissionStore, permission_decision_for_webview}, +}; + +pub(super) struct HostWebView { + pub(super) tab_id: TabId, + pub(super) profile_id: ProfileId, + pub(super) webview: WebView, + pub(super) delegate: Rc, + pub(super) requested_url: Option, +} + +impl HostWebView { + pub(super) fn snapshot(&self, webview_id: &WebViewId) -> WebViewSnapshot { + WebViewSnapshot::new( + webview_id.clone(), + self.tab_id.clone(), + self.profile_id.clone(), + self.state(), + self.current_url(), + self.current_title(), + self.delegate.has_pending_frame(), + ) + } + + pub(super) fn state(&self) -> WebViewState { + let state = self.delegate.state(); + if matches!(state, WebViewState::Crashed | WebViewState::Sleeping) { + return state; + } + + if let Some(requested_url) = &self.requested_url + && self.current_url().as_deref() != Some(requested_url.as_str()) + { + return WebViewState::Loading; + } + + state + } + + pub(super) fn current_url(&self) -> Option { + self.webview.url().map(|url| url.to_string()).or_else(|| self.delegate.url()) + } + + fn current_title(&self) -> Option { + self.webview.page_title().or_else(|| self.delegate.title()) + } +} + +pub(super) struct HostWebViewDelegate { + profile_id: ProfileId, + permissions: PermissionStore, + state: RefCell, + url: RefCell>, + title: RefCell>, + has_pending_frame: Cell, +} + +impl HostWebViewDelegate { + pub(super) fn new(profile_id: ProfileId, permissions: PermissionStore) -> Self { + Self { + profile_id, + permissions, + state: RefCell::new(WebViewState::Created), + url: RefCell::new(None), + title: RefCell::new(None), + has_pending_frame: Cell::new(false), + } + } + + pub(super) fn set_state(&self, state: WebViewState) { + self.state.replace(state); + } + + fn state(&self) -> WebViewState { + self.state.borrow().clone() + } + + fn url(&self) -> Option { + self.url.borrow().clone() + } + + fn title(&self) -> Option { + self.title.borrow().clone() + } + + fn has_pending_frame(&self) -> bool { + self.has_pending_frame.get() + } + + pub(super) fn mark_frame_presented(&self) { + self.has_pending_frame.set(false); + } +} + +impl WebViewDelegate for HostWebViewDelegate { + fn notify_url_changed(&self, _webview: WebView, url: Url) { + self.url.replace(Some(url.to_string())); + } + + fn notify_page_title_changed(&self, _webview: WebView, title: Option) { + self.title.replace(title); + } + + fn notify_load_status_changed(&self, _webview: WebView, status: LoadStatus) { + let state = match status { + LoadStatus::Started | LoadStatus::HeadParsed => WebViewState::Loading, + LoadStatus::Complete => WebViewState::Complete, + }; + self.set_state(state); + } + + fn notify_new_frame_ready(&self, _webview: WebView) { + self.has_pending_frame.set(true); + } + + fn notify_crashed(&self, _webview: WebView, _reason: String, _backtrace: Option) { + self.set_state(WebViewState::Crashed); + } + + fn request_navigation(&self, _webview: WebView, navigation_request: servo::NavigationRequest) { + navigation_request.allow(); + } + + fn request_permission(&self, webview: WebView, permission_request: servo::PermissionRequest) { + match permission_decision_for_webview( + &self.permissions, + &self.profile_id, + &webview, + self.url(), + permission_request.feature(), + ) { + Some(PermissionDecision::AllowOnce | PermissionDecision::AllowAlways) => { + permission_request.allow(); + } + Some(PermissionDecision::DenyAlways) | None => { + permission_request.deny(); + } + } + } +} diff --git a/crates/ely_servo_host/tests/software_host.rs b/crates/ely_servo_host/tests/software_host.rs index 1161820..aac8b4e 100644 --- a/crates/ely_servo_host/tests/software_host.rs +++ b/crates/ely_servo_host/tests/software_host.rs @@ -10,7 +10,7 @@ use std::{ use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText}; use ely_servo_host::{ - KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, + KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState, }; @@ -120,6 +120,18 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box> { ); assert_rendered_frame_has_content(&host, "data:text/html", 1)?; + let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); + host.set_page_zoom(PageZoomRequest { webview_id: webview_id.clone(), zoom_factor: 1.25 })?; + let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?; + assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}"); + assert_rendered_frame_has_content(&host, "data:text/html zoomed", 1)?; + assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash); + + let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); + host.set_page_zoom(PageZoomRequest { webview_id: webview_id.clone(), zoom_factor: 1.0 })?; + let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?; + assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}"); + let previous_frame_hash = host.last_rendered_frame()?.sample_hash(); host.click(MouseClickRequest { webview_id: webview_id.clone(), x: 160, y: 120 })?; let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;