Something went wrong. Try again.
a servo-backed webview component for gpui
Something went wrong. Try again.
23 kB · 703 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704use 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<dyn EventLoopWaker> { 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<String>), 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<ServoConfig>, servo: Option<Servo>, webview: Option<WebView>, surface: Option<Rc<IoSurfaceBridge>>, sink: Rc<EngineSink>, context_menu: Option<ContextMenu>, dialog: Option<SimpleDialog>, 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<u32>, scale_factor: f32,}
impl EventEmitter<WebViewEvent> for ServoEngine {}
impl ServoEngine { /// Create the engine and open `initial_url` (or `about:blank`). pub fn new( initial_url: Option<Url>, config: ServoConfig, window: &mut Window, cx: &mut Context<Self>, ) -> 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<Url> { self.webview.as_ref().and_then(WebView::url) }
/// The current page title. pub fn title(&self) -> Option<String> { 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<CVPixelBuffer> { 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<String> { 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<u32>, 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<gpui::Pixels>| { 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<Vec<ContextMenuItem>> { 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<Self>) { 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<Self>) { 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<CVPixelBuffer> { ServoEngine::frame(self) }
fn cursor(&self) -> CursorStyle { ServoEngine::cursor(self) }
fn status_text(&self) -> Option<String> { ServoEngine::status_text(self) }
fn resize(&mut self, size: dpi::PhysicalSize<u32>, 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 eventfn 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<NamedKey> { 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<u32> { 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)}