From b9dce952072c26f0415c2da22247ebc326bd30b2 Mon Sep 17 00:00:00 2001 From: Owen Law <81528246+someone13574@users.noreply.github.com> Date: Tue, 17 Mar 2026 03:40:44 -0400 Subject: [PATCH] gpui(web): Pass events to input handlers and handle IME composition (#50437) Currently the web backend for gpui doesn't send any events to the `InputHandler`'s like `EntityInputHandler` which are needed for the input example and the editor crate, among others. This PR makes it pass those events, in addition to also dealing with composition events so that IME works. It adds an invisible input element to listen for composition events, since canvases don't receive them. Release Notes: - N/A --- crates/gpui_web/Cargo.toml | 1 + crates/gpui_web/src/events.rs | 99 +++++++++++++++++++++++++++++------ crates/gpui_web/src/window.rs | 32 ++++++++++- 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index dbb110597c7b850c28cde99ed573eab8264a18f7..5980fa5e855214e1d240dcaaacd59ae4bb6f3537 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -35,6 +35,7 @@ raw-window-handle = "0.6" wasm_thread = { version = "0.3", features = ["es_modules"], optional = true } web-sys = { version = "0.3", features = [ "console", + "CompositionEvent", "CssStyleDeclaration", "DataTransfer", "Document", diff --git a/crates/gpui_web/src/events.rs b/crates/gpui_web/src/events.rs index 5f6d8527e70a3778a46a11e00758e822790e742f..e93534fbe88238118ce0a1e819aec5ff3c3d201a 100644 --- a/crates/gpui_web/src/events.rs +++ b/crates/gpui_web/src/events.rs @@ -1,10 +1,10 @@ use std::rc::Rc; use gpui::{ - Capslock, ExternalPaths, FileDropEvent, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, - ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, - MouseUpEvent, NavigationDirection, Pixels, PlatformInput, Point, ScrollDelta, ScrollWheelEvent, - TouchPhase, point, px, + Capslock, DispatchEventResult, ExternalPaths, FileDropEvent, KeyDownEvent, KeyUpEvent, + Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, + MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformInput, Point, ScrollDelta, + ScrollWheelEvent, TouchPhase, point, px, }; use smallvec::smallvec; use wasm_bindgen::prelude::*; @@ -64,6 +64,9 @@ impl WebWindowInner { self.register_dragleave(), self.register_key_down(), self.register_key_up(), + self.register_composition_start(), + self.register_composition_update(), + self.register_composition_end(), self.register_focus(), self.register_blur(), self.register_pointer_enter(), @@ -87,6 +90,18 @@ impl WebWindowInner { closure } + fn listen_input( + self: &Rc, + event_name: &str, + handler: impl FnMut(JsValue) + 'static, + ) -> Closure { + let closure = Closure::::new(handler); + self.input_element + .add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref()) + .ok(); + closure + } + /// Registers a listener with `{passive: false}` so that `preventDefault()` works. /// Needed for events like `wheel` which are passive by default in modern browsers. fn listen_non_passive( @@ -109,11 +124,9 @@ impl WebWindowInner { closure } - fn dispatch_input(&self, input: PlatformInput) { + fn dispatch_input(&self, input: PlatformInput) -> Option { let mut borrowed = self.callbacks.borrow_mut(); - if let Some(ref mut callback) = borrowed.input { - callback(input); - } + borrowed.input.as_mut().map(|callback| callback(input)) } fn register_pointer_down(self: &Rc) -> Closure { @@ -121,7 +134,7 @@ impl WebWindowInner { self.listen("pointerdown", move |event: JsValue| { let event: web_sys::PointerEvent = event.unchecked_into(); event.prevent_default(); - this.canvas.focus().ok(); + this.input_element.focus().ok(); let button = dom_mouse_button_to_gpui(event.button()); let position = pointer_position_in_element(&event); @@ -315,7 +328,7 @@ impl WebWindowInner { fn register_key_down(self: &Rc) -> Closure { let this = Rc::clone(self); - self.listen("keydown", move |event: JsValue| { + self.listen_input("keydown", move |event: JsValue| { let event: web_sys::KeyboardEvent = event.unchecked_into(); let modifiers = modifiers_from_keyboard_event(&event, this.is_mac); @@ -346,20 +359,38 @@ impl WebWindowInner { let keystroke = Keystroke { modifiers, key, - key_char, + key_char: key_char.clone(), }; - this.dispatch_input(PlatformInput::KeyDown(KeyDownEvent { + let result = this.dispatch_input(PlatformInput::KeyDown(KeyDownEvent { keystroke, is_held, prefer_character_input: false, })); + + if let Some(result) = result { + if !result.propagate { + return; + } + } + + if this.is_composing.get() || event.is_composing() { + return; + } + + if modifiers.is_subset_of(&Modifiers::shift()) { + if let Some(text) = key_char { + this.with_input_handler(|handler| { + handler.replace_text_in_range(None, &text); + }); + } + } }) } fn register_key_up(self: &Rc) -> Closure { let this = Rc::clone(self); - self.listen("keyup", move |event: JsValue| { + self.listen_input("keyup", move |event: JsValue| { let event: web_sys::KeyboardEvent = event.unchecked_into(); let modifiers = modifiers_from_keyboard_event(&event, this.is_mac); @@ -396,9 +427,42 @@ impl WebWindowInner { }) } + fn register_composition_start(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("compositionstart", move |_event: JsValue| { + this.is_composing.set(true); + }) + } + + fn register_composition_update(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("compositionupdate", move |event: JsValue| { + let event: web_sys::CompositionEvent = event.unchecked_into(); + let data = event.data().unwrap_or_default(); + this.is_composing.set(true); + this.with_input_handler(|handler| { + handler.replace_and_mark_text_in_range(None, &data, None); + }); + }) + } + + fn register_composition_end(self: &Rc) -> Closure { + let this = Rc::clone(self); + self.listen_input("compositionend", move |event: JsValue| { + let event: web_sys::CompositionEvent = event.unchecked_into(); + let data = event.data().unwrap_or_default(); + this.is_composing.set(false); + this.with_input_handler(|handler| { + handler.replace_text_in_range(None, &data); + handler.unmark_text(); + }); + this.input_element.set_value(""); + }) + } + fn register_focus(self: &Rc) -> Closure { let this = Rc::clone(self); - self.listen("focus", move |_event: JsValue| { + self.listen_input("focus", move |_event: JsValue| { { let mut state = this.state.borrow_mut(); state.is_active = true; @@ -412,7 +476,7 @@ impl WebWindowInner { fn register_blur(self: &Rc) -> Closure { let this = Rc::clone(self); - self.listen("blur", move |_event: JsValue| { + self.listen_input("blur", move |_event: JsValue| { { let mut state = this.state.borrow_mut(); state.is_active = false; @@ -556,7 +620,10 @@ pub(crate) fn is_mac_platform(browser_window: &web_sys::Window) -> bool { } fn is_modifier_only_key(key: &str) -> bool { - matches!(key, "control" | "alt" | "shift" | "platform" | "capslock") + matches!( + key, + "control" | "alt" | "shift" | "platform" | "capslock" | "compose" | "process" + ) } fn compute_key_char( diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index ab6d6fc857dfd092ea7e3c5d2dcb46f9ddc96cfb..5a2e564367033f76b0aa12c2d29d7f098d7eeb7a 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -45,6 +45,7 @@ pub(crate) struct WebWindowMutableState { pub(crate) struct WebWindowInner { pub(crate) browser_window: web_sys::Window, pub(crate) canvas: web_sys::HtmlCanvasElement, + pub(crate) input_element: web_sys::HtmlInputElement, pub(crate) has_device_pixel_support: bool, pub(crate) is_mac: bool, pub(crate) state: RefCell, @@ -53,6 +54,7 @@ pub(crate) struct WebWindowInner { pub(crate) pressed_button: Cell>, pub(crate) last_physical_size: Cell<(u32, u32)>, pub(crate) notify_scale: Cell, + pub(crate) is_composing: Cell, mql_handle: RefCell>, pending_physical_size: Cell>, } @@ -89,7 +91,7 @@ impl WebWindow { let max_texture_dimension = context.device.limits().max_texture_dimension_2d; let has_device_pixel_support = check_device_pixel_support(); - canvas.set_tab_index(0); + canvas.set_tab_index(-1); let style = canvas.style(); style @@ -114,7 +116,21 @@ impl WebWindow { body.append_child(&canvas) .map_err(|e| anyhow::anyhow!("Failed to append canvas to body: {e:?}"))?; - canvas.focus().ok(); + let input_element: web_sys::HtmlInputElement = document + .create_element("input") + .map_err(|e| anyhow::anyhow!("Failed to create input element: {e:?}"))? + .dyn_into() + .map_err(|e| anyhow::anyhow!("Created element is not an input: {e:?}"))?; + let input_style = input_element.style(); + input_style.set_property("position", "fixed").ok(); + input_style.set_property("top", "0").ok(); + input_style.set_property("left", "0").ok(); + input_style.set_property("width", "1px").ok(); + input_style.set_property("height", "1px").ok(); + input_style.set_property("opacity", "0").ok(); + body.append_child(&input_element) + .map_err(|e| anyhow::anyhow!("Failed to append input to body: {e:?}"))?; + input_element.focus().ok(); let device_size = Size { width: DevicePixels(0), @@ -155,6 +171,7 @@ impl WebWindow { let inner = Rc::new(WebWindowInner { browser_window, canvas, + input_element, has_device_pixel_support, is_mac, state: RefCell::new(mutable_state), @@ -163,6 +180,7 @@ impl WebWindow { pressed_button: Cell::new(None), last_physical_size: Cell::new((0, 0)), notify_scale: Cell::new(false), + is_composing: Cell::new(false), mql_handle: RefCell::new(None), pending_physical_size: Cell::new(None), }); @@ -389,6 +407,16 @@ impl WebWindowInner { Some(closure) } + pub(crate) fn with_input_handler( + &self, + f: impl FnOnce(&mut PlatformInputHandler) -> R, + ) -> Option { + let mut handler = self.state.borrow_mut().input_handler.take()?; + let result = f(&mut handler); + self.state.borrow_mut().input_handler = Some(handler); + Some(result) + } + pub(crate) fn register_appearance_change( self: &Rc, ) -> Option> {