linux: Add `keyboard_layout` and `on_keyboard_layout_change` support (#22736)

tims created

No issue, as the functionality is currently not being used in Zed. This
is more of a GPUI improvement.

Currently, `keyboard_layout` and `on_keyboard_layout_change` are already
handled on macOS. This PR implements the same for X11 and Wayland.

Linux supports up to 4 keyboard layout groups (e.g., Group 0: English
US, Group 1: Bulgarian, etc). On X11 and Wayland, `event` provides a new
active group, which maps to the `layout_index`. We already store keymap
state from where we can get the current `layout_index`. By comparing
them, we determine if the layout has changed.

X11:
<img
src="https://github.com/user-attachments/assets/b528db77-1ff2-4f17-aac5-7654837edeb9"
alt="x11" width="300px" />

Wayland:
<img
src="https://github.com/user-attachments/assets/2b4e2a30-b0f4-495c-96bb-7bca41365d56"
alt="wayland" width="300px" />

Release Notes:

- N/A

Change summary

crates/gpui/src/platform/linux/headless/client.rs |  4 ++
crates/gpui/src/platform/linux/platform.rs        |  8 ++-
crates/gpui/src/platform/linux/wayland/client.rs  | 32 +++++++++++++++++
crates/gpui/src/platform/linux/x11/client.rs      | 25 ++++++++++++
4 files changed, 65 insertions(+), 4 deletions(-)

Detailed changes

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

@@ -47,6 +47,10 @@ impl LinuxClient for HeadlessClient {
         f(&mut self.0.borrow_mut().common)
     }
 
+    fn keyboard_layout(&self) -> String {
+        "unknown".to_string()
+    }
+
     fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
         vec![]
     }

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

@@ -46,6 +46,7 @@ const FILE_PICKER_PORTAL_MISSING: &str =
 pub trait LinuxClient {
     fn compositor_name(&self) -> &'static str;
     fn with_common<R>(&self, f: impl FnOnce(&mut LinuxCommon) -> R) -> R;
+    fn keyboard_layout(&self) -> String;
     fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>>;
     #[allow(unused)]
     fn display(&self, id: DisplayId) -> Option<Rc<dyn PlatformDisplay>>;
@@ -76,6 +77,7 @@ pub(crate) struct PlatformHandlers {
     pub(crate) app_menu_action: Option<Box<dyn FnMut(&dyn Action)>>,
     pub(crate) will_open_app_menu: Option<Box<dyn FnMut()>>,
     pub(crate) validate_app_menu_command: Option<Box<dyn FnMut(&dyn Action) -> bool>>,
+    pub(crate) keyboard_layout_change: Option<Box<dyn FnMut()>>,
 }
 
 pub(crate) struct LinuxCommon {
@@ -133,11 +135,11 @@ impl<P: LinuxClient + 'static> Platform for P {
     }
 
     fn keyboard_layout(&self) -> String {
-        "unknown".into()
+        self.keyboard_layout()
     }
 
-    fn on_keyboard_layout_change(&self, _callback: Box<dyn FnMut()>) {
-        // todo(linux)
+    fn on_keyboard_layout_change(&self, callback: Box<dyn FnMut()>) {
+        self.with_common(|common| common.callbacks.keyboard_layout_change = Some(callback));
     }
 
     fn run(&self, on_finish_launching: Box<dyn FnOnce()>) {

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

@@ -588,6 +588,19 @@ impl WaylandClient {
 }
 
 impl LinuxClient for WaylandClient {
+    fn keyboard_layout(&self) -> String {
+        let state = self.0.borrow();
+        if let Some(keymap_state) = &state.keymap_state {
+            let layout_idx = keymap_state.serialize_layout(xkbcommon::xkb::STATE_LAYOUT_EFFECTIVE);
+            keymap_state
+                .get_keymap()
+                .layout_get_name(layout_idx)
+                .to_string()
+        } else {
+            "unknown".to_string()
+        }
+    }
+
     fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
         self.0
             .borrow()
@@ -1139,6 +1152,13 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
                 };
                 state.keymap_state = Some(xkb::State::new(&keymap));
                 state.compose_state = get_xkb_compose_state(&xkb_context);
+
+                if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take() {
+                    drop(state);
+                    callback();
+                    state = client.borrow_mut();
+                    state.common.callbacks.keyboard_layout_change = Some(callback);
+                }
             }
             wl_keyboard::Event::Enter { surface, .. } => {
                 state.keyboard_focused_window = get_window(&mut state, &surface.id());
@@ -1176,9 +1196,21 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
                 let focused_window = state.keyboard_focused_window.clone();
 
                 let keymap_state = state.keymap_state.as_mut().unwrap();
+                let old_layout =
+                    keymap_state.serialize_layout(xkbcommon::xkb::STATE_LAYOUT_EFFECTIVE);
                 keymap_state.update_mask(mods_depressed, mods_latched, mods_locked, 0, 0, group);
                 state.modifiers = Modifiers::from_xkb(keymap_state);
 
+                if group != old_layout {
+                    if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take()
+                    {
+                        drop(state);
+                        callback();
+                        state = client.borrow_mut();
+                        state.common.callbacks.keyboard_layout_change = Some(callback);
+                    }
+                }
+
                 let Some(focused_window) = focused_window else {
                     return;
                 };

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

@@ -37,7 +37,7 @@ use x11rb::{
 };
 use xim::{x11rb::X11rbClient, AttributeName, Client, InputStyle};
 use xkbc::x11::ffi::{XKB_X11_MIN_MAJOR_XKB_VERSION, XKB_X11_MIN_MINOR_XKB_VERSION};
-use xkbcommon::xkb::{self as xkbc, LayoutIndex, ModMask};
+use xkbcommon::xkb::{self as xkbc, LayoutIndex, ModMask, STATE_LAYOUT_EFFECTIVE};
 
 use super::{
     button_or_scroll_from_event_detail, get_valuator_axis_index, modifiers_from_state,
@@ -840,6 +840,8 @@ impl X11Client {
             }
             Event::XkbStateNotify(event) => {
                 let mut state = self.0.borrow_mut();
+                let old_layout = state.xkb.serialize_layout(STATE_LAYOUT_EFFECTIVE);
+                let new_layout = u32::from(event.group);
                 state.xkb.update_mask(
                     event.base_mods.into(),
                     event.latched_mods.into(),
@@ -853,6 +855,17 @@ impl X11Client {
                     latched_layout: event.latched_group as u32,
                     locked_layout: event.locked_group.into(),
                 };
+
+                if new_layout != old_layout {
+                    if let Some(mut callback) = state.common.callbacks.keyboard_layout_change.take()
+                    {
+                        drop(state);
+                        callback();
+                        state = self.0.borrow_mut();
+                        state.common.callbacks.keyboard_layout_change = Some(callback);
+                    }
+                }
+
                 let modifiers = Modifiers::from_xkb(&state.xkb);
                 if state.modifiers == modifiers {
                     drop(state);
@@ -1265,6 +1278,16 @@ impl LinuxClient for X11Client {
         f(&mut self.0.borrow_mut().common)
     }
 
+    fn keyboard_layout(&self) -> String {
+        let state = self.0.borrow();
+        let layout_idx = state.xkb.serialize_layout(STATE_LAYOUT_EFFECTIVE);
+        state
+            .xkb
+            .get_keymap()
+            .layout_get_name(layout_idx)
+            .to_string()
+    }
+
     fn displays(&self) -> Vec<Rc<dyn PlatformDisplay>> {
         let state = self.0.borrow();
         let setup = state.xcb_connection.setup();