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

commit7cc0ff69cee7906d051e6d7109f850e8250df4fc
parent5187fa15b8
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 20:27
osk: show the on-screen keyboard when a touch activates a text field

A touch on an app's window arms osk.rs for 800 ms; the first
text-input-v3 enable or commit inside that window runs
`cce-keyboard show`, and the field going away runs `cce-keyboard hide`
250 ms later (cancelled by any enable, so moving between fields keeps one
board), only when osk.rs showed it. `window_manager { osk_on_touch }`
turns it off.

The relay now enters text inputs whether or not an input method is
registered: river's rule meant no client ever enabled a field. A refocus
of the same surface is no longer a leave (it was an assert), a text input
bound after its client took focus is entered at creation, and an input
method arriving or leaving no longer re-runs focus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                    |  19 ++++++
 src/lib.rs                   |   2 +
 src/server/config.rs         |  16 ++++-
 src/server/input_relay.rs    |  71 ++++++++++++--------
 src/server/osk.rs            | 150 +++++++++++++++++++++++++++++++++++++++++++
 src/server/text_input.rs     |  13 ++++
 src/server/touch.rs          |  17 +++++
 src/server/window_manager.rs |   4 ++
 8 files changed, 264 insertions(+), 28 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index d97fb108..19aa4dc1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1211,6 +1211,25 @@ so `weston-simple-touch` under `WAYLAND_DEBUG=1` shows the `Client` route's
 `wl_touch` traffic — and the `cancel` a third finger sends it. A shadow's
 input.kdl is its own copy: add `edge_*`/`pinch3_*` binds there to try them.
 
+**The on-screen keyboard follows a touched field** (`osk.rs`, since
+2026-10-05). A finger on an app's window (`note_touch` at a `Client` down
+or lift, or a `Pointer` tap's lift; never a layer surface, so taps on the
+board itself do not count) arms it for `TOUCH_WINDOW` (800 ms), and the
+first text-input-v3 enable or commit inside that window spends the touch and
+runs `cce-keyboard show`. When the field goes (`disable_text_input`: a
+disable, a destroy, or focus moving), `cce-keyboard hide` runs after
+`HIDE_DELAY_MS` (250 ms) — cancelled by any enable, so moving field to field
+keeps one board — and only if this module showed it, so a board summoned by
+Super+O stays. Off with `window_manager { osk_on_touch (bool)false }`. It
+needed the relay to **enter text inputs without an input method**:
+`InputRelay::focus` used to send `enter` only when one was registered
+(river's rule), so no client ever enabled a field. It now enters always, a
+refocus of the same surface is no longer a leave (it was an `assert`), a
+text input bound after its client took focus is entered at creation, and an
+input method arriving or leaving no longer re-runs focus. Shadow check:
+`ctl touch tap` on a cce-gallery TextBox brings `cce-keyboard show` up;
+`pointer-click` on it does not.
+
 ### Portal global shortcuts
 
 A native Wayland app cannot grab a key; it asks xdg-desktop-portal's
diff --git a/src/lib.rs b/src/lib.rs
index dd365a9c..b4b00f83 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -103,6 +103,8 @@ pub mod xwayland_override_redirect;
 pub mod text_input;
 #[path = "server/input_relay.rs"]
 pub mod input_relay;
+#[path = "server/osk.rs"]
+pub mod osk;
 #[path = "server/input_popup.rs"]
 pub mod input_popup;
 #[path = "server/drag_icon.rs"]
diff --git a/src/server/config.rs b/src/server/config.rs
index 1582d352..d03b4af0 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -504,6 +504,10 @@ pub struct WindowManagerConfig {
     /// delivered as a vertical scroll with Shift held for the gesture. KDL:
     /// `touchpad_hscroll_shift_apps "Houdini FX"`.
     pub touchpad_hscroll_shift_apps: Option<Vec<String>>,
+    /// Show the on-screen keyboard when a touch activates a text field, and
+    /// hide it when the field lets go (`osk.rs`). Default on. KDL:
+    /// `osk_on_touch (bool)false`.
+    pub osk_on_touch: Option<bool>,
 }
 
 #[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
@@ -2640,7 +2644,8 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let swipe_repeat_threshold = get_child_arg_f64_opt(node, "swipe_repeat_threshold");
         let touchpad_view_invert = get_child_arg_bool_opt(node, "touchpad_view_invert");
         let touchpad_hscroll_shift_apps = get_child_args_string_vec_opt(node, "touchpad_hscroll_shift_apps");
-        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi, xwayland_hidpi_except, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, swipe_peek, swipe_repeat_peek, swipe_focus_cone, swipe_threshold, swipe_repeat_threshold, touchpad_view_invert, touchpad_hscroll_shift_apps });
+        let osk_on_touch = get_child_arg_bool_opt(node, "osk_on_touch");
+        window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi, xwayland_hidpi_except, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, swipe_peek, swipe_repeat_peek, swipe_focus_cone, swipe_threshold, swipe_repeat_threshold, touchpad_view_invert, touchpad_hscroll_shift_apps, osk_on_touch });
     }
 
     Ok(Config {
@@ -2736,6 +2741,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
             .unwrap_or(state.swipe_threshold * 4.0);
         state.touchpad_view_invert = tv.and_then(|w| w.touchpad_view_invert).unwrap_or(false);
         state.touchpad_hscroll_shift_apps = tv.and_then(|w| w.touchpad_hscroll_shift_apps.clone()).unwrap_or_default();
+        state.osk_on_touch = tv.and_then(|w| w.osk_on_touch).unwrap_or(true);
     }
     state.display = config.display.clone();
     state.on_app_exit = config
@@ -3483,6 +3489,14 @@ style {
         assert_eq!(parse_edge_gesture("super+t"), None);
     }
 
+    #[test]
+    fn test_kdl_osk_on_touch() {
+        let off = parse_kdl_config("window_manager {\n osk_on_touch (bool)false\n}").unwrap();
+        assert_eq!(off.window_manager.unwrap().osk_on_touch, Some(false));
+        let unset = parse_kdl_config("window_manager {\n}").unwrap();
+        assert_eq!(unset.window_manager.unwrap().osk_on_touch, None);
+    }
+
     #[test]
     fn test_kdl_touchpad_hscroll_shift_apps() {
         let content = r#"
diff --git a/src/server/input_relay.rs b/src/server/input_relay.rs
index 11cc0e77..2f2a1194 100644
--- a/src/server/input_relay.rs
+++ b/src/server/input_relay.rs
@@ -20,6 +20,9 @@ pub struct InputRelay {
     pub input_method_new_popup: ffi::wl_listener,
 
     pub grab_keyboard_destroy: ffi::wl_listener,
+
+    /// The on-screen keyboard following a touched field (`osk.rs`).
+    pub osk: crate::osk::Osk,
 }
 
 unsafe fn connect_listener(
@@ -54,6 +57,8 @@ impl InputRelay {
         self.input_method_destroy = std::mem::zeroed();
         self.input_method_new_popup = std::mem::zeroed();
         self.grab_keyboard_destroy = std::mem::zeroed();
+        // The relay starts as zeroed memory: write, don't assign over it.
+        std::ptr::write(&mut self.osk, crate::osk::Osk::new((*seat).server));
     }
 
     pub unsafe fn new_input_method(&mut self, input_method: *mut ffi::wlr_input_method_v2) {
@@ -76,15 +81,19 @@ impl InputRelay {
         connect_listener(&mut (*input_method).events.destroy, &mut self.input_method_destroy, handle_input_method_destroy);
         connect_listener(&mut (*input_method).events.new_popup_surface, &mut self.input_method_new_popup, handle_input_method_new_popup);
 
-        let focused_surface = (*seat).focused.surface();
-        if !focused_surface.is_null() {
-            self.focus(focused_surface);
+        // Text inputs are entered whether or not an input method exists
+        // (`focus`), so one that arrives late only needs telling about the
+        // field already enabled.
+        if !self.text_input.is_null() {
+            ffi::wlr_input_method_v2_send_activate(input_method);
+            self.send_input_method_state();
         }
     }
 
     pub unsafe fn disable_text_input(&mut self) {
         assert!(!self.text_input.is_null());
         self.text_input = std::ptr::null_mut();
+        self.osk.field_gone();
 
         if !self.input_method.is_null() {
             let mut pos = self.input_popups.next;
@@ -143,40 +152,50 @@ impl InputRelay {
         ffi::wlr_input_method_v2_send_done(input_method);
     }
 
+    /// Keyboard focus moved to `new_focus` (null: nowhere). Text inputs of
+    /// the client that lost it are left, and those of the client that got it
+    /// entered — whether or not an input method is running. river entered
+    /// them only with one, but then no client ever enabled a field, and the
+    /// on-screen keyboard (`osk.rs`) is driven by exactly that enable.
     pub unsafe fn focus(&mut self, new_focus: *mut ffi::wlr_surface) {
-        // Send leave events
+        let head_ptr = &self.text_inputs as *const ffi::wl_list as *mut ffi::wl_list;
+
+        // Send leave events. A text input already on the new surface keeps
+        // it: a refocus of the same surface is not a leave.
         let mut pos = self.text_inputs.next;
-        let head_ptr = &self.text_inputs as *const ffi::wl_list;
-        while pos != head_ptr as *mut ffi::wl_list {
+        while pos != head_ptr {
             let next_pos = (*pos).next;
             let text_input = crate::container_of!(pos, TextInput, link);
             let focused = (*(*text_input).wlr_text_input).focused_surface;
-            if !focused.is_null() {
-                assert!(focused != new_focus);
+            if !focused.is_null() && focused != new_focus {
                 ffi::wlr_text_input_v3_send_leave((*text_input).wlr_text_input);
             }
             pos = next_pos;
         }
 
-        // Clear currently enabled text input
+        // Clear the enabled text input unless its surface kept focus
         if !self.text_input.is_null() {
-            self.disable_text_input();
+            let focused = (*(*self.text_input).wlr_text_input).focused_surface;
+            if focused.is_null() || focused != new_focus {
+                self.disable_text_input();
+            }
         }
 
-        // Send enter events if we have an input method
-        if !new_focus.is_null() && !self.input_method.is_null() {
-            let new_client = ffi::wl_resource_get_client(ffi::river_wlr_surface_get_resource(new_focus));
-            let mut pos = self.text_inputs.next;
-            while pos != head_ptr as *mut ffi::wl_list {
-                let next_pos = (*pos).next;
-                let text_input = crate::container_of!(pos, TextInput, link);
-                let text_input_resource = (*(*text_input).wlr_text_input).resource;
-                let client = ffi::wl_resource_get_client(text_input_resource);
-                if client == new_client {
-                    ffi::wlr_text_input_v3_send_enter((*text_input).wlr_text_input, new_focus);
-                }
-                pos = next_pos;
+        if new_focus.is_null() {
+            return;
+        }
+        let new_client = ffi::wl_resource_get_client(ffi::river_wlr_surface_get_resource(new_focus));
+        let mut pos = self.text_inputs.next;
+        while pos != head_ptr {
+            let next_pos = (*pos).next;
+            let text_input = crate::container_of!(pos, TextInput, link);
+            let wlr_text_input = (*text_input).wlr_text_input;
+            if (*wlr_text_input).focused_surface.is_null()
+                && ffi::wl_resource_get_client((*wlr_text_input).resource) == new_client
+            {
+                ffi::wlr_text_input_v3_send_enter(wlr_text_input, new_focus);
             }
+            pos = next_pos;
         }
     }
 }
@@ -230,10 +249,8 @@ unsafe extern "C" fn handle_input_method_destroy(listener: *mut ffi::wl_listener
     wl_listener_remove_safe(&mut (*relay).input_method_destroy);
     wl_listener_remove_safe(&mut (*relay).input_method_new_popup);
     (*relay).input_method = std::ptr::null_mut();
-
-    (*relay).focus(std::ptr::null_mut());
-
-    assert!((*relay).text_input.is_null());
+    // The text inputs stay entered and enabled: they never depended on the
+    // input method (`InputRelay::focus`).
 }
 
 unsafe extern "C" fn handle_input_method_grab_keyboard(
diff --git a/src/server/osk.rs b/src/server/osk.rs
new file mode 100644
index 00000000..57798104
--- /dev/null
+++ b/src/server/osk.rs
@@ -0,0 +1,150 @@
+//! The on-screen keyboard follows a touched text field.
+//!
+//! When a client enables (or updates) a text-input-v3 field right after a
+//! finger landed on one of its windows, the board is shown with
+//! `cce-keyboard show`; when the field lets go it is hidden again with
+//! `cce-keyboard hide`, but only if this module showed it — a board summoned
+//! by hand (Super+O) stays until it is dismissed by hand.
+//!
+//! The touch is the whole signal: text-input-v3 says that a field is active,
+//! never why, and a field focused from the keyboard or with the pointer must
+//! not raise the board. So a touch on a window arms a short window
+//! (`TOUCH_WINDOW`), and the first activation inside it spends it. Spending
+//! it matters: every key tapped on the board makes the client commit a new
+//! caret, and a touch that could be reused would re-launch `show` per key.
+//! Touches on the board itself never arm it (they land on a layer surface).
+//!
+//! The hide waits `HIDE_DELAY_MS`: moving from one field to the next
+//! disables one text input and enables the other, and the board should not
+//! close and reopen in between.
+//!
+//! Off with `window_manager { osk_on_touch (bool)false }`.
+
+use std::time::{Duration, Instant};
+
+use crate::ffi;
+use crate::server::Server;
+
+/// How long after a touch on a window an activation still counts as the
+/// touch's. A tap that the client sees as a button press (no wl_touch) is
+/// only delivered at the lift, and the field enables a frame or two after
+/// that; a long press must still be inside it.
+const TOUCH_WINDOW: Duration = Duration::from_millis(800);
+
+/// How long a field may be gone before the board follows it.
+const HIDE_DELAY_MS: i32 = 250;
+
+const SHOW_CMD: &str = "cce-keyboard show";
+const HIDE_CMD: &str = "cce-keyboard hide";
+
+pub struct Osk {
+    server: *mut Server,
+    /// The last touch on a window, until an activation spends it.
+    armed_at: Option<Instant>,
+    /// This module put the board up, so it may take it down.
+    shown_by_us: bool,
+    hide_timer: *mut ffi::wl_event_source,
+}
+
+impl Osk {
+    pub fn new(server: *mut Server) -> Self {
+        Osk { server, armed_at: None, shown_by_us: false, hide_timer: std::ptr::null_mut() }
+    }
+
+    unsafe fn enabled(&self) -> bool {
+        !self.server.is_null() && (*self.server).wm.osk_on_touch
+    }
+
+    /// A finger landed on (or, for a tap, lifted from) a window.
+    pub fn note_touch(&mut self) {
+        self.armed_at = Some(Instant::now());
+    }
+
+    /// Whether a touch armed the board recently enough to count, disarming
+    /// it either way.
+    fn spend_touch(&mut self, now: Instant) -> bool {
+        self.armed_at.take().is_some_and(|t| now.saturating_duration_since(t) <= TOUCH_WINDOW)
+    }
+
+    /// The seat's text input was enabled, or committed new state while
+    /// enabled.
+    pub unsafe fn field_active(&mut self) {
+        // Whatever raised the field, a hide queued by the last one is moot.
+        self.cancel_hide();
+        if !self.enabled() {
+            return;
+        }
+        if !self.spend_touch(Instant::now()) {
+            return;
+        }
+        log::debug!("osk: a touched field activated; showing the board");
+        (*self.server).wm.execute_action(&crate::config::Action::Spawn, Some(SHOW_CMD));
+        self.shown_by_us = true;
+    }
+
+    /// The seat's text input went away (disabled, destroyed, or focus left).
+    pub unsafe fn field_gone(&mut self) {
+        if !self.shown_by_us || self.server.is_null() {
+            return;
+        }
+        if self.hide_timer.is_null() {
+            let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
+            self.hide_timer = ffi::wl_event_loop_add_timer(event_loop, Some(handle_hide_timer), self as *mut Osk as *mut _);
+            if self.hide_timer.is_null() {
+                log::error!("osk: failed to create the hide timer; hiding now");
+                self.hide_now();
+                return;
+            }
+        }
+        ffi::wl_event_source_timer_update(self.hide_timer, HIDE_DELAY_MS);
+    }
+
+    unsafe fn cancel_hide(&mut self) {
+        if !self.hide_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.hide_timer, 0);
+        }
+    }
+
+    unsafe fn hide_now(&mut self) {
+        if !self.shown_by_us {
+            return;
+        }
+        self.shown_by_us = false;
+        log::debug!("osk: the field let go; hiding the board");
+        (*self.server).wm.execute_action(&crate::config::Action::Spawn, Some(HIDE_CMD));
+    }
+}
+
+unsafe extern "C" fn handle_hide_timer(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
+    let osk = &mut *(data as *mut Osk);
+    osk.hide_now();
+    0
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn a_touch_is_spent_by_one_activation() {
+        let mut osk = Osk::new(std::ptr::null_mut());
+        osk.note_touch();
+        let now = Instant::now();
+        assert!(osk.spend_touch(now));
+        assert!(!osk.spend_touch(now), "the second activation is the caret moving, not a new tap");
+    }
+
+    #[test]
+    fn a_stale_touch_does_not_count() {
+        let mut osk = Osk::new(std::ptr::null_mut());
+        osk.note_touch();
+        assert!(!osk.spend_touch(Instant::now() + TOUCH_WINDOW + Duration::from_millis(1)));
+        assert!(osk.armed_at.is_none());
+    }
+
+    #[test]
+    fn without_a_touch_nothing_shows() {
+        let mut osk = Osk::new(std::ptr::null_mut());
+        assert!(!osk.spend_touch(Instant::now()));
+    }
+}
diff --git a/src/server/text_input.rs b/src/server/text_input.rs
index d44ccd91..fac6db53 100644
--- a/src/server/text_input.rs
+++ b/src/server/text_input.rs
@@ -71,6 +71,17 @@ impl TextInput {
         connect_listener(&mut (*wlr_text_input).events.disable, &mut (*raw).disable, handle_disable);
         connect_listener(&mut (*wlr_text_input).events.destroy, &mut (*raw).destroy, handle_destroy);
 
+        // A client that binds its text input after its surface took focus
+        // (cce-ui binds lazily) is entered now; `InputRelay::focus` only
+        // enters on a focus change.
+        let focused = (*seat).focused.surface();
+        if !focused.is_null()
+            && ffi::wl_resource_get_client(ffi::river_wlr_surface_get_resource(focused))
+                == ffi::wl_resource_get_client((*wlr_text_input).resource)
+        {
+            ffi::wlr_text_input_v3_send_enter(wlr_text_input, focused);
+        }
+
         Ok(())
     }
 }
@@ -95,6 +106,7 @@ unsafe extern "C" fn handle_enable(listener: *mut ffi::wl_listener, _data: *mut
     }
 
     (*seat).relay.text_input = text_input;
+    (*seat).relay.osk.field_active();
 
     let input_method = (*seat).relay.input_method;
     if !input_method.is_null() {
@@ -115,6 +127,7 @@ unsafe extern "C" fn handle_commit(listener: *mut ffi::wl_listener, _data: *mut
         return;
     }
 
+    (*seat).relay.osk.field_active();
     if !(*seat).relay.input_method.is_null() {
         (*seat).relay.send_input_method_state();
     }
diff --git a/src/server/touch.rs b/src/server/touch.rs
index 6149c47b..36e3ea3a 100644
--- a/src/server/touch.rs
+++ b/src/server/touch.rs
@@ -367,6 +367,18 @@ impl Cursor {
         self.touch_begin(id, lx, ly, time_msec);
     }
 
+    /// Arm the on-screen keyboard (`osk.rs`) when the finger is on an
+    /// app's window — not the board itself, a layer surface, nor the bar.
+    unsafe fn note_touch_for_osk(&mut self, lx: f64, ly: f64) {
+        let server = (*self.seat).server;
+        let Some(result) = (*server).scene.at(lx, ly) else { return };
+        if let SceneNodeDataVal::Window(window) = result.data {
+            if !(*window).is_status_bar() && !(*window).is_wallpaper() {
+                (*self.seat).relay.osk.note_touch();
+            }
+        }
+    }
+
     /// Route a finger normally (`touch_route_at`) and deliver its down.
     unsafe fn touch_begin(&mut self, id: i32, lx: f64, ly: f64, time_msec: u32) {
         let server = (*self.seat).server;
@@ -384,6 +396,7 @@ impl Cursor {
                     SceneNodeDataVal::Window(window) => {
                         if !(*window).is_status_bar() && !(*window).is_wallpaper() {
                             seat.focus(Focus::Window(window));
+                            seat.relay.osk.note_touch();
                         }
                     }
                     SceneNodeDataVal::LayerSurface(layer_surface) => {
@@ -466,10 +479,14 @@ impl Cursor {
         match point.route {
             TouchRoute::Claimed => self.claim_lift(id, point, time_msec, false),
             TouchRoute::Client { .. } => {
+                // A field that starts editing on the release is still this
+                // touch's (`osk.rs`).
+                self.note_touch_for_osk(point.lx, point.ly);
                 ffi::wlr_seat_touch_notify_up((*self.seat).wlr_seat, time_msec, id);
             }
             TouchRoute::Pointer { start, pressed: false, .. } => {
                 // A tap.
+                self.note_touch_for_osk(start.0, start.1);
                 self.warp_to(start.0, start.1);
                 self.inject_button(BTN_LEFT, true);
                 self.inject_button(BTN_LEFT, false);
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index dc519745..d47668f1 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -466,6 +466,8 @@ pub struct WindowManager {
     pub touchpad_view_swipe_tumble: bool,
     pub touchpad_view_sensitivity: f64,
     pub touchpad_view_invert: bool,
+    /// `window_manager { osk_on_touch }` (`osk.rs`).
+    pub osk_on_touch: bool,
     /// `window_manager { swipe_peek }`: the desktop's lean toward a
     /// directional swipe bind at its threshold, screen px (default 60;
     /// 0 disables). See `cursor::swipe_peek_for`.
@@ -800,6 +802,7 @@ impl WindowManager {
         self.swipe_threshold = 70.0;
         self.swipe_repeat_threshold = 280.0;
         self.touchpad_view_invert = false;
+        self.osk_on_touch = true;
         self.touchpad_hscroll_shift_apps = Vec::new();
         self.display = std::collections::HashMap::new();
         self.input_rules = Vec::new();
@@ -886,6 +889,7 @@ impl WindowManager {
         self.swipe_threshold = 70.0;
         self.swipe_repeat_threshold = 280.0;
         self.touchpad_view_invert = false;
+        self.osk_on_touch = true;
         self.touchpad_hscroll_shift_apps = Vec::new();
         self.display = std::collections::HashMap::new();
         self.has_restored_focused_window = false;