gpui(web): Pass events to input handlers and handle IME composition (#50437)

Owen Law created

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

Change summary

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(-)

Detailed changes

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",

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<Self>,

+        event_name: &str,

+        handler: impl FnMut(JsValue) + 'static,

+    ) -> Closure<dyn FnMut(JsValue)> {

+        let closure = Closure::<dyn FnMut(JsValue)>::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<DispatchEventResult> {

         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<Self>) -> Closure<dyn FnMut(JsValue)> {
@@ -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<Self>) -> Closure<dyn FnMut(JsValue)> {
         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<Self>) -> Closure<dyn FnMut(JsValue)> {
         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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        let this = Rc::clone(self);

+        self.listen_input("compositionstart", move |_event: JsValue| {

+            this.is_composing.set(true);

+        })

+    }

+

+    fn register_composition_update(self: &Rc<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {

+        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<Self>) -> Closure<dyn FnMut(JsValue)> {
         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<Self>) -> Closure<dyn FnMut(JsValue)> {
         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(

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<WebWindowMutableState>,
@@ -53,6 +54,7 @@ pub(crate) struct WebWindowInner {
     pub(crate) pressed_button: Cell<Option<MouseButton>>,
     pub(crate) last_physical_size: Cell<(u32, u32)>,
     pub(crate) notify_scale: Cell<bool>,
+    pub(crate) is_composing: Cell<bool>,

     mql_handle: RefCell<Option<MqlHandle>>,
     pending_physical_size: Cell<Option<(u32, u32)>>,
 }
@@ -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<R>(

+        &self,

+        f: impl FnOnce(&mut PlatformInputHandler) -> R,

+    ) -> Option<R> {

+        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<Self>,
     ) -> Option<Closure<dyn FnMut(JsValue)>> {