git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitfdd1209b6a83c5218f75e330c95ed091adb0f734
parent7db8c03224
authorLucas Galante <[email protected]>
date2026-08-30 19:26
feat: wire virtual keyboards into the keyboard stack

wlr_virtual_keyboard_manager_v1 was created but had no new_virtual_keyboard
listener, so virtual keyboards were advertised to clients and then silently
dropped — keys sent through zwp_virtual_keyboard_v1 went nowhere. Wire them
the same way hardware keyboards go in (InputDevice -> Keyboard -> attach_device
-> KeyboardGroup), so compositor keybindings apply to injected keys too, and
honor the seat the client bound rather than assuming the default.

Two consequences of virtual keyboards actually existing, handled here:

- The client's keymap arrives AFTER creation (the zwp keymap request), but the
  group's own wlr_keyboard is what processes keys — with no keymap it has no
  xkb_state and drops every key. Virtual keyboards get a keymap listener that
  rewrites their (private) group's keymap in place when it lands.
- set_group's matching loop could now let a hardware keyboard join a virtual
  group, whose keymap changes under it and whose IME grab is deliberately
  disabled. Skip virtual groups when matching.

Verified in a shadow instance with a scratch zwp_virtual_keyboard_v1 client:
typed text (with and without held modifiers) reaches the focused terminal, and
client disconnect tears the device down cleanly. This is what lets wtype-style
tools work and lets cce-shadow sessions exercise keyboard paths — e.g. the
Escape-closes-status-menus arm (7db8c03) is now behaviorally testable.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/server/input_manager.rs | 30 +++++++++++++++++++++++++
 src/server/keyboard.rs      | 55 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 85 insertions(+)

diff --git a/src/server/input_manager.rs b/src/server/input_manager.rs
index 51bc4c2..c4bd1fb 100644
--- a/src/server/input_manager.rs
+++ b/src/server/input_manager.rs
@@ -27,6 +27,7 @@ pub struct InputManager {
     pub new_text_input: ffi::wl_listener,
     pub new_input_method: ffi::wl_listener,
     pub new_virtual_pointer_listener: ffi::wl_listener,
+    pub new_virtual_keyboard_listener: ffi::wl_listener,
 
     /// Pending deferred pointer-focus re-evaluation (see
     /// [`InputManager::schedule_pointer_refresh`]); null when none. One idle
@@ -104,6 +105,11 @@ impl InputManager {
         (*new_virtual_pointer_ptr).notify = Some(handle_new_virtual_pointer);
         wl_signal_add(&mut (*self.virtual_pointer_manager).events.new_virtual_pointer, &mut self.new_virtual_pointer_listener);
 
+        // Connect new_virtual_keyboard listener
+        let new_virtual_keyboard_ptr = &mut self.new_virtual_keyboard_listener as *mut ffi::wl_listener as *mut WlListener;
+        (*new_virtual_keyboard_ptr).notify = Some(handle_new_virtual_keyboard);
+        wl_signal_add(&mut (*self.virtual_keyboard_manager).events.new_virtual_keyboard, &mut self.new_virtual_keyboard_listener);
+
         Ok(())
     }
 
@@ -188,6 +194,7 @@ impl InputManager {
         wl_listener_remove(&mut self.new_text_input);
         wl_listener_remove(&mut self.new_input_method);
         wl_listener_remove(&mut self.new_virtual_pointer_listener);
+        wl_listener_remove(&mut self.new_virtual_keyboard_listener);
         log::info!("[deinit] InputManager::deinit finished");
     }
 }
@@ -434,6 +441,29 @@ unsafe extern "C" fn handle_new_virtual_pointer(listener: *mut ffi::wl_listener,
     (*im.default_seat).attach_device(device);
 }
 
+unsafe extern "C" fn handle_new_virtual_keyboard(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let im = &mut *crate::container_of!(listener, InputManager, new_virtual_keyboard_listener);
+    let virtual_keyboard = data as *mut ffi::wlr_virtual_keyboard_v1;
+
+    log::info!("new virtual keyboard device connected");
+
+    let wlr_device = &mut (*virtual_keyboard).keyboard.base as *mut ffi::wlr_input_device;
+
+    // Honor the seat the client bound the virtual keyboard to, like input
+    // methods do; fall back to the default seat if it carries no Seat data.
+    let mut seat = ffi::river_wlr_seat_get_data((*virtual_keyboard).seat) as *mut Seat;
+    if seat.is_null() {
+        seat = im.default_seat;
+    }
+
+    let device = crate::input_device::InputDevice::new(seat, wlr_device, true);
+    crate::keyboard::Keyboard::create(device);
+
+    // Same path a hardware keyboard takes: attach_device puts the keyboard in
+    // a (private, virtual) KeyboardGroup, so compositor keybindings apply.
+    (*seat).attach_device(device);
+}
+
 unsafe extern "C" fn handle_pointer_refresh_idle(data: *mut std::ffi::c_void) {
     let manager = data as *mut InputManager;
     (*manager).pointer_refresh_idle = std::ptr::null_mut();
diff --git a/src/server/keyboard.rs b/src/server/keyboard.rs
index 55ec155..68c79b3 100644
--- a/src/server/keyboard.rs
+++ b/src/server/keyboard.rs
@@ -19,6 +19,10 @@ pub struct Keyboard {
     pub wlr_keyboard: *mut ffi::wlr_keyboard,
     pub key_listener: ffi::wl_listener,
     pub modifiers_listener: ffi::wl_listener,
+    /// Registered for virtual keyboards only: their keymap arrives from the
+    /// client *after* creation (the zwp_virtual_keyboard_v1 keymap request),
+    /// so it must be forwarded to the group once it lands.
+    pub keymap_listener: ffi::wl_listener,
     pub pressed: HashSet<u32>,
     pub group: *mut KeyboardGroup,
     pub group_link: ffi::wl_list,
@@ -50,6 +54,7 @@ impl Keyboard {
             wlr_keyboard,
             key_listener: std::mem::zeroed(),
             modifiers_listener: std::mem::zeroed(),
+            keymap_listener: std::mem::zeroed(),
             pressed: HashSet::new(),
             group: std::ptr::null_mut(),
             group_link: std::mem::zeroed(),
@@ -75,6 +80,13 @@ impl Keyboard {
         let modifiers_signal = ffi::river_wlr_keyboard_get_modifiers_signal(wlr_keyboard);
         wl_signal_add(modifiers_signal, &mut (*keyboard).modifiers_listener);
 
+        if virtual_device {
+            let keymap_listener_ptr = &mut (*keyboard).keymap_listener as *mut ffi::wl_listener as *mut WlListener;
+            (*keymap_listener_ptr).notify = Some(handle_keymap);
+            let keymap_signal = ffi::river_wlr_keyboard_get_keymap_signal(wlr_keyboard);
+            wl_signal_add(keymap_signal, &mut (*keyboard).keymap_listener);
+        }
+
         if !virtual_device && should_set_keymap((*(*device).seat).server) {
             if !keymap.is_null() {
                 ffi::wlr_keyboard_set_keymap(wlr_keyboard, keymap);
@@ -90,6 +102,10 @@ impl Keyboard {
     pub unsafe fn destroy(keyboard: *mut Self) {
         wl_listener_remove(&mut (*keyboard).key_listener);
         wl_listener_remove(&mut (*keyboard).modifiers_listener);
+        // destroy_fn runs before the InputDevice is freed, so device is valid.
+        if (*(*keyboard).device).virtual_device {
+            wl_listener_remove(&mut (*keyboard).keymap_listener);
+        }
 
         if !(*keyboard).group.is_null() {
             let keys: Vec<u32> = (*keyboard).pressed.iter().cloned().collect();
@@ -114,6 +130,13 @@ impl Keyboard {
             while curr != seat_groups_head {
                 let next = (*curr).next;
                 let group = crate::container_of!(curr, KeyboardGroup, link);
+                // A virtual keyboard's group is private to it (its keymap can
+                // change under it, and its IME grab is disabled) — hardware
+                // keyboards must never join one.
+                if (*group).virtual_device {
+                    curr = next;
+                    continue;
+                }
                 let config_ptr = &mut self.config as *mut KeyboardConfig;
                 if (*group).match_config(config_ptr) {
                     self.group = (*group).ref_group();
@@ -246,6 +269,38 @@ unsafe extern "C" fn handle_key(listener: *mut ffi::wl_listener, data: *mut std:
     }
 }
 
+/// Virtual keyboards only. The client's keymap request lands after the group
+/// already exists, and the group's own wlr_keyboard is what processes keys —
+/// with no keymap it has no xkb_state and every key is dropped. The group is
+/// private to this keyboard (see set_group), so its config can be rewritten
+/// in place instead of regrouping.
+unsafe extern "C" fn handle_keymap(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
+    let keyboard = &mut *crate::container_of!(listener, Keyboard, keymap_listener);
+    let keymap = ffi::river_wlr_keyboard_get_keymap(keyboard.wlr_keyboard);
+
+    if !keymap.is_null() {
+        ffi::xkb_keymap_ref(keymap);
+    }
+    if !keyboard.config.keymap.is_null() {
+        ffi::xkb_keymap_unref(keyboard.config.keymap);
+    }
+    keyboard.config.keymap = keymap;
+
+    if !keyboard.group.is_null() {
+        let group = &mut *keyboard.group;
+        if !keymap.is_null() {
+            ffi::xkb_keymap_ref(keymap);
+        }
+        if !group.config.keymap.is_null() {
+            ffi::xkb_keymap_unref(group.config.keymap);
+        }
+        group.config.keymap = keymap;
+        if !keymap.is_null() {
+            group.process_keymap(keymap);
+        }
+    }
+}
+
 unsafe extern "C" fn handle_modifiers(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let keyboard = &mut *crate::container_of!(listener, Keyboard, modifiers_listener);