use std::rc::Rc; use core_video::pixel_buffer::CVPixelBuffer; use euclid::Scale; use futures::{FutureExt, select_biased}; use gpui::{Context, CursorStyle, EventEmitter, Task, Window}; use servo::{ Code, ContextMenu, ContextMenuAction, ContextMenuItem, Cursor, DevicePoint, EditingActionEvent, EventLoopWaker, InputEvent, Key, KeyState, KeyboardEvent, LoadStatus, Location, Modifiers, MouseButton, MouseButtonAction, MouseButtonEvent, MouseMoveEvent, NamedKey, Opts, RenderingContext, Servo, ServoBuilder, SimpleDialog, WebView, WebViewBuilder, WebViewPoint, WheelDelta, WheelEvent, WheelMode, }; use tracing::{debug, info, trace, warn}; use url::Url; use crate::{ bridge::IoSurfaceBridge, settings::ServoConfig, sink::{EngineEvent, EngineSink}, webview::WebInput, }; const REPAINT_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(10); const REPAINT_DEBOUNCE_FALLBACK: std::time::Duration = std::time::Duration::from_millis(100); #[derive(Clone)] struct ChannelWaker(async_channel::Sender<()>); impl EventLoopWaker for ChannelWaker { fn clone_box(&self) -> Box { Box::new(self.clone()) } fn wake(&self) { let _ = self.0.try_send(()); } } /// Notifications emitted by a [`ServoEngine`], e.g. so a host can sync a URL /// bar or window title. #[derive(Debug)] pub enum WebViewEvent { UrlChanged(Url), TitleChanged(Option), LoadStatusChanged(LoadStatus), HistoryChanged { can_go_back: bool, can_go_forward: bool, }, Crashed(String), Closed, /// The page requested a new window/tab (e.g. `window.open`). The base /// component does not open it; hosts can listen and act. NewWindowRequested, /// A context menu is available via [`ServoEngine::context_menu_items`]. ContextMenuShown, /// A simple dialog (alert/confirm/prompt) is available. DialogShown, DialogDismissed, MediaPlaybackChanged(bool), ConsoleMessage(String), } /// A single-webview Servo engine, driven by a gpui entity. /// /// The engine owns the servo instance, the offscreen rendering surface and the /// event pump. Render it with [`crate::WebView`] and feed it input through /// [`ServoEngine::send_input`]. pub struct ServoEngine { config: Rc, servo: Option, webview: Option, surface: Option>, sink: Rc, context_menu: Option, dialog: Option, modifiers: Modifiers, media_playing: bool, can_go_back: bool, can_go_forward: bool, loading: bool, // nudges the pump when we hand servo work ourselves (input, navigation). wake: async_channel::Sender<()>, // held so the pump stops when the engine is dropped. _pump_task: Task<()>, surface_size: dpi::PhysicalSize, scale_factor: f32, } impl EventEmitter for ServoEngine {} impl ServoEngine { /// Create the engine and open `initial_url` (or `about:blank`). pub fn new( initial_url: Option, config: ServoConfig, window: &mut Window, cx: &mut Context, ) -> Self { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let config = Rc::new(config); let scale_factor = window.scale_factor(); let surface_size = initial_physical_size(window); let _ = std::fs::create_dir_all(&config.data_dir); let (wake_tx, wake_rx) = async_channel::bounded(1); // TODO: what if library consumers supplied a servo instead let servo = ServoBuilder::default() .opts(Opts { config_dir: Some(config.data_dir.clone()), ..Opts::default() }) .preferences(config.preferences()) .event_loop_waker(Box::new(ChannelWaker(wake_tx.clone()))) .build(); info!(?surface_size, scale_factor, "servo initialized"); // sleep until servo (or our own input path) has work, then coalesce the burst. let pump_task = cx.spawn(async move |this, cx| { loop { let wake = wake_rx.recv().fuse(); let fallback = cx .background_executor() .timer(REPAINT_DEBOUNCE_FALLBACK) .fuse(); futures::pin_mut!(wake, fallback); select_biased! { result = wake => if result.is_err() { break }, _ = fallback => {} } // let a burst of wakes settle so one repaint covers all of them. cx.background_executor().timer(REPAINT_DEBOUNCE).await; while wake_rx.try_recv().is_ok() {} if this.update(cx, |engine, cx| engine.pump(cx)).is_err() { break; } } }); cx.on_app_quit(|engine, _cx| { engine.shutdown(); async {} }) .detach(); let mut engine = Self { config, servo: Some(servo), webview: None, surface: None, sink: Rc::new(EngineSink::default()), context_menu: None, dialog: None, modifiers: Modifiers::empty(), media_playing: false, can_go_back: false, can_go_forward: false, loading: false, wake: wake_tx, _pump_task: pump_task, surface_size, scale_factor, }; engine.open(initial_url.unwrap_or_else(default_url)); engine } /// Tear down the servo instance. Called automatically on app quit. pub fn shutdown(&mut self) { let Some(servo) = self.servo.take() else { return; }; info!("shutting down web engine"); self.context_menu = None; self.dialog = None; self.webview = None; self.surface = None; drop(servo); info!("web engine shut down"); } fn open(&mut self, url: Url) { let servo = self.servo.clone().expect("engine already shut down"); let surface = IoSurfaceBridge::new(self.surface_size).expect("create IOSurface"); let webview = WebViewBuilder::new(&servo, surface.clone()) .url(url) .hidpi_scale_factor(Scale::new(self.scale_factor)) .delegate(self.sink.clone()) .build(); webview.resize(self.surface_size); webview.notify_theme_change(self.config.color_scheme); self.webview = Some(webview); self.surface = Some(surface); self.nudge(); } /// Navigate the current page to `url`. pub fn load(&mut self, url: Url) { info!(%url, "navigating"); if let Some(webview) = &self.webview { webview.load(url); } self.loading = true; self.nudge(); } pub fn go_back(&mut self) { if let Some(webview) = &self.webview { webview.go_back(1); } self.nudge(); } pub fn go_forward(&mut self) { if let Some(webview) = &self.webview { webview.go_forward(1); } self.nudge(); } pub fn reload(&mut self) { if let Some(webview) = &self.webview { webview.reload(); } self.loading = true; self.nudge(); } /// The current page URL. pub fn url(&self) -> Option { self.webview.as_ref().and_then(WebView::url) } /// The current page title. pub fn title(&self) -> Option { self.webview.as_ref().and_then(WebView::page_title) } pub fn can_go_back(&self) -> bool { self.can_go_back } pub fn can_go_forward(&self) -> bool { self.can_go_forward } pub fn is_loading(&self) -> bool { self.loading } pub fn is_media_playing(&self) -> bool { self.media_playing } /// The latest rendered frame, to be handed to `Window::paint_surface`. pub fn frame(&self) -> Option { self.surface.as_ref()?.current_pixel_buffer() } /// The CSS cursor the page wants the embedding surface to use. pub fn cursor(&self) -> CursorStyle { self.webview .as_ref() .map_or(CursorStyle::Arrow, |webview| map_cursor(webview.cursor())) } /// Link hover status text, shown by [`crate::WebView`] as an overlay. pub fn status_text(&self) -> Option { self.webview .as_ref() .and_then(WebView::status_text) .filter(|text| !text.is_empty()) } /// Resize the rendering surface; called from the element's `paint`. pub fn resize(&mut self, size: dpi::PhysicalSize, scale: f32) { if size == self.surface_size && scale == self.scale_factor { return; } self.surface_size = size; self.scale_factor = scale; if let Some(webview) = &self.webview { webview.set_hidpi_scale_factor(Scale::new(scale)); webview.resize(size); } } /// Feed a raw input event from the [`crate::WebView`] element. pub fn send_input(&mut self, input: WebInput) { let Some(webview) = self.webview.clone() else { return; }; // input is latency sensitive, so never wait for the fallback tick. self.nudge(); let scale = self.scale_factor; let point = |pos: gpui::Point| { WebViewPoint::Device(DevicePoint::new( f32::from(pos.x) * scale, f32::from(pos.y) * scale, )) }; match input { WebInput::MouseDown { button, pos } => { trace!(?button, "mouse down"); webview.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new( MouseButtonAction::Down, map_button(button), point(pos), ))); } WebInput::MouseUp { button, pos } => { trace!(?button, "mouse up"); webview.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new( MouseButtonAction::Up, map_button(button), point(pos), ))); } WebInput::MouseMove { pos } => { webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(point(pos)))); } WebInput::Scroll { delta, pos } => { let (x, y, mode) = match delta { gpui::ScrollDelta::Pixels(delta) => ( f32::from(delta.x) as f64, f32::from(delta.y) as f64, WheelMode::DeltaPixel, ), gpui::ScrollDelta::Lines(delta) => ( (delta.x * 76.0) as f64, (delta.y * 76.0) as f64, WheelMode::DeltaLine, ), }; webview.notify_input_event(InputEvent::Wheel(WheelEvent::new( WheelDelta { x, y, z: 0.0, mode }, point(pos), ))); } WebInput::Key { keystroke, pressed } => { webview .notify_input_event(InputEvent::Keyboard(keyboard_event(&keystroke, pressed))); } WebInput::EditingAction { action } => { trace!(?action, "editing action"); webview.notify_input_event(InputEvent::EditingAction(action)); } WebInput::ModifiersChanged { modifiers } => { let next = to_servo_modifiers(&modifiers); if next == self.modifiers { return; } for (flag, named) in [ (Modifiers::SHIFT, NamedKey::Shift), (Modifiers::CONTROL, NamedKey::Control), (Modifiers::ALT, NamedKey::Alt), (Modifiers::META, NamedKey::Meta), (Modifiers::FN, NamedKey::Fn), ] { if self.modifiers.contains(flag) == next.contains(flag) { continue; } let state = if next.contains(flag) { KeyState::Down } else { KeyState::Up }; webview.notify_input_event(InputEvent::Keyboard( KeyboardEvent::new_without_event( state, Key::Named(named), Code::Unidentified, Location::Standard, next, false, false, ), )); } self.modifiers = next; } } } /// Run an editing action (copy/cut/paste) against the page. pub fn editing_action(&mut self, action: EditingActionEvent) { self.send_input(WebInput::EditingAction { action }); } pub fn context_menu_items(&self) -> Option> { self.context_menu.as_ref().map(|m| m.items().to_vec()) } pub fn has_context_menu(&self) -> bool { self.context_menu.is_some() } pub fn select_context_menu(&mut self, action: ContextMenuAction) { if let Some(menu) = self.context_menu.take() { menu.select(action); } } pub fn dismiss_context_menu(&mut self) { if let Some(menu) = self.context_menu.take() { menu.dismiss(); } } pub fn active_dialog(&self) -> Option<&SimpleDialog> { self.dialog.as_ref() } /// Accept the pending dialog, supplying a value for `prompt` dialogs. pub fn confirm_dialog(&mut self, value: Option<&str>) { let Some(dialog) = self.dialog.take() else { return; }; match dialog { SimpleDialog::Prompt(mut dialog) => { dialog.set_current_value(value.unwrap_or("")); dialog.confirm(); } dialog => dialog.confirm(), } } pub fn dismiss_dialog(&mut self) { if let Some(dialog) = self.dialog.take() { dialog.dismiss(); } } // ask the pump to run soon. used when we hand servo work rather than waiting for it. fn nudge(&self) { let _ = self.wake.try_send(()); } fn pump(&mut self, cx: &mut Context) { let Some(servo) = self.servo.clone() else { return; }; servo.spin_event_loop(); for event in self.sink.take_events() { self.apply_event(event, cx); } let mut dirty = self.sink.take_needs_update(); if self.sink.take_frame_ready() { if let Some(webview) = &self.webview { webview.paint(); } if let Some(surface) = &self.surface { surface.present(); } dirty = true; } if dirty { cx.notify(); } } fn apply_event(&mut self, event: EngineEvent, cx: &mut Context) { match event { EngineEvent::UrlChanged(url) => { debug!(%url, "url changed"); cx.emit(WebViewEvent::UrlChanged(url)); } EngineEvent::MainFrameRequest(url) => { debug!(%url, "main frame navigation requested"); } EngineEvent::TitleChanged(title) => { cx.emit(WebViewEvent::TitleChanged(title)); } EngineEvent::LoadStatusChanged(status) => { debug!(?status, "load status changed"); self.loading = !matches!(status, LoadStatus::Complete); cx.emit(WebViewEvent::LoadStatusChanged(status)); } EngineEvent::HistoryChanged(back, forward) => { let can_go_back = back.is_some(); let can_go_forward = forward.is_some(); if (can_go_back, can_go_forward) != (self.can_go_back, self.can_go_forward) { self.can_go_back = can_go_back; self.can_go_forward = can_go_forward; cx.emit(WebViewEvent::HistoryChanged { can_go_back, can_go_forward, }); } } EngineEvent::TraversalComplete => { debug!("traversal complete"); } EngineEvent::Crashed(reason) => { warn!(%reason, "webview crashed"); self.loading = false; cx.emit(WebViewEvent::Crashed(reason)); } EngineEvent::Closed => { info!("page requested close"); cx.emit(WebViewEvent::Closed); } EngineEvent::CreateNew => { warn!("page requested a new window; ignoring (single-webview engine)"); cx.emit(WebViewEvent::NewWindowRequested); } EngineEvent::ShowContextMenu(menu) => { if let Some(old) = self.context_menu.take() { old.dismiss(); } self.context_menu = Some(menu); cx.emit(WebViewEvent::ContextMenuShown); } EngineEvent::ShowSimpleDialog(dialog) => { info!("simple dialog requested"); self.dialog = Some(dialog); cx.emit(WebViewEvent::DialogShown); } EngineEvent::HideEmbedderControl(id) => { if self.context_menu.as_ref().is_some_and(|m| m.id() == id) { self.context_menu = None; } } EngineEvent::ConsoleMessage(message) => { cx.emit(WebViewEvent::ConsoleMessage(message)); } EngineEvent::MediaPlaybackChanged(playing) => { debug!(playing, "media playback state changed"); self.media_playing = playing; cx.emit(WebViewEvent::MediaPlaybackChanged(playing)); } } } } impl crate::webview::WebViewSource for ServoEngine { fn frame(&self) -> Option { ServoEngine::frame(self) } fn cursor(&self) -> CursorStyle { ServoEngine::cursor(self) } fn status_text(&self) -> Option { ServoEngine::status_text(self) } fn resize(&mut self, size: dpi::PhysicalSize, scale: f32) { ServoEngine::resize(self, size, scale); } fn send_input(&mut self, input: WebInput) { ServoEngine::send_input(self, input); } } fn default_url() -> Url { // about:blank keeps the component network-free until the host loads a page. Url::parse("about:blank").expect("about:blank is a valid url") } fn to_servo_modifiers(m: &gpui::Modifiers) -> Modifiers { let mut modifiers = Modifiers::empty(); modifiers.set(Modifiers::SHIFT, m.shift); modifiers.set(Modifiers::CONTROL, m.control); modifiers.set(Modifiers::ALT, m.alt); modifiers.set(Modifiers::META, m.platform); modifiers.set(Modifiers::FN, m.function); modifiers } // translate a gpui keystroke into a servo keyboard event fn keyboard_event(keystroke: &gpui::Keystroke, pressed: bool) -> KeyboardEvent { let state = if pressed { KeyState::Down } else { KeyState::Up }; let key = match map_named_key(&keystroke.key) { Some(named) => Key::Named(named), None => Key::Character( keystroke .key_char .clone() .unwrap_or_else(|| keystroke.key.clone()), ), }; let modifiers = to_servo_modifiers(&keystroke.modifiers); KeyboardEvent::new_without_event( state, key, Code::Unidentified, Location::Standard, modifiers, false, false, ) } fn map_named_key(key: &str) -> Option { Some(match key { "enter" => NamedKey::Enter, "escape" => NamedKey::Escape, "backspace" => NamedKey::Backspace, "delete" => NamedKey::Delete, "tab" => NamedKey::Tab, "up" => NamedKey::ArrowUp, "down" => NamedKey::ArrowDown, "left" => NamedKey::ArrowLeft, "right" => NamedKey::ArrowRight, "home" => NamedKey::Home, "end" => NamedKey::End, "pageup" => NamedKey::PageUp, "pagedown" => NamedKey::PageDown, "insert" => NamedKey::Insert, _ => return None, }) } fn map_cursor(cursor: Cursor) -> CursorStyle { match cursor { Cursor::Pointer => CursorStyle::PointingHand, Cursor::Text | Cursor::VerticalText => CursorStyle::IBeam, Cursor::Crosshair | Cursor::Cell => CursorStyle::Crosshair, Cursor::Grab => CursorStyle::OpenHand, Cursor::Grabbing | Cursor::Move => CursorStyle::ClosedHand, Cursor::NotAllowed | Cursor::NoDrop => CursorStyle::OperationNotAllowed, Cursor::ContextMenu => CursorStyle::ContextualMenu, Cursor::Alias => CursorStyle::DragLink, Cursor::Copy => CursorStyle::DragCopy, Cursor::EResize => CursorStyle::ResizeRight, Cursor::WResize => CursorStyle::ResizeLeft, Cursor::EwResize => CursorStyle::ResizeLeftRight, Cursor::NResize => CursorStyle::ResizeUp, Cursor::SResize => CursorStyle::ResizeDown, Cursor::NsResize => CursorStyle::ResizeUpDown, Cursor::NeResize | Cursor::SwResize | Cursor::NeswResize => { CursorStyle::ResizeUpRightDownLeft } Cursor::NwResize | Cursor::SeResize | Cursor::NwseResize => { CursorStyle::ResizeUpLeftDownRight } Cursor::ColResize => CursorStyle::ResizeColumn, Cursor::RowResize => CursorStyle::ResizeRow, _ => CursorStyle::Arrow, } } fn map_button(button: gpui::MouseButton) -> MouseButton { match button { gpui::MouseButton::Left => MouseButton::Primary, gpui::MouseButton::Right => MouseButton::Secondary, gpui::MouseButton::Middle => MouseButton::Auxiliary, gpui::MouseButton::Navigate(n) => match n { gpui::NavigationDirection::Forward => MouseButton::Forward, gpui::NavigationDirection::Back => MouseButton::Back, }, } } fn initial_physical_size(window: &Window) -> dpi::PhysicalSize { let scale = window.scale_factor(); let logical = window.viewport_size(); let width = (f32::from(logical.width) * scale).round().max(1.0) as u32; let height = (f32::from(logical.height) * scale).round().max(1.0) as u32; dpi::PhysicalSize::new(width, height) }