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

commit80f83f6249bfc1715e9b8e2728ee50a550349047
parent01b1f5f384
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-04 16:12
feat(touch): make touchscreens work

The seat never offered WL_SEAT_CAPABILITY_TOUCH, so no client bound
wl_touch and every touch_notify call reached nobody. It is now offered
while a touch device is attached, and each finger is routed once at
touch-down (cursor::TouchRoute):

- a client that bound wl_touch gets real touch events, focus as a click
  gives, and the press-time menu dismissals; motion stays surface-local
  through the frame frozen at down, so a finger sliding off the window
  keeps reporting to it (the old code re-hit-tested every motion and sent
  the coordinates of whatever surface happened to be underneath);
- everything else -- every cce-ui app, the bar, the lock screen, the
  desktop, and in overview/adjust mode every press -- becomes a held left
  button on the real pointer path, so overview drags, handles and the
  rubber band work by finger with no touch path of their own;
- further fingers while one drives the pointer are ignored.

Cancel is per point now (wlroots 0.20 carries the id), replacing a C
helper that iterated a list its own calls were freeing. The cursor image
hides on touch and returns on real pointer motion. `ccectl touch
down|motion|up|cancel|tap` drives it in a headless shadow.

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

 CLAUDE.md                        |  49 ++++
 src/cce_ctl.rs                   |   3 +
 src/server/cursor.rs             | 571 ++++++++++++++++++++++-----------------
 src/server/drag_icon.rs          |   2 +-
 src/server/seat.rs               |  38 ++-
 src/server/window_manager.rs     |  73 +++++
 src/server/wlroots_log_wrapper.c |   9 +-
 wrapper.h                        |   2 +-
 8 files changed, 499 insertions(+), 248 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c494b21e..4e2e3d5a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1106,6 +1106,55 @@ headless seat has no keyboard and Chromium crashes in
   measurement and window-content readbacks ran every frame to feed a bar-side
   scrim.
 
+### Touchscreens
+
+Until 2026-10-04 the seat never offered the touch capability, so no client
+ever bound `wl_touch` and every `wlr_seat_touch_notify_*` call went
+nowhere: a touchscreen did nothing. The seat now offers it while a touch
+device is attached (`Seat::touch_devices`, counted in `attach_device` /
+`detach_device`), and **each finger is routed once, at touch-down**
+(`cursor::TouchRoute`, decided by `Cursor::touch_route_at`):
+
+- **`Client`** — the surface under it belongs to a client that bound
+  `wl_touch` (`wlr_surface_accepts_touch`: GTK, Qt, Chromium, Xwayland,
+  foot) and the compositor is not in window-adjust mode. It gets real touch
+  events, focus as a click would give, and the press-time menu dismissals
+  (`press_dismissals`, shared with `handle_button`). Motion is mapped through
+  the surface frame frozen at down (origin plus the scene buffer's scale,
+  the pointer implicit grab's `grab_origin`/`grab_scale`), so a finger that
+  slides off the window keeps reporting surface-local positions to it.
+- **`Pointer`** — everything else: a left button held where the finger is,
+  run through the real `handle_button` and motion path. That covers every
+  cce-ui app (the toolkit has no `wl_touch` handling), the status bar, the
+  lock screen, the desktop, and all the compositor's own presses — overview
+  (where every finger takes this route, even over a touch client), the
+  adjust handles, window body drags and the rubber band — none of which
+  needed a touch path of its own. One finger at a time, since the pointer is
+  singular.
+- **`Ignored`** — a second finger while one drives the pointer, or any
+  finger while a real button or seat op holds it.
+
+A cancel on a `Pointer` finger releases the button (a stuck button is worse
+than a stray click), and on a `Client` finger sends `wl_touch.cancel`
+(`river_wlr_seat_touch_cancel_point`, which voids that client's whole
+sequence, the protocol's unit). Unplugging the last touchscreen cancels any
+fingers still down. The cursor image goes away on touch-down
+(`Cursor::hidden_by_touch`; `set_xcursor` and `handle_request_set_cursor`
+both honour it) and real pointer motion brings it back
+(`unhide_after_touch`, which clears pointer focus so the client under it
+re-enters and sets its cursor again).
+
+**Not yet done:** touch gestures (edge swipes, a two-finger pan or pinch of
+the desk) and finger scrolling in cce-ui apps, where a drag is a held button
+and so selects rather than scrolls.
+
+Drive it in a shadow with `ccectl touch down <id> <x> <y>`, `motion <id> <x>
+<y>`, `up <id>`, `cancel <id>` and `tap <x> <y>` (layout pixels). The first
+use sets `Seat::touch_injected`, which offers the capability as a
+touchscreen would, so `weston-simple-touch` under `WAYLAND_DEBUG=1` shows
+the `Client` route's `wl_touch` traffic and a cce-ui app shows the
+`Pointer` route.
+
 ### Portal global shortcuts
 
 A native Wayland app cannot grab a key; it asks xdg-desktop-portal's
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 5d7da17b..afa0cce4 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -108,6 +108,9 @@ fn usage(name: &str, to_stderr: bool) {
     print("  pointer-click [button]             (left|right|middle|back|forward or evdev code)");
     print("  pointer-press [button]             (held until pointer-release — drives drags)");
     print("  pointer-release [button]");
+    print("  touch down <id> <x> <y> | motion <id> <x> <y> | up <id> | cancel <id> | tap <x> <y>");
+    print("                                     (a touchscreen finger, layout pixels; a client that");
+    print("                                      bound wl_touch gets touch, anything else the pointer)");
     print("  migrate-input                      (local: move config.kdl keybindings to input.kdl)");
     print("  keypress <keycode>                 (evdev code; press+release to the focused client)");
     print("  key-down <keycode>                 (modifier codes — ctrl 29/97, shift 42/54,");
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 0d034fdf..4a958f43 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -5,6 +5,37 @@ use crate::scene_node_data::SceneNodeDataVal;
 use crate::drag_icon::DragIcon;
 use std::collections::{HashMap, HashSet};
 
+/// One finger on a touchscreen (`Cursor::touch_points`).
+#[derive(Clone, Copy, Debug)]
+pub struct TouchPoint {
+    pub lx: f64,
+    pub ly: f64,
+    pub route: TouchRoute,
+}
+
+/// Where a touch point's events go, fixed at touch-down for its lifetime —
+/// a finger that slides onto another window keeps talking to the one it
+/// went down on, exactly like a held button's implicit grab.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum TouchRoute {
+    /// A client that bound `wl_touch` gets real touch events. Positions are
+    /// surface-local through the frame frozen at down: `origin` is the
+    /// surface's layout origin and `scale` its surface-per-layout-pixel
+    /// ratio, the same mapping the pointer's implicit grab uses
+    /// (`grab_origin` / `grab_scale`).
+    Client { origin: (f64, f64), scale: f64 },
+    /// This finger drives the pointer: a left button held at its position.
+    /// Everything that is not a touch-capable client goes this way — cce-ui
+    /// apps, the status bar, the lock screen, the desktop, and every press
+    /// the compositor itself handles (overview, the adjust-mode handles,
+    /// drag-selection) — so all of those work by touch without a touch path
+    /// of their own. One finger at a time: the pointer is singular.
+    Pointer,
+    /// Swallowed until it lifts: a second finger while another drives the
+    /// pointer, or a touch while a real button is held.
+    Ignored,
+}
+
 pub struct Cursor {
     pub seat: *mut Seat,
     pub wlr_cursor: *mut ffi::wlr_cursor,
@@ -58,7 +89,15 @@ pub struct Cursor {
     pub tablet_tool_tip_listener: ffi::wl_listener,
     pub tablet_tool_button_listener: ffi::wl_listener,
 
-    pub touch_points: HashMap<i32, (f64, f64)>,
+    /// Every finger on a touchscreen, by touch id: where it is (layout
+    /// coordinates; the drag icon of a touch drag follows it) and where its
+    /// events go, decided once at touch-down (`TouchRoute`).
+    pub touch_points: HashMap<i32, TouchPoint>,
+    /// The cursor image is off because the last input was a touchscreen.
+    /// A finger has no pointer to show, and the emulated pointer warping to
+    /// every tap would otherwise leave an arrow wherever the hand last was.
+    /// Real pointer motion brings it back (`unhide_after_touch`).
+    pub hidden_by_touch: bool,
     pub pressed: HashMap<u32, Option<*mut crate::pointer_binding::PointerBinding>>,
     /// Buttons whose PRESS was forwarded to the focused client. The paired
     /// release must reach the client no matter what the compositor is doing
@@ -179,6 +218,7 @@ impl Default for Cursor {
             tablet_tool_button_listener: unsafe { std::mem::zeroed() },
 
             touch_points: HashMap::new(),
+            hidden_by_touch: false,
             pressed: HashMap::new(),
             notified_pressed: HashSet::new(),
             grab_origin: (0.0, 0.0),
@@ -591,6 +631,9 @@ impl Cursor {
     }
 
     pub unsafe fn set_xcursor(&mut self, name: *const std::os::raw::c_char) {
+        if self.hidden_by_touch {
+            return;
+        }
         ffi::wlr_cursor_set_xcursor(
             self.wlr_cursor,
             self.xcursor_manager,
@@ -1011,6 +1054,33 @@ impl Cursor {
         self.clear_focus();
     }
 
+    /// Take the cursor image off for a touch (see `hidden_by_touch`).
+    unsafe fn hide_for_touch(&mut self) {
+        if self.hidden_by_touch {
+            return;
+        }
+        self.hidden_by_touch = true;
+        self.stop_xcursor_animation();
+        ffi::wlr_cursor_unset_image(self.wlr_cursor);
+    }
+
+    /// Bring the cursor image back once a real pointer moves. Called before
+    /// the motion's passthrough, which re-enters the surface under the
+    /// pointer: the clear here is what makes that enter fresh, so the
+    /// client sets its cursor again — `handle_request_set_cursor` dropped
+    /// whatever it asked for while the image was off. Not while a client
+    /// holds an implicit grab: clearing pointer focus would end it.
+    pub unsafe fn unhide_after_touch(&mut self) {
+        if !self.hidden_by_touch {
+            return;
+        }
+        self.hidden_by_touch = false;
+        if self.notified_pressed.is_empty() {
+            ffi::wlr_seat_pointer_notify_clear_focus((*self.seat).wlr_seat);
+        }
+        self.set_xcursor(b"default\0".as_ptr() as *const _);
+    }
+
     pub unsafe fn clear_focus(&mut self) {
         ffi::wlr_seat_pointer_notify_clear_focus((*self.seat).wlr_seat);
         self.set_xcursor(b"default\0".as_ptr() as *const _);
@@ -1030,6 +1100,13 @@ impl Cursor {
     /// layout pixels — `wlr_cursor_warp_absolute` is 0..1-normalized, which is the
     /// bug the old `pointer-move-to` had.
     pub unsafe fn inject_motion_to(&mut self, x: f64, y: f64) {
+        self.unhide_after_touch();
+        self.warp_to(x, y);
+    }
+
+    /// `inject_motion_to` without bringing a touch-hidden cursor back: the
+    /// emulated pointer of a touch (`TouchRoute::Pointer`) moves this way.
+    unsafe fn warp_to(&mut self, x: f64, y: f64) {
         (*self.seat).handle_activity();
         ffi::wlr_cursor_warp(self.wlr_cursor, std::ptr::null_mut(), x, y);
         self.update_hovered();
@@ -1304,6 +1381,8 @@ unsafe extern "C" fn handle_motion(listener: *mut ffi::wl_listener, data: *mut s
         cursor.end_hscroll_shift("motion");
     }
     
+    cursor.unhide_after_touch();
+
     let mut dx = (*event).delta_x;
     let mut dy = (*event).delta_y;
 
@@ -1358,6 +1437,7 @@ unsafe extern "C" fn handle_motion_absolute(listener: *mut ffi::wl_listener, dat
     let cursor = &mut *crate::container_of!(listener, Cursor, motion_absolute_listener);
     let event = data as *mut ffi::wlr_pointer_motion_absolute_event;
     (*cursor.seat).handle_activity();
+    cursor.unhide_after_touch();
     
     let wlr_device = if (*event).pointer.is_null() {
         std::ptr::null_mut()
@@ -1408,6 +1488,74 @@ unsafe fn layer_takes_click_focus(layer_surface: *mut crate::layer_shell::LayerS
         != ffi::zwlr_layer_surface_v1_keyboard_interactivity_ZWLR_LAYER_SURFACE_V1_KEYBOARD_INTERACTIVITY_NONE
 }
 
+/// What every press does before anyone handles it: close the menus it
+/// lands outside of. Shared by a pointer press (`handle_button`) and a
+/// touch that goes to a client as touch (`handle_touch_down`); an
+/// emulated touch arrives as a pointer press and runs it there.
+unsafe fn press_dismissals(server: *mut crate::server::Server, lx: f64, ly: f64) {
+    // Click-away-close for in-surface status menus: if any status
+    // segment is expanded (menu open) and this press did not land on it,
+    // push a one-shot dismiss over the status socket. The line carries
+    // the pressed segment's app_id so a press ON an expanded segment
+    // exempts that segment (it handles its own clicks) while still
+    // dismissing any other open menu.
+    {
+        let mut target_status: *mut crate::window::Window = std::ptr::null_mut();
+        if let Some(result) = (*server).scene.at(lx, ly) {
+            if let SceneNodeDataVal::Window(window) = result.data {
+                if (*window).is_status_bar() {
+                    target_status = window;
+                }
+            }
+        }
+        let any_other_expanded = (*server).wm.any_expanded_status_segment(target_status);
+        if any_other_expanded {
+            let except = if target_status.is_null() {
+                "-".to_string()
+            } else {
+                (*target_status).get_app_id_string().unwrap_or_else(|| "-".to_string())
+            };
+            if let Some(ref sender) = (*server).wm.status_sender {
+                sender.send_menu_dismiss(&except);
+            }
+        }
+    }
+
+    // Click-away for X11 popups. Xwayland only sees the pointer over
+    // its own surfaces, so a popup menu an X11 app opened (Wine's, most
+    // of all — a tray icon's menu opened through cce-xembed-tray) never
+    // hears a press on a Wayland window and stays open; only a click on
+    // one of the app's own X windows used to close it. So a press that
+    // lands on no X11 surface while some override-redirect window is
+    // showing is reported on the status socket's `clickaway` topic, and
+    // the bridge closes the popup it opened. The tray bridge's own
+    // containers have no scene tree, so they never count as showing.
+    {
+        let or_showing = (*server)
+            .wm
+            .override_redirects
+            .iter()
+            .any(|&or| !or.is_null() && !(*or).surface_tree.is_null());
+        if or_showing {
+            let on_x11 = match (*server).scene.at(lx, ly) {
+                Some(result) => match result.data {
+                    SceneNodeDataVal::Window(window) => {
+                        matches!((*window).impl_type, crate::window::WindowImpl::Xwayland(_))
+                    }
+                    SceneNodeDataVal::OverrideRedirect(_) => true,
+                    _ => false,
+                },
+                None => false,
+            };
+            if !on_x11 {
+                if let Some(ref sender) = (*server).wm.status_sender {
+                    sender.send_click_away();
+                }
+            }
+        }
+    }
+}
+
 unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, button_listener);
     let event = data as *mut ffi::wlr_pointer_button_event;
@@ -1476,67 +1624,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
             return;
         }
 
-        // Click-away-close for in-surface status menus: if any status
-        // segment is expanded (menu open) and this press did not land on it,
-        // push a one-shot dismiss over the status socket. The line carries
-        // the pressed segment's app_id so a press ON an expanded segment
-        // exempts that segment (it handles its own clicks) while still
-        // dismissing any other open menu.
-        {
-            let mut target_status: *mut crate::window::Window = std::ptr::null_mut();
-            if let Some(result) = (*server).scene.at(lx, ly) {
-                if let SceneNodeDataVal::Window(window) = result.data {
-                    if (*window).is_status_bar() {
-                        target_status = window;
-                    }
-                }
-            }
-            let any_other_expanded = (*server).wm.any_expanded_status_segment(target_status);
-            if any_other_expanded {
-                let except = if target_status.is_null() {
-                    "-".to_string()
-                } else {
-                    (*target_status).get_app_id_string().unwrap_or_else(|| "-".to_string())
-                };
-                if let Some(ref sender) = (*server).wm.status_sender {
-                    sender.send_menu_dismiss(&except);
-                }
-            }
-        }
-
-        // Click-away for X11 popups. Xwayland only sees the pointer over
-        // its own surfaces, so a popup menu an X11 app opened (Wine's, most
-        // of all — a tray icon's menu opened through cce-xembed-tray) never
-        // hears a press on a Wayland window and stays open; only a click on
-        // one of the app's own X windows used to close it. So a press that
-        // lands on no X11 surface while some override-redirect window is
-        // showing is reported on the status socket's `clickaway` topic, and
-        // the bridge closes the popup it opened. The tray bridge's own
-        // containers have no scene tree, so they never count as showing.
-        {
-            let or_showing = (*server)
-                .wm
-                .override_redirects
-                .iter()
-                .any(|&or| !or.is_null() && !(*or).surface_tree.is_null());
-            if or_showing {
-                let on_x11 = match (*server).scene.at(lx, ly) {
-                    Some(result) => match result.data {
-                        SceneNodeDataVal::Window(window) => {
-                            matches!((*window).impl_type, crate::window::WindowImpl::Xwayland(_))
-                        }
-                        SceneNodeDataVal::OverrideRedirect(_) => true,
-                        _ => false,
-                    },
-                    None => false,
-                };
-                if !on_x11 {
-                    if let Some(ref sender) = (*server).wm.status_sender {
-                        sender.send_click_away();
-                    }
-                }
-            }
-        }
+        press_dismissals(server, lx, ly);
 
         // Status-bar segments are dragged either in adjust-position mode or
         // directly with super+left-drag (0x40 = WLR_MODIFIER_LOGO).
@@ -3497,8 +3585,11 @@ unsafe extern "C" fn handle_tablet_tool_axis(listener: *mut ffi::wl_listener, da
 }
 
 unsafe extern "C" fn handle_tablet_tool_proximity(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
-    let _cursor = &mut *crate::container_of!(listener, Cursor, tablet_tool_proximity_listener);
+    let cursor = &mut *crate::container_of!(listener, Cursor, tablet_tool_proximity_listener);
     let event = data as *mut ffi::wlr_tablet_tool_proximity_event;
+    // A stylus sets its own image (`TabletTool::proximity`); the touch
+    // hide must not then block the compositor's next one.
+    cursor.hidden_by_touch = false;
 
     let wlr_tablet = (*event).tablet;
     let wlr_device = &mut (*wlr_tablet).base as *mut ffi::wlr_input_device;
@@ -3565,219 +3656,215 @@ unsafe extern "C" fn handle_tablet_tool_button(listener: *mut ffi::wl_listener,
     }
 }
 
-unsafe extern "C" fn handle_touch_down(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_down_listener);
-    let event = data as *mut ffi::wlr_touch_down_event;
+// ── Touchscreen ──────────────────────────────────────────────────────────────
+// A finger is routed once, at touch-down (`TouchRoute`): a surface whose
+// client bound `wl_touch` gets real touch events; anything else gets the
+// pointer, a left button held where the finger is. The `Cursor` methods take
+// layout coordinates, so the device handlers below and `ccectl touch` (a
+// headless shadow has no touchscreen) share every line of the routing.
 
-    let seat = &mut *cursor.seat;
-    seat.handle_activity();
-
-    let mut lx = 0.0;
-    let mut ly = 0.0;
-    let wlr_device = &mut (*(*event).touch).base as *mut ffi::wlr_input_device;
-    ffi::wlr_cursor_absolute_to_layout_coords(
-        cursor.wlr_cursor,
-        wlr_device,
-        (*event).x,
-        (*event).y,
-        &mut lx,
-        &mut ly,
-    );
-
-    cursor.touch_points.insert((*event).touch_id, (lx, ly));
-
-    let server = seat.server;
-    if let Some(result) = (*server).scene.at(lx, ly) {
-        match result.data {
-            SceneNodeDataVal::LayerSurface(layer_surface) => {
-                if layer_takes_click_focus(layer_surface) {
-                    seat.focus(Focus::LayerSurface(result.surface));
-                }
-            }
-            _ => {}
-        }
-        
-        let mut is_app_surface = false;
-        let mut is_overlay_window = false;
-        match result.data {
-            SceneNodeDataVal::Window(window) => {
-                if !(*window).is_status_bar() && !(*window).is_wallpaper() {
-                    is_app_surface = true;
-                    // Popup counts as chrome like Overlay: the cce-cloud
-                    // launcher must keep receiving clicks in overview.
-                    if (*window).tiling_mode == crate::tiling::TilingMode::Overlay
-                        || (*window).tiling_mode == crate::tiling::TilingMode::Popup
-                    {
-                        is_overlay_window = true;
-                    }
-                }
-            }
-            SceneNodeDataVal::LayerSurface(layer_surface) => {
-                if !layer_surface.is_null() {
-                    is_app_surface = true;
-                    if is_cloud_layer(layer_surface) {
-                        is_overlay_window = true;
+impl Cursor {
+    /// Decide where a new finger at `lx, ly` goes. In window-adjust mode
+    /// (overview, or Super held) every press is the compositor's to spend
+    /// — the handles, a window body's drag, the rubber band — and those live
+    /// on the pointer path, so the finger becomes the pointer even over a
+    /// touch-capable window.
+    unsafe fn touch_route_at(&self, lx: f64, ly: f64) -> TouchRoute {
+        let seat = &*self.seat;
+        let server = seat.server;
+        if let Some(result) = (*server).scene.at(lx, ly) {
+            // Locked, only the lock surface may hear a finger, as only it
+            // may hear the pointer (`passthrough`).
+            let locked = (*server).lock_manager.state != crate::lock_manager::LockState::Unlocked;
+            let lock_ok = !locked || matches!(result.data, SceneNodeDataVal::LockSurface(_));
+            if lock_ok
+                && !result.surface.is_null()
+                && !(*server).wm.window_adjust_active()
+                && ffi::wlr_surface_accepts_touch(result.surface, seat.wlr_seat)
+            {
+                // The implicit grab's frame (see the button path): the
+                // surface's scene buffer may be drawn scaled, so map through
+                // its destination size rather than assume 1:1.
+                let mut scale = 1.0;
+                if !result.node.is_null() {
+                    let dest_w = ffi::river_scene_buffer_get_dest_width(result.node as *mut ffi::wlr_scene_buffer);
+                    let surf_w = ffi::river_wlr_surface_get_width(result.surface);
+                    if dest_w > 0 && surf_w > 0 {
+                        scale = surf_w as f64 / dest_w as f64;
                     }
                 }
+                let origin = (lx - result.sx / scale, ly - result.sy / scale);
+                return TouchRoute::Client { origin, scale };
             }
-            SceneNodeDataVal::ShellSurface(_) | SceneNodeDataVal::OverrideRedirect(_) => {
-                is_app_surface = true;
-            }
-            _ => {}
         }
-        let should_block_touch = (*(*seat).server).wm.mode == crate::window_manager::WindowManagerMode::Overview && is_app_surface && !is_overlay_window;
-
-        if !result.surface.is_null() && !should_block_touch {
-            ffi::wlr_seat_touch_notify_down(
-                seat.wlr_seat,
-                result.surface,
-                (*event).time_msec,
-                (*event).touch_id,
-                result.sx,
-                result.sy,
-            );
+        // The pointer is singular: a second finger cannot also drive it, and
+        // a real button held (or a seat op it started) owns it already.
+        let pointer_busy = self.touch_points.values().any(|p| p.route == TouchRoute::Pointer)
+            || !self.pressed.is_empty()
+            || seat.op.is_some();
+        if pointer_busy {
+            TouchRoute::Ignored
+        } else {
+            TouchRoute::Pointer
         }
     }
-}
-
-unsafe extern "C" fn handle_touch_motion(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_motion_listener);
-    let event = data as *mut ffi::wlr_touch_motion_event;
 
-    let seat = &mut *cursor.seat;
-    seat.handle_activity();
-
-    if cursor.touch_points.contains_key(&(*event).touch_id) {
-        let wlr_device = &mut (*(*event).touch).base as *mut ffi::wlr_input_device;
-        let mut lx: f64 = 0.0;
-        let mut ly: f64 = 0.0;
-        ffi::wlr_cursor_absolute_to_layout_coords(
-            cursor.wlr_cursor,
-            wlr_device,
-            (*event).x,
-            (*event).y,
-            &mut lx,
-            &mut ly,
-        );
-
-        cursor.touch_points.insert((*event).touch_id, (lx, ly));
-
-        cursor.update_drag_icons();
-
-        let server = seat.server;
-        if let Some(result) = (*server).scene.at(lx, ly) {
-            let mut is_app_surface = false;
-            let mut is_overlay_window = false;
-            match result.data {
-                SceneNodeDataVal::Window(window) => {
-                    if !(*window).is_status_bar() && !(*window).is_wallpaper() {
-                        is_app_surface = true;
-                        if (*window).tiling_mode == crate::tiling::TilingMode::Overlay {
-                            is_overlay_window = true;
+    pub unsafe fn touch_down(&mut self, id: i32, lx: f64, ly: f64, time_msec: u32) {
+        (*self.seat).handle_activity();
+        let server = (*self.seat).server;
+        // A touch is deliberate input, like a press (see `Seat::focus`).
+        (*server).wm.startup_input_seen = true;
+        self.hide_for_touch();
+        // A device that reuses a live id without lifting it first has lost
+        // the up; finish the old point so its button or client is released.
+        if self.touch_points.contains_key(&id) {
+            log::warn!("touch: down for live touch id {id}; lifting the old point first");
+            self.touch_up(id, time_msec);
+        }
+
+        let route = self.touch_route_at(lx, ly);
+        self.touch_points.insert(id, TouchPoint { lx, ly, route });
+        log::debug!("touch: down id={id} at ({lx:.0}, {ly:.0}) -> {route:?}");
+
+        match route {
+            TouchRoute::Client { origin, scale } => {
+                press_dismissals(server, lx, ly);
+                let Some(result) = (*server).scene.at(lx, ly) else { return };
+                // Focus as a click would (`handle_button`).
+                let seat = &mut *self.seat;
+                match result.data {
+                    SceneNodeDataVal::Window(window) => {
+                        if !(*window).is_status_bar() && !(*window).is_wallpaper() {
+                            seat.focus(Focus::Window(window));
                         }
                     }
-                }
-                SceneNodeDataVal::LayerSurface(layer_surface) => {
-                    if !layer_surface.is_null() {
-                        is_app_surface = true;
-                        let wlr_layer_surface = (*layer_surface).wlr_layer_surface;
-                        if !wlr_layer_surface.is_null() && !(*wlr_layer_surface).namespace.is_null() {
-                            let ns = std::ffi::CStr::from_ptr((*wlr_layer_surface).namespace).to_string_lossy();
-                            if ns.starts_with("cce-cloud") {
-                                is_overlay_window = true;
-                            }
+                    SceneNodeDataVal::LayerSurface(layer_surface) => {
+                        if layer_takes_click_focus(layer_surface) {
+                            seat.focus(Focus::LayerSurface(result.surface));
                         }
                     }
+                    _ => {}
                 }
-                SceneNodeDataVal::ShellSurface(_) | SceneNodeDataVal::OverrideRedirect(_) => {
-                    is_app_surface = true;
-                }
-                _ => {}
+                ffi::wlr_seat_touch_notify_down(
+                    seat.wlr_seat,
+                    result.surface,
+                    time_msec,
+                    id,
+                    (lx - origin.0) * scale,
+                    (ly - origin.1) * scale,
+                );
+            }
+            TouchRoute::Pointer => {
+                self.warp_to(lx, ly);
+                self.inject_button(BTN_LEFT, true);
             }
-            let should_block_touch = (*(*seat).server).wm.mode == crate::window_manager::WindowManagerMode::Overview && is_app_surface && !is_overlay_window;
+            TouchRoute::Ignored => {}
+        }
+    }
 
-            if !should_block_touch {
+    pub unsafe fn touch_motion(&mut self, id: i32, lx: f64, ly: f64, time_msec: u32) {
+        (*self.seat).handle_activity();
+        let Some(point) = self.touch_points.get_mut(&id) else { return };
+        point.lx = lx;
+        point.ly = ly;
+        let route = point.route;
+        match route {
+            TouchRoute::Client { origin, scale } => {
                 ffi::wlr_seat_touch_notify_motion(
-                    seat.wlr_seat,
-                    (*event).time_msec,
-                    (*event).touch_id,
-                    result.sx,
-                    result.sy,
+                    (*self.seat).wlr_seat,
+                    time_msec,
+                    id,
+                    (lx - origin.0) * scale,
+                    (ly - origin.1) * scale,
                 );
+                // A touch drag's icon follows the finger.
+                self.update_drag_icons();
             }
+            TouchRoute::Pointer => self.warp_to(lx, ly),
+            TouchRoute::Ignored => {}
         }
     }
-}
 
-unsafe extern "C" fn handle_touch_up(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_up_listener);
-    let event = data as *mut ffi::wlr_touch_up_event;
-
-    let seat = &mut *cursor.seat;
-    seat.handle_activity();
-
-    if let Some((lx, ly)) = cursor.touch_points.remove(&(*event).touch_id) {
-        let server = seat.server;
-        let mut is_app_surface = false;
-        let mut is_overlay_window = false;
-        if let Some(result) = (*server).scene.at(lx, ly) {
-            match result.data {
-                SceneNodeDataVal::Window(window) => {
-                    if !(*window).is_status_bar() && !(*window).is_wallpaper() {
-                        is_app_surface = true;
-                        if (*window).tiling_mode == crate::tiling::TilingMode::Overlay {
-                            is_overlay_window = true;
-                        }
-                    }
-                }
-                SceneNodeDataVal::LayerSurface(layer_surface) => {
-                    if !layer_surface.is_null() {
-                        is_app_surface = true;
-                        let wlr_layer_surface = (*layer_surface).wlr_layer_surface;
-                        if !wlr_layer_surface.is_null() && !(*wlr_layer_surface).namespace.is_null() {
-                            let ns = std::ffi::CStr::from_ptr((*wlr_layer_surface).namespace).to_string_lossy();
-                            if ns.starts_with("cce-cloud") {
-                                is_overlay_window = true;
-                            }
-                        }
-                    }
-                }
-                SceneNodeDataVal::ShellSurface(_) | SceneNodeDataVal::OverrideRedirect(_) => {
-                    is_app_surface = true;
-                }
-                _ => {}
+    pub unsafe fn touch_up(&mut self, id: i32, time_msec: u32) {
+        (*self.seat).handle_activity();
+        let Some(point) = self.touch_points.remove(&id) else { return };
+        match point.route {
+            TouchRoute::Client { .. } => {
+                ffi::wlr_seat_touch_notify_up((*self.seat).wlr_seat, time_msec, id);
             }
+            TouchRoute::Pointer => self.inject_button(BTN_LEFT, false),
+            TouchRoute::Ignored => {}
         }
-        let should_block_touch = (*(*seat).server).wm.mode == crate::window_manager::WindowManagerMode::Overview && is_app_surface && !is_overlay_window;
+    }
 
-        if !should_block_touch {
-            ffi::wlr_seat_touch_notify_up(
-                seat.wlr_seat,
-                (*event).time_msec,
-                (*event).touch_id,
-            );
+    /// The device gave up on a finger (a palm, a gesture the kernel took).
+    /// A client is told `wl_touch.cancel`, which voids its whole sequence; a
+    /// finger driving the pointer releases the button — a cancelled tap
+    /// still clicks, but a held button must never be left behind.
+    pub unsafe fn touch_cancel(&mut self, id: i32) {
+        (*self.seat).handle_activity();
+        let Some(point) = self.touch_points.remove(&id) else { return };
+        match point.route {
+            TouchRoute::Client { .. } => ffi::river_wlr_seat_touch_cancel_point((*self.seat).wlr_seat, id),
+            TouchRoute::Pointer => self.inject_button(BTN_LEFT, false),
+            TouchRoute::Ignored => {}
+        }
+    }
+
+    pub unsafe fn touch_frame(&mut self) {
+        // The emulated pointer frames each of its own events; only touch
+        // clients are waiting on this one.
+        if self.touch_points.values().any(|p| matches!(p.route, TouchRoute::Client { .. }))
+            || ffi::wlr_seat_touch_num_points((*self.seat).wlr_seat) > 0
+        {
+            ffi::wlr_seat_touch_notify_frame((*self.seat).wlr_seat);
         }
     }
+
+    /// Layout coordinates of a touch event's normalized `x, y`, through the
+    /// device's output mapping (`map_to_output` / `map_to_region`).
+    unsafe fn touch_layout_coords(&self, touch: *mut ffi::wlr_touch, x: f64, y: f64) -> (f64, f64) {
+        let wlr_device = if touch.is_null() {
+            std::ptr::null_mut()
+        } else {
+            &mut (*touch).base as *mut ffi::wlr_input_device
+        };
+        let mut lx = 0.0;
+        let mut ly = 0.0;
+        ffi::wlr_cursor_absolute_to_layout_coords(self.wlr_cursor, wlr_device, x, y, &mut lx, &mut ly);
+        (lx, ly)
+    }
 }
 
-unsafe extern "C" fn handle_touch_cancel(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
-    let cursor = &mut *crate::container_of!(listener, Cursor, touch_cancel_listener);
-    
-    let seat = &mut *cursor.seat;
-    seat.handle_activity();
+unsafe extern "C" fn handle_touch_down(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_down_listener);
+    let event = data as *mut ffi::wlr_touch_down_event;
+    let (lx, ly) = cursor.touch_layout_coords((*event).touch, (*event).x, (*event).y);
+    cursor.touch_down((*event).touch_id, lx, ly, (*event).time_msec);
+}
+
+unsafe extern "C" fn handle_touch_motion(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_motion_listener);
+    let event = data as *mut ffi::wlr_touch_motion_event;
+    let (lx, ly) = cursor.touch_layout_coords((*event).touch, (*event).x, (*event).y);
+    cursor.touch_motion((*event).touch_id, lx, ly, (*event).time_msec);
+}
 
-    cursor.touch_points.clear();
+unsafe extern "C" fn handle_touch_up(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_up_listener);
+    let event = data as *mut ffi::wlr_touch_up_event;
+    cursor.touch_up((*event).touch_id, (*event).time_msec);
+}
 
-    ffi::river_wlr_seat_touch_cancel_all(seat.wlr_seat);
+unsafe extern "C" fn handle_touch_cancel(listener: *mut ffi::wl_listener, data: *mut std::ffi::c_void) {
+    let cursor = &mut *crate::container_of!(listener, Cursor, touch_cancel_listener);
+    let event = data as *mut ffi::wlr_touch_cancel_event;
+    cursor.touch_cancel((*event).touch_id);
 }
 
 unsafe extern "C" fn handle_touch_frame(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, touch_frame_listener);
-    
-    let seat = &mut *cursor.seat;
-    seat.handle_activity();
-
-    ffi::wlr_seat_touch_notify_frame(seat.wlr_seat);
+    cursor.touch_frame();
 }
 
 /// A directional swipe bind fires once the accumulated travel (libinput
diff --git a/src/server/drag_icon.rs b/src/server/drag_icon.rs
index 53a5653e..2e37e76f 100644
--- a/src/server/drag_icon.rs
+++ b/src/server/drag_icon.rs
@@ -66,7 +66,7 @@ impl DragIcon {
             }
             ffi::wlr_drag_grab_type_WLR_DRAG_GRAB_KEYBOARD_TOUCH => {
                 let touch_id = ffi::river_wlr_drag_get_touch_id((*self.wlr_drag_icon).drag);
-                if let Some(&(lx, ly)) = (*cursor).touch_points.get(&touch_id) {
+                if let Some(&crate::cursor::TouchPoint { lx, ly, .. }) = (*cursor).touch_points.get(&touch_id) {
                     ffi::wlr_scene_node_set_position(
                         self.scene_drag_icon as *mut ffi::wlr_scene_node,
                         lx as i32,
diff --git a/src/server/seat.rs b/src/server/seat.rs
index cfcbca15..a039e085 100644
--- a/src/server/seat.rs
+++ b/src/server/seat.rs
@@ -93,6 +93,16 @@ pub struct Seat {
     pub wlr_seat: *mut ffi::wlr_seat,
     pub cursor: Cursor,
     pub focused: Focus,
+    /// Touchscreens attached to this seat. The seat offers the touch
+    /// capability only while there is one: a client that sees it binds
+    /// `wl_touch`, and from then on a finger on its surface reaches it as
+    /// touch instead of an emulated pointer (`cursor::TouchRoute`). A
+    /// toolkit with no touch handling would otherwise take a capability it
+    /// cannot use and get nothing.
+    pub touch_devices: u32,
+    /// `ccectl touch` has run: offer touch as if a touchscreen were here,
+    /// so a headless shadow can exercise the touch-client route.
+    pub touch_injected: bool,
     pub relay: crate::input_relay::InputRelay,
     pub layer_shell: crate::layer_shell::LayerShellSeat,
     pub xkb_bindings_seat: crate::xkb_bindings::XkbBindingsSeat,
@@ -172,6 +182,8 @@ impl Seat {
             wlr_seat,
             cursor: Cursor::default(),
             focused: Focus::None,
+            touch_devices: 0,
+            touch_injected: false,
             relay: std::mem::zeroed(),
             layer_shell: crate::layer_shell::LayerShellSeat::default(),
             xkb_bindings_seat: crate::xkb_bindings::XkbBindingsSeat::default(),
@@ -390,6 +402,9 @@ impl Seat {
                 ffi::wlr_cursor_attach_input_device(self.cursor.wlr_cursor, (*device).wlr_device);
             }
             ffi::wlr_input_device_type_WLR_INPUT_DEVICE_TOUCH | ffi::wlr_input_device_type_WLR_INPUT_DEVICE_TABLET => {
+                if dev_type == ffi::wlr_input_device_type_WLR_INPUT_DEVICE_TOUCH {
+                    self.touch_devices += 1;
+                }
                 ffi::wlr_cursor_attach_input_device(self.cursor.wlr_cursor, (*device).wlr_device);
                 if !(*device).config.map_to_output.is_null() {
                     ffi::wlr_cursor_map_input_to_output(self.cursor.wlr_cursor, (*device).wlr_device, (*device).config.map_to_output);
@@ -405,6 +420,17 @@ impl Seat {
         ffi::wlr_cursor_detach_input_device(self.cursor.wlr_cursor, (*device).wlr_device);
 
         let dev_type = ffi::river_wlr_input_device_get_type((*device).wlr_device);
+        if dev_type == ffi::wlr_input_device_type_WLR_INPUT_DEVICE_TOUCH {
+            self.touch_devices = self.touch_devices.saturating_sub(1);
+            // An unplugged touchscreen sends no up for the fingers it had
+            // down; one driving the pointer would leave the button held.
+            if self.touch_devices == 0 && !self.touch_injected {
+                let ids: Vec<i32> = self.cursor.touch_points.keys().copied().collect();
+                for id in ids {
+                    self.cursor.touch_cancel(id);
+                }
+            }
+        }
         if dev_type == ffi::wlr_input_device_type_WLR_INPUT_DEVICE_KEYBOARD {
             let keyboard = (*device).destroy_data as *mut crate::keyboard::Keyboard;
             if !keyboard.is_null() {
@@ -428,8 +454,11 @@ impl Seat {
     }
 
     pub unsafe fn update_capabilities(&mut self) {
-        let caps = ffi::wl_seat_capability_WL_SEAT_CAPABILITY_POINTER
+        let mut caps = ffi::wl_seat_capability_WL_SEAT_CAPABILITY_POINTER
             | ffi::wl_seat_capability_WL_SEAT_CAPABILITY_KEYBOARD;
+        if self.touch_devices > 0 || self.touch_injected {
+            caps |= ffi::wl_seat_capability_WL_SEAT_CAPABILITY_TOUCH;
+        }
         ffi::wlr_seat_set_capabilities(self.wlr_seat, caps);
     }
 
@@ -2007,6 +2036,13 @@ unsafe extern "C" fn handle_request_set_cursor(
     };
     let is_wm = !wm_client.is_null() && event_client == wm_client;
 
+    // While a touch has the image off, nothing may put one back: the
+    // emulated pointer's every enter draws this request.
+    // `Cursor::unhide_after_touch` re-enters the surface, so the client
+    // asks again once the image is wanted.
+    if seat.cursor.hidden_by_touch {
+        return;
+    }
     if focused_client == (*event).seat_client || is_wm {
         // The client owns the cursor image from here; a compositor-driven
         // xcursor animation would paint over it on its next tick.
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 753e48a5..f8892d7d 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -6485,6 +6485,62 @@ impl WindowManager {
                     "error: invalid x or y\n".to_string()
                 }
             }
+            // ── Synthetic touch (`ccectl touch …`), in layout pixels. Runs the
+            // same `Cursor::touch_*` routing a touchscreen does; the first use
+            // offers the touch capability as a touchscreen would, so clients
+            // in a headless shadow bind `wl_touch` and the client route can be
+            // exercised too.
+            "touch" => {
+                let usage = "error: usage: touch down <id> <x> <y> | motion <id> <x> <y> | up <id> | cancel <id> | tap <x> <y>\n";
+                let id = |i: usize| parts.get(i).and_then(|v| v.parse::<i32>().ok());
+                let num = |i: usize| parts.get(i).and_then(|v| parse_finite(v).ok());
+                let stage = parts.get(1).copied().unwrap_or("");
+                let time = crate::util::msec_timestamp();
+                let ok = match stage {
+                    "down" | "motion" => match (id(2), num(3), num(4)) {
+                        (Some(id), Some(x), Some(y)) => {
+                            self.for_each_seat_touch(|cursor| {
+                                if stage == "down" {
+                                    cursor.touch_down(id, x, y, time);
+                                } else {
+                                    cursor.touch_motion(id, x, y, time);
+                                }
+                                cursor.touch_frame();
+                            });
+                            true
+                        }
+                        _ => false,
+                    },
+                    "up" | "cancel" => match id(2) {
+                        Some(id) => {
+                            self.for_each_seat_touch(|cursor| {
+                                if stage == "up" {
+                                    cursor.touch_up(id, time);
+                                } else {
+                                    cursor.touch_cancel(id);
+                                }
+                                cursor.touch_frame();
+                            });
+                            true
+                        }
+                        None => false,
+                    },
+                    "tap" => match (num(2), num(3)) {
+                        (Some(x), Some(y)) => {
+                            self.for_each_seat_touch(|cursor| {
+                                cursor.touch_down(0, x, y, time);
+                                cursor.touch_frame();
+                                cursor.touch_up(0, time);
+                                cursor.touch_frame();
+                            });
+                            true
+                        }
+                        _ => false,
+                    },
+                    _ => false,
+                };
+                if ok { "ok\n".to_string() } else { usage.to_string() }
+            }
             "pointer-move-by" => {
                 if parts.len() < 3 { return "error: usage: pointer-move-by <dx> <dy>\n".to_string(); }
                 if let (Ok(dx), Ok(dy)) = (parse_finite(parts[1]), parse_finite(parts[2])) {
@@ -6775,6 +6831,23 @@ impl WindowManager {
         }
     }
 
+    /// `for_each_cursor` for `ccectl touch`: marks each seat as having had
+    /// touch injected first, which offers clients the touch capability.
+    unsafe fn for_each_seat_touch(&mut self, mut f: impl FnMut(&mut crate::cursor::Cursor)) {
+        let seats_list = &mut (*self.server).input_manager.seats as *mut ffi::wl_list as *mut WlList;
+        let mut curr_seat = (*seats_list).next;
+        while curr_seat != seats_list {
+            let next_seat = (*curr_seat).next;
+            let seat = crate::container_of!(curr_seat, crate::seat::Seat, link);
+            if !(*seat).touch_injected {
+                (*seat).touch_injected = true;
+                (*seat).update_capabilities();
+            }
+            f(&mut (*seat).cursor);
+            curr_seat = next_seat;
+        }
+    }
+
     /// Button-name/evdev-code parsing for the pointer commands; a missing argument
     /// means the left button, like wlrctl.
     fn parse_pointer_button(arg: Option<&str>) -> Option<u32> {
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index a1ab0ab6..99ca06e8 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -645,9 +645,12 @@ struct wlr_tablet_tool *river_wlr_tablet_v2_tablet_tool_get_wlr_tool(struct wlr_
 	return tool->wlr_tool;
 }
 
-void river_wlr_seat_touch_cancel_all(struct wlr_seat *wlr_seat) {
-	struct wlr_touch_point *point, *tmp;
-	wl_list_for_each_safe(point, tmp, &wlr_seat->touch_state.touch_points, link) {
+// wl_touch.cancel is per client: it voids every point that client holds,
+// and wlr_seat_touch_notify_cancel destroys them all. So cancelling one
+// finger cancels its client's whole sequence — the protocol's unit.
+void river_wlr_seat_touch_cancel_point(struct wlr_seat *wlr_seat, int32_t touch_id) {
+	struct wlr_touch_point *point = wlr_seat_touch_get_point(wlr_seat, touch_id);
+	if (point != NULL && point->client != NULL) {
 		wlr_seat_touch_notify_cancel(wlr_seat, point->client);
 	}
 }
diff --git a/wrapper.h b/wrapper.h
index 0a28bb2b..a2a17c6c 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -250,7 +250,7 @@ bool river_wlr_tablet_v2_tablet_tool_get_is_down(struct wlr_tablet_v2_tablet_too
 size_t river_wlr_tablet_v2_tablet_tool_get_num_buttons(struct wlr_tablet_v2_tablet_tool *tool);
 struct wlr_tablet_tool *river_wlr_tablet_v2_tablet_tool_get_wlr_tool(struct wlr_tablet_v2_tablet_tool *tool);
 
-void river_wlr_seat_touch_cancel_all(struct wlr_seat *wlr_seat);
+void river_wlr_seat_touch_cancel_point(struct wlr_seat *wlr_seat, int32_t touch_id);
 struct wlr_surface *river_wlr_seat_get_keyboard_focused_surface(struct wlr_seat *seat);
 
 int river_wlr_surface_get_width(struct wlr_surface *surface);