Wayland: Implement text_input_v3 and xkb compose (#11712)

Fernando Tagawa created

Release Notes:

- N/A

Fixes #9207 
Known Issues:
- [ ] ~~After launching Zed and immediately trying to change input
method, the input panel will appear at Point{0, 0}~~
- [ ] ~~`ime_handle_preedit` should not trigger `write_to_primary`~~
Move to other PR
- [ ] ~~Cursor is visually stuck at the end.~~ Move to other PR
Currently tested with KDE & fcitx5.

Change summary

crates/gpui/src/platform/linux/platform.rs       |  62 ++++++
crates/gpui/src/platform/linux/wayland/client.rs | 183 +++++++++++++++++
crates/gpui/src/platform/linux/wayland/serial.rs |   1 
crates/gpui/src/platform/linux/wayland/window.rs |  40 +++
4 files changed, 281 insertions(+), 5 deletions(-)

Detailed changes

crates/gpui/src/platform/linux/platform.rs 🔗

@@ -655,6 +655,68 @@ impl Keystroke {
             ime_key,
         }
     }
+
+    /**
+     * Returns which symbol the dead key represents
+     * https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#dead_keycodes_for_linux
+     */
+    pub fn underlying_dead_key(keysym: Keysym) -> Option<String> {
+        match keysym {
+            Keysym::dead_grave => Some("`".to_owned()),
+            Keysym::dead_acute => Some("´".to_owned()),
+            Keysym::dead_circumflex => Some("^".to_owned()),
+            Keysym::dead_tilde => Some("~".to_owned()),
+            Keysym::dead_perispomeni => Some("͂".to_owned()),
+            Keysym::dead_macron => Some("¯".to_owned()),
+            Keysym::dead_breve => Some("˘".to_owned()),
+            Keysym::dead_abovedot => Some("˙".to_owned()),
+            Keysym::dead_diaeresis => Some("¨".to_owned()),
+            Keysym::dead_abovering => Some("˚".to_owned()),
+            Keysym::dead_doubleacute => Some("˝".to_owned()),
+            Keysym::dead_caron => Some("ˇ".to_owned()),
+            Keysym::dead_cedilla => Some("¸".to_owned()),
+            Keysym::dead_ogonek => Some("˛".to_owned()),
+            Keysym::dead_iota => Some("ͅ".to_owned()),
+            Keysym::dead_voiced_sound => Some("゙".to_owned()),
+            Keysym::dead_semivoiced_sound => Some("゚".to_owned()),
+            Keysym::dead_belowdot => Some("̣̣".to_owned()),
+            Keysym::dead_hook => Some("̡".to_owned()),
+            Keysym::dead_horn => Some("̛".to_owned()),
+            Keysym::dead_stroke => Some("̶̶".to_owned()),
+            Keysym::dead_abovecomma => Some("̓̓".to_owned()),
+            Keysym::dead_psili => Some("᾿".to_owned()),
+            Keysym::dead_abovereversedcomma => Some("ʽ".to_owned()),
+            Keysym::dead_dasia => Some("῾".to_owned()),
+            Keysym::dead_doublegrave => Some("̏".to_owned()),
+            Keysym::dead_belowring => Some("˳".to_owned()),
+            Keysym::dead_belowmacron => Some("̱".to_owned()),
+            Keysym::dead_belowcircumflex => Some("ꞈ".to_owned()),
+            Keysym::dead_belowtilde => Some("̰".to_owned()),
+            Keysym::dead_belowbreve => Some("̮".to_owned()),
+            Keysym::dead_belowdiaeresis => Some("̤".to_owned()),
+            Keysym::dead_invertedbreve => Some("̯".to_owned()),
+            Keysym::dead_belowcomma => Some("̦".to_owned()),
+            Keysym::dead_currency => None,
+            Keysym::dead_lowline => None,
+            Keysym::dead_aboveverticalline => None,
+            Keysym::dead_belowverticalline => None,
+            Keysym::dead_longsolidusoverlay => None,
+            Keysym::dead_a => None,
+            Keysym::dead_A => None,
+            Keysym::dead_e => None,
+            Keysym::dead_E => None,
+            Keysym::dead_i => None,
+            Keysym::dead_I => None,
+            Keysym::dead_o => None,
+            Keysym::dead_O => None,
+            Keysym::dead_u => None,
+            Keysym::dead_U => None,
+            Keysym::dead_small_schwa => Some("ə".to_owned()),
+            Keysym::dead_capital_schwa => Some("Ə".to_owned()),
+            Keysym::dead_greek => None,
+            _ => None,
+        }
+    }
 }
 
 impl Modifiers {

crates/gpui/src/platform/linux/wayland/client.rs 🔗

@@ -1,5 +1,6 @@
 use core::hash;
 use std::cell::{RefCell, RefMut};
+use std::ffi::OsString;
 use std::os::fd::{AsRawFd, BorrowedFd};
 use std::path::PathBuf;
 use std::rc::{Rc, Weak};
@@ -42,6 +43,12 @@ use wayland_protocols::wp::cursor_shape::v1::client::{
 use wayland_protocols::wp::fractional_scale::v1::client::{
     wp_fractional_scale_manager_v1, wp_fractional_scale_v1,
 };
+use wayland_protocols::wp::text_input::zv3::client::zwp_text_input_v3::{
+    ContentHint, ContentPurpose,
+};
+use wayland_protocols::wp::text_input::zv3::client::{
+    zwp_text_input_manager_v3, zwp_text_input_v3,
+};
 use wayland_protocols::wp::viewporter::client::{wp_viewport, wp_viewporter};
 use wayland_protocols::xdg::activation::v1::client::{xdg_activation_token_v1, xdg_activation_v1};
 use wayland_protocols::xdg::decoration::zv1::client::{
@@ -53,7 +60,7 @@ use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1;
 use xkbcommon::xkb::{self, Keycode, KEYMAP_COMPILE_NO_FLAGS};
 
 use super::super::{open_uri_internal, read_fd, DOUBLE_CLICK_INTERVAL};
-use super::window::{WaylandWindowState, WaylandWindowStatePtr};
+use super::window::{ImeInput, WaylandWindowState, WaylandWindowStatePtr};
 use crate::platform::linux::is_within_click_distance;
 use crate::platform::linux::wayland::cursor::Cursor;
 use crate::platform::linux::wayland::serial::{SerialKind, SerialTracker};
@@ -87,6 +94,7 @@ pub struct Globals {
         Option<wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1>,
     pub decoration_manager: Option<zxdg_decoration_manager_v1::ZxdgDecorationManagerV1>,
     pub blur_manager: Option<org_kde_kwin_blur_manager::OrgKdeKwinBlurManager>,
+    pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
     pub executor: ForegroundExecutor,
 }
 
@@ -122,6 +130,7 @@ impl Globals {
             fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(),
             decoration_manager: globals.bind(&qh, 1..=1, ()).ok(),
             blur_manager: globals.bind(&qh, 1..=1, ()).ok(),
+            text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
             executor,
             qh,
         }
@@ -135,11 +144,14 @@ pub(crate) struct WaylandClientState {
     wl_pointer: Option<wl_pointer::WlPointer>,
     cursor_shape_device: Option<wp_cursor_shape_device_v1::WpCursorShapeDeviceV1>,
     data_device: Option<wl_data_device::WlDataDevice>,
+    text_input: Option<zwp_text_input_v3::ZwpTextInputV3>,
+    pre_edit_text: Option<String>,
     // Surface to Window mapping
     windows: HashMap<ObjectId, WaylandWindowStatePtr>,
     // Output to scale mapping
     output_scales: HashMap<ObjectId, i32>,
     keymap_state: Option<xkb::State>,
+    compose_state: Option<xkb::compose::State>,
     drag: DragState,
     click: ClickState,
     repeat: KeyRepeat,
@@ -241,6 +253,9 @@ impl Drop for WaylandClient {
         if let Some(data_device) = &state.data_device {
             data_device.release();
         }
+        if let Some(text_input) = &state.text_input {
+            text_input.destroy();
+        }
     }
 }
 
@@ -334,10 +349,13 @@ impl WaylandClient {
             wl_pointer: None,
             cursor_shape_device: None,
             data_device,
+            text_input: None,
+            pre_edit_text: None,
             output_scales: outputs,
             windows: HashMap::default(),
             common,
             keymap_state: None,
+            compose_state: None,
             drag: DragState {
                 data_offer: None,
                 window: None,
@@ -577,6 +595,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion);
 delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1);
 delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1);
 delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager);
+delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3);
 delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur);
 delegate_noop!(WaylandClientStatePtr: ignore wp_viewporter::WpViewporter);
 delegate_noop!(WaylandClientStatePtr: ignore wp_viewport::WpViewport);
@@ -753,12 +772,17 @@ impl Dispatch<wl_seat::WlSeat, ()> for WaylandClientStatePtr {
             capabilities: WEnum::Value(capabilities),
         } = event
         {
+            let client = state.get_client();
+            let mut state = client.borrow_mut();
             if capabilities.contains(wl_seat::Capability::Keyboard) {
                 seat.get_keyboard(qh, ());
+                state.text_input = state
+                    .globals
+                    .text_input_manager
+                    .as_ref()
+                    .map(|text_input_manager| text_input_manager.get_text_input(&seat, qh, ()));
             }
             if capabilities.contains(wl_seat::Capability::Pointer) {
-                let client = state.get_client();
-                let mut state = client.borrow_mut();
                 let pointer = seat.get_pointer(qh, ());
                 state.cursor_shape_device = state
                     .globals
@@ -798,9 +822,10 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
                     wl_keyboard::KeymapFormat::XkbV1,
                     "Unsupported keymap format"
                 );
+                let xkb_context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
                 let keymap = unsafe {
                     xkb::Keymap::new_from_fd(
-                        &xkb::Context::new(xkb::CONTEXT_NO_FLAGS),
+                        &xkb_context,
                         fd,
                         size as usize,
                         XKB_KEYMAP_FORMAT_TEXT_V1,
@@ -810,7 +835,21 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
                     .flatten()
                     .expect("Failed to create keymap")
                 };
+                let table = {
+                    let locale = std::env::var_os("LC_CTYPE").unwrap_or(OsString::from("C"));
+                    xkb::compose::Table::new_from_locale(
+                        &xkb_context,
+                        &locale,
+                        xkb::compose::COMPILE_NO_FLAGS,
+                    )
+                    .log_err()
+                    .unwrap()
+                };
                 state.keymap_state = Some(xkb::State::new(&keymap));
+                state.compose_state = Some(xkb::compose::State::new(
+                    &table,
+                    xkb::compose::STATE_NO_FLAGS,
+                ));
             }
             wl_keyboard::Event::Enter { surface, .. } => {
                 state.keyboard_focused_window = get_window(&mut state, &surface.id());
@@ -827,7 +866,12 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
                 state.enter_token.take();
 
                 if let Some(window) = keyboard_focused_window {
+                    if let Some(ref mut compose) = state.compose_state {
+                        compose.reset();
+                    }
+                    state.pre_edit_text.take();
                     drop(state);
+                    window.handle_ime(ImeInput::DeleteText);
                     window.set_focused(false);
                 }
             }
@@ -874,8 +918,47 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
 
                 match key_state {
                     wl_keyboard::KeyState::Pressed if !keysym.is_modifier_key() => {
+                        let mut keystroke =
+                            Keystroke::from_xkb(&keymap_state, state.modifiers, keycode);
+                        if let Some(mut compose) = state.compose_state.take() {
+                            compose.feed(keysym);
+                            match compose.status() {
+                                xkb::Status::Composing => {
+                                    state.pre_edit_text =
+                                        compose.utf8().or(Keystroke::underlying_dead_key(keysym));
+                                    let pre_edit =
+                                        state.pre_edit_text.clone().unwrap_or(String::default());
+                                    drop(state);
+                                    focused_window.handle_ime(ImeInput::SetMarkedText(pre_edit));
+                                    state = client.borrow_mut();
+                                }
+
+                                xkb::Status::Composed => {
+                                    state.pre_edit_text.take();
+                                    keystroke.ime_key = compose.utf8();
+                                    keystroke.key = xkb::keysym_get_name(compose.keysym().unwrap());
+                                }
+                                xkb::Status::Cancelled => {
+                                    let pre_edit = state.pre_edit_text.take();
+                                    drop(state);
+                                    if let Some(pre_edit) = pre_edit {
+                                        focused_window.handle_ime(ImeInput::InsertText(pre_edit));
+                                    }
+                                    if let Some(current_key) =
+                                        Keystroke::underlying_dead_key(keysym)
+                                    {
+                                        focused_window
+                                            .handle_ime(ImeInput::SetMarkedText(current_key));
+                                    }
+                                    compose.feed(keysym);
+                                    state = client.borrow_mut();
+                                }
+                                _ => {}
+                            }
+                            state.compose_state = Some(compose);
+                        }
                         let input = PlatformInput::KeyDown(KeyDownEvent {
-                            keystroke: Keystroke::from_xkb(keymap_state, state.modifiers, keycode),
+                            keystroke: keystroke,
                             is_held: false, // todo(linux)
                         });
 
@@ -932,6 +1015,86 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
         }
     }
 }
+impl Dispatch<zwp_text_input_v3::ZwpTextInputV3, ()> for WaylandClientStatePtr {
+    fn event(
+        this: &mut Self,
+        text_input: &zwp_text_input_v3::ZwpTextInputV3,
+        event: <zwp_text_input_v3::ZwpTextInputV3 as Proxy>::Event,
+        data: &(),
+        conn: &Connection,
+        qhandle: &QueueHandle<Self>,
+    ) {
+        let client = this.get_client();
+        let mut state = client.borrow_mut();
+        match event {
+            zwp_text_input_v3::Event::Enter { surface } => {
+                text_input.enable();
+                text_input.set_content_type(ContentHint::None, ContentPurpose::Normal);
+
+                if let Some(window) = state.keyboard_focused_window.clone() {
+                    drop(state);
+                    if let Some(area) = window.get_ime_area() {
+                        text_input.set_cursor_rectangle(
+                            area.origin.x.0 as i32,
+                            area.origin.y.0 as i32,
+                            area.size.width.0 as i32,
+                            area.size.height.0 as i32,
+                        );
+                    }
+                }
+                text_input.commit();
+            }
+            zwp_text_input_v3::Event::Leave { surface } => {
+                text_input.disable();
+                text_input.commit();
+            }
+            zwp_text_input_v3::Event::CommitString { text } => {
+                let Some(window) = state.keyboard_focused_window.clone() else {
+                    return;
+                };
+
+                if let Some(commit_text) = text {
+                    drop(state);
+                    window.handle_ime(ImeInput::InsertText(commit_text));
+                }
+            }
+            zwp_text_input_v3::Event::PreeditString {
+                text,
+                cursor_begin,
+                cursor_end,
+            } => {
+                state.pre_edit_text = text;
+            }
+            zwp_text_input_v3::Event::Done { serial } => {
+                let last_serial = state.serial_tracker.get(SerialKind::InputMethod);
+                state.serial_tracker.update(SerialKind::InputMethod, serial);
+                let Some(window) = state.keyboard_focused_window.clone() else {
+                    return;
+                };
+
+                if let Some(text) = state.pre_edit_text.take() {
+                    drop(state);
+                    window.handle_ime(ImeInput::SetMarkedText(text));
+                    if let Some(area) = window.get_ime_area() {
+                        text_input.set_cursor_rectangle(
+                            area.origin.x.0 as i32,
+                            area.origin.y.0 as i32,
+                            area.size.width.0 as i32,
+                            area.size.height.0 as i32,
+                        );
+                        if last_serial == serial {
+                            text_input.commit();
+                        }
+                    }
+                } else {
+                    drop(state);
+                    window.handle_ime(ImeInput::DeleteText);
+                }
+            }
+            _ => {}
+        }
+    }
+}
 
 fn linux_button_to_gpui(button: u32) -> Option<MouseButton> {
     // These values are coming from <linux/input-event-codes.h>.
@@ -1053,6 +1216,16 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
                 }
                 match button_state {
                     wl_pointer::ButtonState::Pressed => {
+                        if let (Some(window), Some(text), Some(compose_state)) = (
+                            state.keyboard_focused_window.clone(),
+                            state.pre_edit_text.take(),
+                            state.compose_state.as_mut(),
+                        ) {
+                            compose_state.reset();
+                            drop(state);
+                            window.handle_ime(ImeInput::InsertText(text));
+                            state = client.borrow_mut();
+                        }
                         let click_elapsed = state.click.last_click.elapsed();
 
                         if click_elapsed < DOUBLE_CLICK_INTERVAL

crates/gpui/src/platform/linux/wayland/window.rs 🔗

@@ -2,6 +2,7 @@ use std::any::Any;
 use std::cell::{Ref, RefCell, RefMut};
 use std::ffi::c_void;
 use std::num::NonZeroU32;
+use std::ops::Range;
 use std::ptr::NonNull;
 use std::rc::{Rc, Weak};
 use std::sync::Arc;
@@ -162,6 +163,11 @@ impl WaylandWindowState {
 }
 
 pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr);
+pub enum ImeInput {
+    InsertText(String),
+    SetMarkedText(String),
+    DeleteText,
+}
 
 impl Drop for WaylandWindow {
     fn drop(&mut self) {
@@ -425,6 +431,40 @@ impl WaylandWindowStatePtr {
         }
     }
 
+    pub fn handle_ime(&self, ime: ImeInput) {
+        let mut state = self.state.borrow_mut();
+        if let Some(mut input_handler) = state.input_handler.take() {
+            drop(state);
+            match ime {
+                ImeInput::InsertText(text) => {
+                    input_handler.replace_text_in_range(None, &text);
+                }
+                ImeInput::SetMarkedText(text) => {
+                    input_handler.replace_and_mark_text_in_range(None, &text, None);
+                }
+                ImeInput::DeleteText => {
+                    if let Some(marked) = input_handler.marked_text_range() {
+                        input_handler.replace_text_in_range(Some(marked), "");
+                    }
+                }
+            }
+            self.state.borrow_mut().input_handler = Some(input_handler);
+        }
+    }
+
+    pub fn get_ime_area(&self) -> Option<Bounds<Pixels>> {
+        let mut state = self.state.borrow_mut();
+        let mut bounds: Option<Bounds<Pixels>> = None;
+        if let Some(mut input_handler) = state.input_handler.take() {
+            drop(state);
+            if let Some(range) = input_handler.selected_text_range() {
+                bounds = input_handler.bounds_for_range(range);
+            }
+            self.state.borrow_mut().input_handler = Some(input_handler);
+        }
+        bounds
+    }
+
     pub fn set_size_and_scale(
         &self,
         width: Option<NonZeroU32>,