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

commit5165d55a8b070c13ea8dc722551ed7e52fb7f8c9
parentf32348e85f
authorLucas Galante <[email protected]>
date2026-09-14 12:55
feat(ccectl): let an app confine the touchpad view drag to its view panes

A window named in touchpad_view_apps turned every two-finger scroll into
the emulated Space + button view drag, so Houdini's parameter editor
(and every other pane that wants the swipe as a scroll) got nothing at
all; only Ctrl + swipe, which the drag lets through, scrolled it.

touchpad-view-regions <x11:ID|id|app_id> clear | <x,y,w,h> ... stores
surface-local rectangles on the window; view_drag_target then denies the
drag outside them and the axis event reaches the client untouched. None
(never sent) keeps the whole-window drag, an empty list means no drag at
all. windows --json now reports the X11 window id so a client can name
its own windows; hou-control publishes its SceneViewer and NetworkEditor
tabs this way.

Verified in a cce-shadow session with Houdini 22: a finger scroll over
the parameter pane scrolls it, over the viewport and the network editor
still begins a view drag at the expected surface coordinates, and clear
restores the old behaviour.

 src/cce_ctl.rs               |  7 +++-
 src/server/cursor.rs         |  9 +++++
 src/server/window.rs         |  9 +++++
 src/server/window_manager.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 115 insertions(+), 1 deletion(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index e622cf4..e0ecd43 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -86,7 +86,12 @@ fn usage(name: &str, to_stderr: bool) {
     print("  pointer-location");
     print("  pointer-move-to <x> <y>            (layout pixels)");
     print("  pointer-move-by <dx> <dy>");
-    print("  pointer-scroll <dy> [dx]           (positive dy scrolls down; 15 = one notch)");
+    print("  pointer-scroll <dy> [dx] [finger]  (positive dy scrolls down; 15 = one notch;");
+    print("  pointer-scroll finger-stop          finger = a two-finger swipe, ended by finger-stop)");
+    print("  pointer-pinch <scale> [rotation] [steps] | begin | update <scale> [rotation] | end");
+    print("  touchpad-view-regions <x11:ID|id|app_id> clear | <x,y,w,h> ...");
+    print("                                     (limit a touchpad_view_apps drag to these window-local");
+    print("                                      rects; a swipe elsewhere scrolls the app normally)");
     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]");
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 2156cc0..6f0aec9 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -2418,6 +2418,15 @@ impl Cursor {
         if !wm.touchpad_view_apps.iter().any(|p| crate::window_manager::app_id_matches(p, &app_id)) {
             return None;
         }
+        // The app may have narrowed the drag to its own view panes (see
+        // `touchpad-view-regions`); elsewhere the scroll passes through.
+        // `result.sx`/`sy` are surface-local, the same pixels the client
+        // measures its panes in.
+        if let Some(regions) = &(*window).view_regions {
+            if !crate::window_manager::point_in_view_regions(regions, result.sx, result.sy) {
+                return None;
+            }
+        }
         let mut ratio = 1.0;
         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);
diff --git a/src/server/window.rs b/src/server/window.rs
index f65305f..a58de84 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -374,6 +374,14 @@ pub struct Window {
     pub node: WmNode,
     pub state: WindowState,
     pub impl_type: WindowImpl,
+    /// Where a two-finger scroll over this window becomes an emulated
+    /// view drag (see `cursor::ViewDrag`), when the app has said so through
+    /// `touchpad-view-regions`: rectangles in surface-local pixels, `[x, y,
+    /// w, h]`. `None` means the whole window, which is what an app that
+    /// never sends any gets. Outside the rectangles the scroll reaches the
+    /// client untouched — Houdini's parameter editor scrolls, its 3D
+    /// viewports tumble.
+    pub view_regions: Option<Vec<[f64; 4]>>,
 
     pub tree: *mut ffi::wlr_scene_tree,
     pub fullscreen_background: *mut ffi::wlr_scene_rect,
@@ -702,6 +710,7 @@ impl Window {
         let decorations_above_tree = ffi::wlr_scene_tree_create(tree);
 
         let mut window = Box::new(Window {
+            view_regions: None,
             ref_key: crate::slotmap::Key { generation: 0, index: 0 },
             server,
             object: std::ptr::null_mut(),
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 360522c..0469aac 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -382,6 +382,40 @@ const RECONNECT_FOCUS_GRACE: std::time::Duration = std::time::Duration::from_sec
 /// Deliberately NOT a general glob: no `?`, no character classes. An app_id is
 /// a flat identifier and `*` covers the rename cases; the rest is surface for
 /// a pattern to match something nobody intended.
+/// Whether a surface-local point lies in one of an app's view regions
+/// (`Window::view_regions`). An empty list matches nothing: an app that
+/// has told us it currently shows no view pane gets no view drag at all,
+/// which is different from an app that never said anything (`None`).
+pub fn point_in_view_regions(regions: &[[f64; 4]], x: f64, y: f64) -> bool {
+    regions.iter().any(|[rx, ry, rw, rh]| x >= *rx && y >= *ry && x < rx + rw && y < ry + rh)
+}
+
+/// The mapped window an IPC command names: `x11:<id>` is the X11 window
+/// id an Xwayland client knows itself by (what `windows --json` reports as
+/// `x11`), a bare number is the compositor's own id, anything else an
+/// app_id. Only the first form is unambiguous for an app with several
+/// windows, which is why a client that publishes per-window state should
+/// use it.
+pub unsafe fn window_by_query(windows: impl Iterator<Item = *mut Window>, query: &str) -> Option<*mut Window> {
+    let x11 = query.strip_prefix("x11:").and_then(|v| v.parse::<u32>().ok());
+    let id = query.parse::<u32>().ok();
+    windows.into_iter().find(|&w| {
+        if w.is_null() || (*w).closed || !matches!((*w).state, crate::window::WindowState::Mapped) {
+            return false;
+        }
+        if let Some(x11) = x11 {
+            return match (*w).impl_type {
+                crate::window::WindowImpl::Xwayland(xw) if !xw.is_null() => (*(*xw).xsurface).window_id == x11,
+                _ => false,
+            };
+        }
+        if let Some(id) = id {
+            return (*w).ref_key.index == id;
+        }
+        (*w).get_app_id_string().map_or(false, |a| app_id_matches(query, &a))
+    })
+}
+
 pub fn app_id_matches(pattern: &str, app_id: &str) -> bool {
     if !pattern.contains('*') {
         return pattern.eq_ignore_ascii_case(app_id);
@@ -5120,6 +5154,10 @@ impl WindowManager {
                                 "h": (*w).box_geom.height,
                                 "vx": (*w).virtual_x,
                                 "vy": (*w).virtual_y,
+                                "x11": match (*w).impl_type {
+                                    crate::window::WindowImpl::Xwayland(xw) if !xw.is_null() => Some((*(*xw).xsurface).window_id),
+                                    _ => None,
+                                },
                                 "cell": cell,
                                 "minimized": (*w).minimized,
                                 "has_parent": (*w).has_parent,
@@ -5374,6 +5412,40 @@ impl WindowManager {
                     "error: invalid pinch arguments\n".to_string()
                 }
             }
+            "touchpad-view-regions" => {
+                // touchpad-view-regions <x11:ID|id|app_id> clear | <x,y,w,h> ...
+                // An app named in `touchpad_view_apps` narrows the emulated
+                // view drag (see `cursor::ViewDrag`) to rectangles of one of
+                // its windows, in surface-local pixels — for an X11 window
+                // under `xwayland_hidpi`, the physical pixels the client
+                // itself measures in. A two-finger scroll outside them reaches
+                // the client as the plain scroll it was, so Houdini's
+                // parameter editor scrolls while its viewports still tumble;
+                // it publishes its 3D viewports and network editors from
+                // hou-control. `clear` restores the whole-window drag.
+                if parts.len() < 3 {
+                    return "error: usage: touchpad-view-regions <x11:ID|id|app_id> clear|<x,y,w,h> ...\n".to_string();
+                }
+                let Some(w) = window_by_query(self.windows.iter().copied(), parts[1]) else {
+                    return format!("error: no mapped window matches {}\n", parts[1]);
+                };
+                if parts[2] == "clear" {
+                    (*w).view_regions = None;
+                    log::info!("[touchpad-view-regions] {} cleared", parts[1]);
+                    return "ok\n".to_string();
+                }
+                let mut regions = Vec::new();
+                for spec in &parts[2..] {
+                    let v: Vec<f64> = spec.split(',').filter_map(|n| n.parse().ok()).collect();
+                    if v.len() != 4 {
+                        return format!("error: bad rect {} (want x,y,w,h)\n", spec);
+                    }
+                    regions.push([v[0], v[1], v[2], v[3]]);
+                }
+                log::info!("[touchpad-view-regions] {} -> {:?}", parts[1], regions);
+                (*w).view_regions = Some(regions);
+                "ok\n".to_string()
+            }
             "place-next" => {
                 // place-next <app_id> <x> <y>: one-shot hint — the next map of
                 // a floating toplevel with this app_id lands near this layout
@@ -6573,6 +6645,25 @@ unsafe extern "C" fn handle_border_fade_tick(data: *mut std::ffi::c_void) -> std
 mod tests {
     use super::*;
 
+    #[test]
+    fn point_in_view_regions_is_half_open() {
+        let r = [[10.0, 20.0, 100.0, 50.0], [500.0, 0.0, 10.0, 10.0]];
+        assert!(point_in_view_regions(&r, 10.0, 20.0));
+        assert!(point_in_view_regions(&r, 109.9, 69.9));
+        assert!(!point_in_view_regions(&r, 110.0, 30.0));
+        assert!(!point_in_view_regions(&r, 50.0, 70.0));
+        assert!(point_in_view_regions(&r, 505.0, 5.0));
+        assert!(!point_in_view_regions(&r, 0.0, 0.0));
+    }
+
+    /// An app that reports no view pane at all gets no drag: that is not
+    /// the same as never having reported (`None`), which keeps the whole
+    /// window.
+    #[test]
+    fn point_in_view_regions_empty_matches_nothing() {
+        assert!(!point_in_view_regions(&[], 0.0, 0.0));
+    }
+
     #[test]
     fn app_id_matches_is_exact_without_a_star() {
         assert!(app_id_matches("claude-desktop", "claude-desktop"));