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