Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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;