use crate::dispatcher::WebDispatcher; use crate::display::WebDisplay; use crate::events::EventListenerHandle; use crate::http_client::FetchHttpClient; use crate::keyboard::WebKeyboardLayout; use crate::window::WebWindow; use anyhow::Result; use futures::channel::oneshot; use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, ThermalState, WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError, }; use gpui_wgpu::WgpuContext; use std::{ borrow::Cow, cell::{Cell, RefCell}, path::{Path, PathBuf}, rc::Rc, sync::Arc, }; use wasm_bindgen::prelude::*; static BUNDLED_FONTS: &[&[u8]] = &[ include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"), include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf"), include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf"), include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBoldItalic.ttf"), include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"), include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf"), include_bytes!("../../../assets/fonts/lilex/Lilex-Italic.ttf"), include_bytes!("../../../assets/fonts/lilex/Lilex-BoldItalic.ttf"), ]; pub struct WebPlatform { browser_window: web_sys::Window, dispatcher: Arc, background_executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, text_system: Arc, active_window: RefCell>, active_display: Rc, callbacks: RefCell, wgpu_context: Rc>>, cursor_visible: Rc>, last_cursor_css: Rc>, _cursor_restore_listeners: Vec, } #[derive(Default)] struct WebPlatformCallbacks { open_urls: Option)>>, quit: Option>, reopen: Option>, app_menu_action: Option>, will_open_app_menu: Option>, validate_app_menu_command: Option bool>>, keyboard_layout_change: Option>, thermal_state_change: Option>, } impl WebPlatform { pub fn new(allow_multi_threading: bool) -> Self { let browser_window = web_sys::window().expect("must be running in a browser window context"); let dispatcher = Arc::new(WebDispatcher::new( browser_window.clone(), allow_multi_threading, )); let background_executor = BackgroundExecutor::new(dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new_without_system_fonts( "IBM Plex Sans", )); let fonts = BUNDLED_FONTS .iter() .map(|bytes| Cow::Borrowed(*bytes)) .collect(); if let Err(error) = text_system.add_fonts(fonts) { log::error!("failed to load bundled fonts: {error:#}"); } let text_system: Arc = text_system; let active_display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); let cursor_visible = Rc::new(Cell::new(true)); let last_cursor_css = Rc::new(Cell::new("default")); let cursor_restore_listeners = cursor_restore_listeners( &browser_window, cursor_visible.clone(), last_cursor_css.clone(), ); Self { browser_window, dispatcher, background_executor, foreground_executor, text_system, active_window: RefCell::new(None), active_display, callbacks: RefCell::new(WebPlatformCallbacks::default()), wgpu_context: Rc::new(RefCell::new(None)), cursor_visible, last_cursor_css, _cursor_restore_listeners: cursor_restore_listeners, } } /// Returns an HTTP client that runs browser Fetch operations on this platform's main thread. pub fn fetch_http_client(&self) -> FetchHttpClient { FetchHttpClient::new(self.dispatcher.clone()) } /// Returns a browser Fetch HTTP client with the given reported user agent. pub fn fetch_http_client_with_user_agent( &self, user_agent: &str, ) -> anyhow::Result { FetchHttpClient::with_user_agent(self.dispatcher.clone(), user_agent) } } impl Platform for WebPlatform { fn background_executor(&self) -> BackgroundExecutor { self.background_executor.clone() } fn foreground_executor(&self) -> ForegroundExecutor { self.foreground_executor.clone() } fn text_system(&self) -> Arc { self.text_system.clone() } fn run(&self, on_finish_launching: Box) { let wgpu_context = self.wgpu_context.clone(); let browser_window = self.browser_window.clone(); wasm_bindgen_futures::spawn_local(async move { match WgpuContext::new_web().await { Ok(context) => { log::info!("WebGPU context initialized successfully"); *wgpu_context.borrow_mut() = Some(context); on_finish_launching(); } Err(err) => { // Without a GPU context nothing can ever render, so // launching the app would only produce confusing // downstream errors. Leave a message in the page instead. log::error!("Failed to initialize WebGPU context: {err:#}"); show_webgpu_unavailable_message(&browser_window); } } }); } fn quit(&self) { log::warn!("WebPlatform::quit called, but quitting is not supported in the browser ."); } fn restart(&self, _binary_path: Option) {} fn activate(&self, _ignoring_other_apps: bool) {} fn hide(&self) {} fn hide_other_apps(&self) {} fn unhide_other_apps(&self) {} fn displays(&self) -> Vec> { vec![self.active_display.clone()] } fn primary_display(&self) -> Option> { Some(self.active_display.clone()) } fn active_window(&self) -> Option { *self.active_window.borrow() } fn open_window( &self, handle: AnyWindowHandle, params: WindowParams, ) -> anyhow::Result> { // Native popups are not implemented on the web yet. Rejecting lets callers fall back to // gpui's in-window popovers. if let WindowKind::AnchoredPopup(_) = params.kind { return Err(PopupNotSupportedError.into()); } let context_ref = self.wgpu_context.borrow(); let context = context_ref.as_ref().ok_or_else(|| { anyhow::anyhow!("WebGPU context not initialized. Was Platform::run() called?") })?; let window = WebWindow::new(handle, params, context, self.browser_window.clone())?; *self.active_window.borrow_mut() = Some(handle); Ok(Box::new(window)) } fn window_appearance(&self) -> WindowAppearance { let Ok(Some(media_query)) = self .browser_window .match_media("(prefers-color-scheme: dark)") else { return WindowAppearance::Light; }; if media_query.matches() { WindowAppearance::Dark } else { WindowAppearance::Light } } fn open_url(&self, url: &str) { if let Err(error) = self.browser_window.open_with_url(url) { log::warn!("Failed to open URL '{url}': {error:?}"); } } fn on_open_urls(&self, callback: Box)>) { self.callbacks.borrow_mut().open_urls = Some(callback); } fn register_url_scheme(&self, _url: &str) -> Task> { Task::ready(Ok(())) } fn prompt_for_paths( &self, _options: PathPromptOptions, ) -> oneshot::Receiver>>> { let (tx, rx) = oneshot::channel(); tx.send(Err(anyhow::anyhow!( "prompt_for_paths is not supported on the web" ))) .ok(); rx } fn prompt_for_new_path( &self, _directory: &Path, _suggested_name: Option<&str>, ) -> oneshot::Receiver>> { let (sender, receiver) = oneshot::channel(); sender .send(Err(anyhow::anyhow!( "prompt_for_new_path is not supported on the web" ))) .ok(); receiver } fn can_select_mixed_files_and_dirs(&self) -> bool { false } fn reveal_path(&self, _path: &Path) {} fn open_with_system(&self, _path: &Path) {} fn on_quit(&self, callback: Box) { self.callbacks.borrow_mut().quit = Some(callback); } fn on_reopen(&self, callback: Box) { self.callbacks.borrow_mut().reopen = Some(callback); } fn on_system_wake(&self, _callback: Box) {} fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} fn on_app_menu_action(&self, callback: Box) { self.callbacks.borrow_mut().app_menu_action = Some(callback); } fn on_will_open_app_menu(&self, callback: Box) { self.callbacks.borrow_mut().will_open_app_menu = Some(callback); } fn on_validate_app_menu_command(&self, callback: Box bool>) { self.callbacks.borrow_mut().validate_app_menu_command = Some(callback); } fn thermal_state(&self) -> ThermalState { ThermalState::Nominal } fn on_thermal_state_change(&self, callback: Box) { self.callbacks.borrow_mut().thermal_state_change = Some(callback); } fn compositor_name(&self) -> &'static str { "Web" } fn app_path(&self) -> Result { Err(anyhow::anyhow!("app_path is not available on the web")) } fn path_for_auxiliary_executable(&self, _name: &str) -> Result { Err(anyhow::anyhow!( "path_for_auxiliary_executable is not available on the web" )) } fn set_cursor_style(&self, style: CursorStyle) { let css_cursor = match style { CursorStyle::Arrow => "default", CursorStyle::IBeam => "text", CursorStyle::Crosshair => "crosshair", CursorStyle::ClosedHand => "grabbing", CursorStyle::OpenHand => "grab", CursorStyle::PointingHand => "pointer", CursorStyle::ResizeLeft | CursorStyle::ResizeRight | CursorStyle::ResizeLeftRight => { "ew-resize" } CursorStyle::ResizeUp | CursorStyle::ResizeDown | CursorStyle::ResizeUpDown => { "ns-resize" } CursorStyle::ResizeUpLeftDownRight => "nesw-resize", CursorStyle::ResizeUpRightDownLeft => "nwse-resize", CursorStyle::ResizeColumn => "col-resize", CursorStyle::ResizeRow => "row-resize", CursorStyle::IBeamCursorForVerticalLayout => "vertical-text", CursorStyle::OperationNotAllowed => "not-allowed", CursorStyle::DragLink => "alias", CursorStyle::DragCopy => "copy", CursorStyle::ContextualMenu => "context-menu", }; self.last_cursor_css.set(css_cursor); if self.cursor_visible.get() { set_body_cursor(&self.browser_window, css_cursor); } } fn hide_cursor_until_mouse_moves(&self) { if !self.cursor_visible.replace(false) { return; } set_body_cursor(&self.browser_window, "none"); } fn is_cursor_visible(&self) -> bool { self.cursor_visible.get() } fn should_auto_hide_scrollbars(&self) -> bool { true } fn read_from_clipboard(&self) -> Option { None } fn write_to_clipboard(&self, item: ClipboardItem) { if let Some(text) = item.text() && let Some(window) = web_sys::window() { // Fire-and-forget; called synchronously inside the user's input // event, which satisfies the browser's user-activation requirement. drop(window.navigator().clipboard().write_text(&text)); } } fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Task> { Task::ready(Err(anyhow::anyhow!( "credential storage is not available on the web" ))) } fn read_credentials(&self, _url: &str) -> Task)>>> { Task::ready(Ok(None)) } fn delete_credentials(&self, _url: &str) -> Task> { Task::ready(Err(anyhow::anyhow!( "credential storage is not available on the web" ))) } fn keyboard_layout(&self) -> Box { Box::new(WebKeyboardLayout) } fn keyboard_mapper(&self) -> Rc { Rc::new(DummyKeyboardMapper) } fn on_keyboard_layout_change(&self, callback: Box) { self.callbacks.borrow_mut().keyboard_layout_change = Some(callback); } } fn cursor_restore_listeners( browser_window: &web_sys::Window, cursor_visible: Rc>, last_cursor_css: Rc>, ) -> Vec { let mut handles = Vec::new(); let Some(document) = browser_window.document() else { return handles; }; let mut add_listener = |target: &web_sys::EventTarget, event_name: &'static str| { let browser_window = browser_window.clone(); let cursor_visible = cursor_visible.clone(); let last_cursor_css = last_cursor_css.clone(); handles.push(EventListenerHandle::add( target, event_name, move |_event: JsValue| { if !cursor_visible.replace(true) { set_body_cursor(&browser_window, last_cursor_css.get()); } }, )); }; let document_target: &web_sys::EventTarget = document.as_ref(); let window_target: &web_sys::EventTarget = browser_window.as_ref(); add_listener(document_target, "mousemove"); add_listener(document_target, "mouseenter"); add_listener(window_target, "blur"); add_listener(document_target, "visibilitychange"); handles } fn show_webgpu_unavailable_message(browser_window: &web_sys::Window) { let Some(document) = browser_window.document() else { return; }; let Some(body) = document.body() else { return; }; let Ok(message) = document.create_element("p") else { return; }; message.set_text_content(Some( "Failed to initialize WebGPU. This application requires a browser with WebGPU support.", )); body.append_child(&message).ok(); } fn set_body_cursor(browser_window: &web_sys::Window, css_cursor: &str) { if let Some(document) = browser_window.document() && let Some(body) = document.body() && let Err(error) = body.style().set_property("cursor", css_cursor) { log::warn!("Failed to set cursor style: {error:?}"); } }