diff --git a/.gitignore b/.gitignore index 72de5f5..519b7e3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,10 @@ target Cargo.lock .DS_Store .vscode/ -resources/ .flatpak-builder/ libmozjs* cargo-sources.json + +resources/ +!resources/panel.html +!resources/context-menu.html \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 804e99b..0ff3c0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6682,6 +6682,8 @@ dependencies = [ "raw-window-handle", "script", "script_traits", + "serde", + "serde_json", "servo-media", "servo-media-dummy", "servo_config", diff --git a/Cargo.toml b/Cargo.toml index 6719a66..3350426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,6 +107,10 @@ url = { workspace = true } headers = "0.3" versoview_messages = { path = "./versoview_messages" } +[target.'cfg(all(unix, not(apple), not(android)))'.dependencies] +serde_json = "1.0.132" +serde = { workspace = true } + [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] muda = "0.15" diff --git a/resources/context_menu.html b/resources/context_menu.html new file mode 100644 index 0000000..765b5f9 --- /dev/null +++ b/resources/context_menu.html @@ -0,0 +1,81 @@ + + + + + + + + + diff --git a/src/compositor.rs b/src/compositor.rs index 2f08194..f5273b8 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -1106,6 +1106,10 @@ impl IOCompositor { if close_window { window_id = Some(window.id()); + } else { + // if the window is not closed, we need to update the display list + // to remove the webview from viewport + self.send_root_pipeline_display_list(window); } self.frame_tree_id.next(); @@ -1329,7 +1333,8 @@ impl IOCompositor { } } - fn hit_test_at_point(&self, point: DevicePoint) -> Option { + /// TODO: doc + pub fn hit_test_at_point(&self, point: DevicePoint) -> Option { return self .hit_test_at_point_with_flags_and_pipeline(point, HitTestFlags::empty(), None) .first() @@ -2158,6 +2163,24 @@ impl IOCompositor { self.webrender_api .send_transaction(self.webrender_document, transaction); } + + /// Get webview id on the position + pub fn webview_id_on_position( + &self, + position: DevicePoint, + ) -> Option { + let hit_result: Option = self.hit_test_at_point(position); + if let Some(result) = hit_result { + let pipeline_id = result.pipeline_id; + for (w_id, p_id) in &self.webviews { + if *p_id == pipeline_id { + return Some(*w_id); + } + } + } + + None + } } #[derive(Debug, PartialEq)] diff --git a/src/context_menu.rs b/src/context_menu.rs index 968aa93..b1feb91 100644 --- a/src/context_menu.rs +++ b/src/context_menu.rs @@ -1,25 +1,69 @@ +/* macOS, Windows Native Implementation */ #[cfg(any(target_os = "macos", target_os = "windows"))] -use muda::{ContextMenu as MudaContextMenu, Menu}; +use muda::{ContextMenu as MudaContextMenu, Menu as MudaMenu}; #[cfg(any(target_os = "macos", target_os = "windows"))] use raw_window_handle::{HasWindowHandle, RawWindowHandle}; -/// Context Menu -#[cfg(any(target_os = "macos", target_os = "windows"))] -pub struct ContextMenu { - menu: Menu, -} - -/// Context Menu +/* Wayland Implementation */ +#[cfg(linux)] +use crate::{verso::send_to_constellation, webview::WebView, window::Window}; +#[cfg(linux)] +use base::id::WebViewId; +#[cfg(linux)] +use compositing_traits::ConstellationMsg; #[cfg(linux)] -pub struct ContextMenu {} +use crossbeam_channel::Sender; +#[cfg(linux)] +use euclid::{Point2D, Size2D}; +#[cfg(linux)] +use serde::{Deserialize, Serialize}; +#[cfg(linux)] +use servo_url::ServoUrl; +#[cfg(linux)] +use webrender_api::units::DeviceIntRect; +#[cfg(linux)] +use winit::dpi::PhysicalPosition; +/// Context Menu inner menu #[cfg(any(target_os = "macos", target_os = "windows"))] +pub struct Menu(pub MudaMenu); +/// Context Menu inner menu +#[cfg(linux)] +#[derive(Debug, Clone)] +pub struct Menu(pub Vec); + impl ContextMenu { /// Create context menu with custom items + /// + /// **Platform Specific** + /// - macOS / Windows: Creates a context menu by muda crate with natvie OS support + /// - Linux: Creates a context menu with webview implementation pub fn new_with_menu(menu: Menu) -> Self { - Self { menu } + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + Self { menu: menu.0 } + } + #[cfg(linux)] + { + let webview_id = WebViewId::new(); + let webview = WebView::new(webview_id, DeviceIntRect::zero()); + + Self { + menu_items: menu.0, + webview, + } + } } +} +/// Context Menu +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub struct ContextMenu { + menu: MudaMenu, +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +impl ContextMenu { /// Show the context menu on current cursor position /// /// This function returns when the context menu is dismissed @@ -48,3 +92,135 @@ impl ContextMenu { } } } + +/// Context Menu +#[cfg(linux)] +#[derive(Debug, Clone)] +pub struct ContextMenu { + menu_items: Vec, + /// The webview that the context menu is attached to + webview: WebView, +} + +#[cfg(linux)] +impl ContextMenu { + /// Show the context menu to current cursor position + pub fn show( + &mut self, + sender: &Sender, + window: &mut Window, + position: PhysicalPosition, + ) { + let scale_factor = window.scale_factor(); + self.set_position(window, position, scale_factor); + + send_to_constellation( + sender, + ConstellationMsg::NewWebView(self.resource_url(), self.webview.webview_id), + ); + } + + /// Get webview of the context menu + pub fn webview(&self) -> &WebView { + &self.webview + } + + /// Get resource URL of the context menu + fn resource_url(&self) -> ServoUrl { + let items_json: String = self.to_items_json(); + let url_str = format!("verso://context_menu.html?items={}", items_json); + ServoUrl::parse(&url_str).unwrap() + } + + /// Set the position of the context menu + fn set_position( + &mut self, + window: &Window, + position: PhysicalPosition, + scale_factor: f64, + ) { + // Calculate menu size + // Each menu item is 30px height + // Menu has 10px padding top and bottom + let height = (self.menu_items.len() * 30 + 20) as f64 * scale_factor; + let width = 200.0 * scale_factor; + let menu_size = Size2D::new(width as i32, height as i32); + + // Translate position to origin + let mut origin = Point2D::new(position.x as i32, position.y as i32); + + // Avoid overflow to the window, adjust position if necessary + let window_size = window.size(); + let x_overflow: i32 = origin.x + menu_size.width - window_size.width; + let y_overflow: i32 = origin.y + menu_size.height - window_size.height; + + if x_overflow >= 0 { + // check if the menu can be shown on left side of the cursor + if (origin.x - menu_size.width) >= 0 { + origin.x = i32::max(0, origin.x - menu_size.width); + } else { + // if menu can't fit to left side of the cursor, + // shift left the menu, but not less than zero. + // TODO: if still smaller than screen, should show scroller + origin.x = i32::max(0, origin.x - x_overflow); + } + } + if y_overflow >= 0 { + // check if the menu can be shown above the cursor + if (origin.y - menu_size.height) >= 0 { + origin.y = i32::max(0, origin.y - menu_size.height); + } else { + // if menu can't fit to top of the cursor + // shift up the menu, but not less than zero. + // TODO: if still smaller than screen, should show scroller + origin.y = i32::max(0, origin.y - y_overflow); + } + } + + self.webview + .set_size(DeviceIntRect::from_origin_and_size(origin, menu_size)); + } + + /// get item json + fn to_items_json(&self) -> String { + serde_json::to_string(&self.menu_items).unwrap() + } +} + +/// Menu Item +#[cfg(linux)] +#[derive(Debug, Clone, Serialize)] +pub struct MenuItem { + id: String, + /// label of the menu item + pub label: String, + /// Whether the menu item is enabled + pub enabled: bool, +} + +#[cfg(linux)] +impl MenuItem { + /// Create a new menu item + pub fn new(id: Option<&str>, label: &str, enabled: bool) -> Self { + let id = id.unwrap_or(label); + Self { + id: id.to_string(), + label: label.to_string(), + enabled, + } + } + /// Get the id of the menu item + pub fn id(&self) -> &str { + &self.id + } +} + +/// Context Menu Click Result +#[cfg(linux)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContextMenuClickResult { + /// The id of the menu ite /// Get the label of the menu item + pub id: String, + /// Close the context menu + pub close: bool, +} diff --git a/src/webview.rs b/src/webview.rs index 096e112..68513a0 100644 --- a/src/webview.rs +++ b/src/webview.rs @@ -14,6 +14,9 @@ use webrender_api::units::DeviceIntRect; use crate::{compositor::IOCompositor, verso::send_to_constellation, window::Window}; +#[cfg(linux)] +use crate::context_menu::ContextMenuClickResult; + /// A web view is an area to display web browsing context. It's what user will treat as a "web page". #[derive(Debug, Clone)] pub struct WebView { @@ -290,4 +293,36 @@ impl Window { } false } + + /// Handle servo messages with main panel. Return true it requests a new window. + #[cfg(linux)] + pub fn handle_servo_messages_with_context_menu( + &mut self, + webview_id: WebViewId, + message: EmbedderMsg, + sender: &Sender, + _clipboard: Option<&mut Clipboard>, + _compositor: &mut IOCompositor, + ) -> bool { + log::trace!("Verso Context Menu {webview_id:?} is handling Embedder message: {message:?}",); + match message { + EmbedderMsg::Prompt(definition, _origin) => match definition { + PromptDefinition::Input(msg, _, prompt_sender) => { + let _ = prompt_sender.send(None); + if msg.starts_with("CONTEXT_MENU:") { + let json_str_msg = msg.strip_prefix("CONTEXT_MENU:").unwrap(); + let result = + serde_json::from_str::(json_str_msg).unwrap(); + + self.handle_context_menu_event(sender, result); + } + } + _ => log::trace!("Verso Panel isn't supporting this prompt yet"), + }, + e => { + log::trace!("Verso Panel isn't supporting this message yet: {e:?}") + } + } + false + } } diff --git a/src/window.rs b/src/window.rs index 20a5898..35eeb74 100644 --- a/src/window.rs +++ b/src/window.rs @@ -11,10 +11,9 @@ use glutin::{ }; use glutin_winit::DisplayBuilder; #[cfg(any(target_os = "macos", target_os = "windows"))] -use muda::{Menu, MenuEvent, MenuEventReceiver, MenuItem}; +use muda::{Menu as MudaMenu, MenuEvent, MenuEventReceiver, MenuItem}; #[cfg(any(target_os = "macos", target_os = "windows"))] use raw_window_handle::HasWindowHandle; -#[cfg(any(target_os = "macos", target_os = "windows"))] use script_traits::TraversalDirection; use script_traits::{TouchEventType, WheelDelta, WheelMode}; use servo_url::ServoUrl; @@ -32,10 +31,9 @@ use winit::{ window::{CursorIcon, Window as WinitWindow, WindowAttributes, WindowId}, }; -#[cfg(any(target_os = "macos", target_os = "windows"))] -use crate::context_menu::ContextMenu; use crate::{ compositor::{IOCompositor, MouseWindowEvent}, + context_menu::{ContextMenu, Menu}, keyboard::keyboard_event_from_winit, rendering::{gl_config_picker, RenderingContext}, verso::send_to_constellation, @@ -58,12 +56,21 @@ pub struct Window { mouse_position: Cell>>, /// Modifiers state of the keyboard. modifiers_state: Cell, + /// Browser history of the window. history: Vec, + /// Current history index. current_history_index: usize, /// State to indicate if the window is resizing. pub(crate) resizing: bool, - /// Global menu evnet receiver for muda crate + /// dialog webviews + dialog_webviews: Vec, + + /// Linux context_menu + #[cfg(linux)] + pub(crate) context_menu: Option, + + /// Global menu event receiver for muda crate #[cfg(any(target_os = "macos", target_os = "windows"))] menu_event_receiver: MenuEventReceiver, } @@ -116,6 +123,9 @@ impl Window { history: vec![], current_history_index: 0, resizing: false, + dialog_webviews: vec![], + #[cfg(linux)] + context_menu: None, #[cfg(any(target_os = "macos", target_os = "windows"))] menu_event_receiver: MenuEvent::receiver().clone(), }, @@ -157,6 +167,9 @@ impl Window { history: vec![], current_history_index: 0, resizing: false, + dialog_webviews: vec![], + #[cfg(linux)] + context_menu: None, #[cfg(any(target_os = "macos", target_os = "windows"))] menu_event_receiver: MenuEvent::receiver().clone(), }; @@ -272,17 +285,6 @@ impl Window { } } WindowEvent::MouseInput { state, button, .. } => { - // handle context menu - // TODO: should create on ShowContextMenu event - #[cfg(any(target_os = "macos", target_os = "windows"))] - if *button == winit::event::MouseButton::Right && *state == ElementState::Pressed { - self.show_context_menu(); - // FIXME: there's chance to lose the event since the channel is async. - if let Ok(event) = self.menu_event_receiver.try_recv() { - self.handle_context_menu_event(sender, event); - } - } - let position = match self.mouse_position.get() { Some(position) => Point2D::new(position.x as f32, position.y as f32), None => { @@ -291,7 +293,59 @@ impl Window { } }; - // handle Windows and Linux non-decoration window resize + /* handle context menu */ + // TODO(context-menu): should create on ShowContextMenu event + + #[cfg(linux)] + { + let is_click_on_context_menu = + self.is_position_on_context_menu(compositor, position); + + if !is_click_on_context_menu { + if *state == ElementState::Pressed { + match *button { + winit::event::MouseButton::Left => { + if self.close_context_menu(sender) { + // return here to bypass following mouse event for underlying element + return; + } + } + winit::event::MouseButton::Right => { + // Close old context menu + self.close_context_menu(sender); + // Create new context menu + self.context_menu = Some(self.show_context_menu(sender)); + return; + } + _ => {} + } + } else if *state == ElementState::Released { + match *button { + winit::event::MouseButton::Right => { + if self.context_menu.is_some() { + return; + } + } + _ => {} + } + } + } + // TODO(context-menu): ignore first release event after context menu open or close to prevent click on background element + } + + #[cfg(any(target_os = "macos", target_os = "windows"))] + if *button == winit::event::MouseButton::Right && *state == ElementState::Pressed { + { + self.show_context_menu(); + // FIXME: there's chance to lose the event since the channel is async. + if let Ok(event) = self.menu_event_receiver.try_recv() { + self.handle_context_menu_event(sender, event); + } + } + } + + /* handle Windows and Linux non-decoration window resize */ + #[cfg(any(linux, target_os = "windows"))] { if *state == ElementState::Pressed && *button == winit::event::MouseButton::Left @@ -302,6 +356,8 @@ impl Window { } } + /* handle mouse events */ + let button: script_traits::MouseButton = match button { winit::event::MouseButton::Left => script_traits::MouseButton::Left, winit::event::MouseButton::Right => script_traits::MouseButton::Right, @@ -409,6 +465,15 @@ impl Window { ); } } + #[cfg(linux)] + if let Some(context_menu) = &self.context_menu { + if context_menu.webview().webview_id == webview_id { + self.handle_servo_messages_with_context_menu( + webview_id, message, sender, clipboard, compositor, + ); + return false; + } + } // Handle message in Verso WebView self.handle_servo_messages_with_webview(webview_id, message, sender, clipboard, compositor); false @@ -435,12 +500,40 @@ impl Window { self.window.scale_factor() } + /// Append a dialog webview to the window. + pub fn append_dialog_webview(&mut self, webview: WebView) { + self.dialog_webviews.push(webview); + } + + /// Remove a dialog webview from the window. + pub fn remove_dialog_webview(&mut self, id: WebViewId) { + self.dialog_webviews.retain(|w| w.webview_id != id); + } + + /// Check has dialog webview in the window. + fn has_dialog_webview(&self, id: WebViewId) -> bool { + self.dialog_webviews + .iter() + .find(|w| w.webview_id == id) + .is_some() + } + /// Check if the window has such webview. pub fn has_webview(&self, id: WebViewId) -> bool { + #[cfg(linux)] + if self + .context_menu + .as_ref() + .map_or(false, |w| w.webview().webview_id == id) + { + return true; + } + self.panel .as_ref() .map_or(false, |w| w.webview.webview_id == id) || self.webview.as_ref().map_or(false, |w| w.webview_id == id) + || self.has_dialog_webview(id) } /// Remove the webview in this window by provided webview ID. If this is the panel, it will @@ -450,6 +543,17 @@ impl Window { id: WebViewId, compositor: &mut IOCompositor, ) -> (Option, bool) { + #[cfg(linux)] + if self + .context_menu + .as_ref() + .filter(|menu| menu.webview().webview_id == id) + .is_some() + { + let context_menu = self.context_menu.take().expect("Context menu should exist"); + return (Some(context_menu.webview().clone()), false); + } + if self .panel .as_ref() @@ -470,6 +574,9 @@ impl Window { .is_some() { (self.webview.take(), self.panel.is_none()) + } else if let Some(index) = self.dialog_webviews.iter().position(|w| w.webview_id == id) { + let webview = self.dialog_webviews.remove(index); + (Some(webview), false) } else { (None, false) } @@ -484,6 +591,14 @@ impl Window { if let Some(webview) = &self.webview { order.push(webview); } + + self.dialog_webviews.iter().for_each(|w| order.push(w)); + + #[cfg(linux)] + if let Some(context_menu) = &self.context_menu { + order.push(context_menu.webview()); + } + order } @@ -537,8 +652,8 @@ impl Window { } // Dummy Context Menu -#[cfg(any(target_os = "macos", target_os = "windows"))] impl Window { + #[cfg(any(target_os = "macos", target_os = "windows"))] pub(crate) fn show_context_menu(&self) { let history_len = self.history.len(); @@ -552,12 +667,14 @@ impl Window { ); let reload = MenuItem::with_id("reload", "Reload", true, None); - let menu = Menu::new(); + let menu = MudaMenu::new(); let _ = menu.append_items(&[&back, &forward, &reload]); - let context_menu = ContextMenu::new_with_menu(menu); + let context_menu = ContextMenu::new_with_menu(Menu(menu)); context_menu.show(self.window.window_handle().unwrap()); } + + #[cfg(any(target_os = "macos", target_os = "windows"))] fn handle_context_menu_event(&self, sender: &Sender, event: MenuEvent) { // TODO: should be more flexible to handle different menu items match event.id().0.as_str() { @@ -588,6 +705,108 @@ impl Window { _ => {} } } + + #[cfg(linux)] + pub(crate) fn show_context_menu(&mut self, sender: &Sender) -> ContextMenu { + use crate::context_menu::MenuItem; + + let history_len = self.history.len(); + + // items + let back = MenuItem::new(Some("back"), "Back", self.current_history_index > 0); + let forward = MenuItem::new( + Some("forward"), + "Forward", + self.current_history_index + 1 < history_len, + ); + let reload = MenuItem::new(Some("reload"), "Reload", true); + + let mut context_menu = ContextMenu::new_with_menu(Menu([back, forward, reload].to_vec())); + + let position = self.mouse_position.get().unwrap(); + context_menu.show(sender, self, position); + + context_menu + } + + /// Close the context menu + /// + /// If context menu exists, return true. + #[cfg(linux)] + pub(crate) fn close_context_menu(&self, sender: &Sender) -> bool { + if let Some(context_menu) = &self.context_menu { + send_to_constellation( + sender, + ConstellationMsg::CloseWebView(context_menu.webview().webview_id), + ); + return true; + } + false + } + + #[cfg(linux)] + fn is_position_on_context_menu( + &self, + compositor: &mut IOCompositor, + position: DevicePoint, + ) -> bool { + if let Some(webview_id) = compositor.webview_id_on_position(position) { + return self + .context_menu + .as_ref() + .and_then(|context_menu| { + if context_menu.webview().webview_id == webview_id { + return Some(true); + } + None + }) + .unwrap_or(false); + } + false + } + + /// Handle linux context menu event + // TODO(context-menu): should make the call in synchronous way after calling show_context_menu, otherwise + // we'll have to deal with constellation sender and other parameter's lifetime, also we lose the context that why this context menu popup + #[cfg(linux)] + pub(crate) fn handle_context_menu_event( + &mut self, + sender: &Sender, + event: crate::context_menu::ContextMenuClickResult, + ) { + // FIXME: (context-menu) find the reason that close after doing action (traverse history) will hang the window + // Close context menu somehow must put before other actions, or it will hang the window + if event.close { + self.close_context_menu(sender); + } + match event.id.as_str() { + "back" => { + send_to_constellation( + sender, + ConstellationMsg::TraverseHistory( + self.webview.as_ref().unwrap().webview_id, + TraversalDirection::Back(1), + ), + ); + } + "forward" => { + send_to_constellation( + sender, + ConstellationMsg::TraverseHistory( + self.webview.as_ref().unwrap().webview_id, + TraversalDirection::Forward(1), + ), + ); + } + "reload" => { + send_to_constellation( + sender, + ConstellationMsg::Reload(self.webview.as_ref().unwrap().webview_id), + ); + } + _ => {} + } + } } // Non-decorated window resizing for Windows and Linux.