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

commit6a0039b7ff21a937754669ee03e1c96b73ed13d3
parentd59cf675c5
authorLucas Galante <[email protected]>
date2026-09-17 11:58
feat(cursor): horizontal trackpad scroll as Shift+vertical for named apps

Houdini's native panes -- the geometry spreadsheet first of all -- read a
wheel event's magnitude and ignore its axis: a horizontal wheel scrolls
the rows, and the documented way to scroll the columns is Shift+scroll.
Measured live: a horizontal two-finger swipe reached Houdini as a proper
horizontal QWheelEvent and moved the rows; the same swipe with Shift held
moved the columns. Nothing on the input side was at fault.

`touchpad_hscroll_shift_apps "Houdini FX"` (window_manager block) names
the apps. Over their windows a horizontal finger scroll becomes an
HScrollShift gesture: the compositor holds Shift on the client's behalf
and re-emits each horizontal delta as a vertical one, same sign and
source; an off-axis vertical delta mid-gesture is dropped. The gesture
ends on the lift, real pointer motion, a button, a key, or 300 ms of
silence, like ViewDrag and PopupWheel, and runs after both so a view
region still gets its drag. A user-held Shift or Ctrl passes through.
PopupWheel's Ctrl hold is factored into hold_synthetic_modifier and
shared.

Verified in a cce-shadow with a Qt X11 probe reporting as Houdini FX and
an empty view-region list: horizontal swipe -> Shift down, three dy
events, Shift up; vertical swipe untouched; held Shift passes dx through;
wheel-source dx untouched; diagonal drift dropped.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/server/config.rs         |  27 ++++++-
 src/server/cursor.rs         | 182 ++++++++++++++++++++++++++++++++++++++++++-
 src/server/keyboard_group.rs |   6 ++
 src/server/window_manager.rs |   4 +
 4 files changed, 216 insertions(+), 3 deletions(-)

diff --git a/src/server/config.rs b/src/server/config.rs
index d3d3cc9..2bf120f 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -421,6 +421,12 @@ pub struct WindowManagerConfig {
     /// Reverse the drag direction, on top of the natural-scroll correction
     /// the drag already makes. KDL: `touchpad_view_invert (bool)true`.
     pub touchpad_view_invert: Option<bool>,
+    /// Apps whose native widgets scroll sideways only under Shift and read a
+    /// horizontal wheel as a vertical one (Houdini's spreadsheet, list and
+    /// parameter panes): a horizontal two-finger scroll over their windows is
+    /// 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>>,
 }
 
 #[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
@@ -2347,7 +2353,8 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
         let touchpad_view_swipe = get_child_arg_string_opt(node, "touchpad_view_swipe");
         let touchpad_view_sensitivity = get_child_arg_f64_opt(node, "touchpad_view_sensitivity");
         let touchpad_view_invert = get_child_arg_bool_opt(node, "touchpad_view_invert");
-        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, 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, touchpad_view_invert, touchpad_hscroll_shift_apps });
     }
 
     Ok(Config {
@@ -2422,6 +2429,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
             .map_or(false, |s| s.eq_ignore_ascii_case("tumble"));
         state.touchpad_view_sensitivity = tv.and_then(|w| w.touchpad_view_sensitivity).unwrap_or(1.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.display = config.display.clone();
     state.on_app_exit = config
@@ -3081,6 +3089,23 @@ style {
         assert_eq!(tpoint.scroll_method, Some("none".to_string()));
     }
 
+    #[test]
+    fn test_kdl_touchpad_hscroll_shift_apps() {
+        let content = r#"
+            window_manager {
+                touchpad_view_apps "Houdini FX"
+                touchpad_hscroll_shift_apps "Houdini FX" "hython*"
+            }
+        "#;
+        let config = parse_kdl_config(content).unwrap();
+        let wm = config.window_manager.unwrap();
+        assert_eq!(wm.touchpad_view_apps, Some(vec!["Houdini FX".to_string()]));
+        assert_eq!(wm.touchpad_hscroll_shift_apps, Some(vec!["Houdini FX".to_string(), "hython*".to_string()]));
+        // Absent, the list is empty and the emulation is off.
+        let config = parse_kdl_config("window_manager { }").unwrap();
+        assert_eq!(config.window_manager.unwrap().touchpad_hscroll_shift_apps, None);
+    }
+
     #[test]
     fn test_kdl_surface_border_parsing() {
         let content = r##"
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 3cf5b6e..8df3b9b 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -19,6 +19,10 @@ pub struct Cursor {
     /// are mutually exclusive — a view drag needs a window under the
     /// pointer, this one an override-redirect surface.
     pub popup_wheel: Option<PopupWheel>,
+    /// Horizontal-scroll-as-Shift+vertical emulation over the apps
+    /// `touchpad_hscroll_shift_apps` names (see `HScrollShift`); `None`
+    /// while no such gesture is in progress.
+    pub hscroll_shift: Option<HScrollShift>,
     /// Ends an emulated gesture — a `ViewDrag` or a `PopupWheel`, which
     /// never run at once — that has seen no finger event for a while: a
     /// two-finger scroll normally ends with a zero-delta axis event, but
@@ -187,6 +191,7 @@ impl Default for Cursor {
             pinch_start_zoom: 1.0,
             view_drag: None,
             popup_wheel: None,
+            hscroll_shift: None,
             view_drag_timer: std::ptr::null_mut(),
             last_click_time: 0,
             last_click_window: std::ptr::null_mut(),
@@ -1194,6 +1199,11 @@ unsafe extern "C" fn handle_motion(listener: *mut ffi::wl_listener, data: *mut s
     if cursor.view_drag.is_some() {
         cursor.end_view_drag("motion");
     }
+    // Likewise the held Shift must not ride along to wherever the pointer
+    // goes next.
+    if cursor.hscroll_shift.is_some() {
+        cursor.end_hscroll_shift("motion");
+    }
     
     let mut dx = (*event).delta_x;
     let mut dy = (*event).delta_y;
@@ -1296,6 +1306,7 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
     // Before the button reaches the client, so a click on the popup is not
     // a Ctrl-click (see `PopupWheel`).
     cursor.end_popup_wheel("button");
+    cursor.end_hscroll_shift("button");
     
     let seat = &mut *cursor.seat;
     let lx = cursor.x();
@@ -2410,6 +2421,11 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
     if is_finger && cursor.popup_wheel_axis(event, delta, modifiers) {
         return;
     }
+    // A horizontal one over an app in `touchpad_hscroll_shift_apps` is
+    // delivered as Shift + vertical (see `HScrollShift`).
+    if is_finger && cursor.hscroll_shift_axis(event, delta, delta_discrete, modifiers) {
+        return;
+    }
 
     ffi::wlr_seat_pointer_notify_axis(
         seat.wlr_seat,
@@ -2800,6 +2816,15 @@ impl Cursor {
             Some(p) if p.ctrl_down != down => p.ctrl_down = down,
             _ => return,
         }
+        self.hold_synthetic_modifier(KEY_LEFTCTRL, b"Control\0", down);
+    }
+
+    /// Press or release `key` on the client's behalf and OR its xkb
+    /// modifier (`xkb_name`, NUL-terminated) over the keyboard's live state.
+    /// Never touches `injected_key_mods`, so the compositor's own modifier
+    /// reads keep seeing the user's real keys. Shared by `PopupWheel`
+    /// (Ctrl) and `HScrollShift` (Shift).
+    unsafe fn hold_synthetic_modifier(&mut self, key: u32, xkb_name: &[u8], down: bool) {
         let seat = &mut *self.seat;
         let time = crate::util::msec_timestamp();
         let state = if down {
@@ -2807,10 +2832,10 @@ impl Cursor {
         } else {
             ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_RELEASED
         };
-        ffi::wlr_seat_keyboard_notify_key(seat.wlr_seat, time, KEY_LEFTCTRL, state);
+        ffi::wlr_seat_keyboard_notify_key(seat.wlr_seat, time, key, state);
         let kb = ffi::river_wlr_seat_get_keyboard(seat.wlr_seat);
         if !kb.is_null() && !(*kb).keymap.is_null() {
-            let idx = ffi::xkb_keymap_mod_get_index((*kb).keymap, b"Control\0".as_ptr() as *const _);
+            let idx = ffi::xkb_keymap_mod_get_index((*kb).keymap, xkb_name.as_ptr() as *const _);
             if idx != ffi::XKB_MOD_INVALID {
                 // Released sends the device's own state back, which is the
                 // user's keys minus this bit.
@@ -2924,9 +2949,162 @@ unsafe extern "C" fn handle_view_drag_timeout(data: *mut std::ffi::c_void) -> st
     let cursor = &mut *(data as *mut Cursor);
     cursor.end_view_drag("idle");
     cursor.end_popup_wheel("idle");
+    cursor.end_hscroll_shift("idle");
     0
 }
 
+/// A horizontal trackpad scroll over an app whose widgets cannot use one,
+/// turned into the Shift + vertical scroll they can.
+///
+/// Why this exists: Houdini's native panes -- the geometry spreadsheet
+/// first of all -- read a wheel event's magnitude and ignore its axis: a
+/// horizontal wheel scrolls the rows, and the documented way to scroll the
+/// columns is to hold Shift while scrolling. Measured live: a horizontal
+/// two-finger swipe reached Houdini as a proper horizontal QWheelEvent
+/// (angleDelta x only) and moved the rows; the same swipe with Shift held
+/// moved the columns. So over such a window the compositor holds Shift for
+/// the gesture and re-emits each horizontal finger delta as a vertical one,
+/// same sign, same source. A vertical delta arriving mid-gesture (a swipe
+/// drifting off axis) is dropped rather than sent sideways; the gesture
+/// ends on the lift, on real pointer motion, a button, a key, or silence.
+///
+/// The user already holding Shift or Ctrl passes through untouched: Shift
+/// means they are doing it by hand, Ctrl is the app's own wheel modifier.
+pub struct HScrollShift {
+    /// The window's surface the gesture started on; pointer focus moving
+    /// off it ends the gesture, so the held Shift never reaches another.
+    pub surface: *mut ffi::wlr_surface,
+    pub shift_down: bool,
+    /// Keyboard repeat before the gesture, restored at its end (see
+    /// `PopupWheel::repeat`).
+    pub repeat: Option<(i32, i32)>,
+}
+
+const KEY_LEFTSHIFT: u32 = 42;
+/// Finger silence that ends the gesture when the lift never came; a short
+/// one is safe here because nothing is held that a re-press would break
+/// (unlike `ViewDrag`'s Space).
+const HSCROLL_SHIFT_IDLE_MS: i32 = 300;
+
+impl Cursor {
+    /// The surface under the pointer, if it belongs to a window of an app
+    /// in `touchpad_hscroll_shift_apps`.
+    unsafe fn hscroll_shift_target(&mut self) -> Option<*mut ffi::wlr_surface> {
+        let server = (*self.seat).server;
+        let wm = &(*server).wm;
+        if wm.touchpad_hscroll_shift_apps.is_empty() {
+            return None;
+        }
+        let result = (*server).scene.at(self.x(), self.y())?;
+        let SceneNodeDataVal::Window(window) = result.data else { return None };
+        if window.is_null() || result.surface.is_null() {
+            return None;
+        }
+        let app_id = (*window).get_app_id_string().unwrap_or_default();
+        if !wm.touchpad_hscroll_shift_apps.iter().any(|p| crate::window_manager::app_id_matches(p, &app_id)) {
+            return None;
+        }
+        Some(result.surface)
+    }
+
+    unsafe fn hold_hscroll_shift(&mut self, down: bool) {
+        match self.hscroll_shift.as_mut() {
+            Some(h) if h.shift_down != down => h.shift_down = down,
+            _ => return,
+        }
+        self.hold_synthetic_modifier(KEY_LEFTSHIFT, b"Shift\0", down);
+    }
+
+    /// `reason` names what ended it, as `end_view_drag`'s does.
+    pub unsafe fn end_hscroll_shift(&mut self, reason: &str) {
+        if self.hscroll_shift.is_none() {
+            return;
+        }
+        self.hold_hscroll_shift(false);
+        let Some(h) = self.hscroll_shift.take() else { return };
+        log::info!("[HScrollShift] end reason={}", reason);
+        if !self.view_drag_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.view_drag_timer, 0);
+        }
+        if let Some((rate, delay)) = h.repeat {
+            let kbd = ffi::river_wlr_seat_get_keyboard((*self.seat).wlr_seat);
+            if !kbd.is_null() {
+                ffi::wlr_keyboard_set_repeat_info(kbd, rate, delay);
+            }
+        }
+    }
+
+    /// A finger-source axis event over such a window. Returns true when
+    /// consumed (re-emitted as Shift + vertical, or dropped).
+    pub unsafe fn hscroll_shift_axis(&mut self, event: *const ffi::wlr_pointer_axis_event, delta: f64, delta_discrete: i32, modifiers: u32) -> bool {
+        const SHIFT: u32 = 0x1;
+        const CTRL: u32 = 0x4;
+        let vertical = (*event).orientation == ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL;
+        if delta == 0.0 {
+            // The fingers lifted. Both axes lift; the first one ends it and
+            // the second finds nothing to do -- and neither must reach the
+            // client as a stray horizontal event.
+            if self.hscroll_shift.is_some() {
+                self.end_hscroll_shift("lift");
+                return true;
+            }
+            return false;
+        }
+        if modifiers & (SHIFT | CTRL) != 0 {
+            if self.hscroll_shift.is_some() {
+                self.end_hscroll_shift("modifier");
+            }
+            return false;
+        }
+        if self.hscroll_shift.is_none() {
+            if vertical {
+                return false;
+            }
+            let Some(surface) = self.hscroll_shift_target() else { return false };
+            let seat = &mut *self.seat;
+            seat.ensure_synthetic_keyboard();
+            let mut repeat = None;
+            let kbd = ffi::river_wlr_seat_get_keyboard(seat.wlr_seat);
+            if !kbd.is_null() {
+                repeat = Some(((*kbd).repeat_info.rate, (*kbd).repeat_info.delay));
+                ffi::wlr_keyboard_set_repeat_info(kbd, 0, 0);
+            }
+            log::info!("[HScrollShift] begin on surface {:p}", surface);
+            self.hscroll_shift = Some(HScrollShift { surface, shift_down: false, repeat });
+            self.hold_hscroll_shift(true);
+        }
+        let focused = ffi::river_wlr_seat_get_pointer_focused_surface((*self.seat).wlr_seat);
+        if matches!(&self.hscroll_shift, Some(h) if focused != h.surface) {
+            self.end_hscroll_shift("left-window");
+            return false;
+        }
+        if vertical {
+            // Off-axis drift mid-gesture: under the held Shift it would
+            // scroll sideways too. Swallow it.
+            self.arm_hscroll_shift_timer();
+            return true;
+        }
+        let seat = &mut *self.seat;
+        ffi::wlr_seat_pointer_notify_axis(
+            seat.wlr_seat,
+            (*event).time_msec,
+            ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL,
+            delta,
+            delta_discrete,
+            (*event).source,
+            (*event).relative_direction,
+        );
+        self.arm_hscroll_shift_timer();
+        true
+    }
+
+    unsafe fn arm_hscroll_shift_timer(&mut self) {
+        if !self.view_drag_timer.is_null() {
+            ffi::wl_event_source_timer_update(self.view_drag_timer, HSCROLL_SHIFT_IDLE_MS);
+        }
+    }
+}
+
 unsafe extern "C" fn handle_frame(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let cursor = &mut *crate::container_of!(listener, Cursor, frame_listener);
 
diff --git a/src/server/keyboard_group.rs b/src/server/keyboard_group.rs
index e89683c..56f7eb8 100644
--- a/src/server/keyboard_group.rs
+++ b/src/server/keyboard_group.rs
@@ -361,6 +361,12 @@ unsafe extern "C" fn handle_group_key(listener: *mut ffi::wl_listener, data: *mu
     {
         (*group.seat).cursor.end_popup_wheel("key");
     }
+    if (*event).state == ffi::wl_keyboard_key_state_WL_KEYBOARD_KEY_STATE_PRESSED
+        && !group.seat.is_null()
+        && (*group.seat).cursor.hscroll_shift.is_some()
+    {
+        (*group.seat).cursor.end_hscroll_shift("key");
+    }
 
     // Cancel active binding repeats
     let seat_groups_head = &mut (*group.seat).keyboard_groups as *mut ffi::wl_list as *mut crate::server::WlList;
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 37e6da0..6eb6448 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -175,6 +175,8 @@ pub struct WindowManager {
     pub touchpad_view_swipe_tumble: bool,
     pub touchpad_view_sensitivity: f64,
     pub touchpad_view_invert: bool,
+    /// See `WindowManagerConfig::touchpad_hscroll_shift_apps`.
+    pub touchpad_hscroll_shift_apps: Vec<String>,
     /// Live override-redirect X11 surfaces (menus, tooltips, combo lists),
     /// so the per-frame pass can re-apply their 1/scale dest size — the
     /// scene's own commit listener resets it on every commit.
@@ -476,6 +478,7 @@ impl WindowManager {
         self.touchpad_view_swipe_tumble = false;
         self.touchpad_view_sensitivity = 1.0;
         self.touchpad_view_invert = false;
+        self.touchpad_hscroll_shift_apps = Vec::new();
         self.display = std::collections::HashMap::new();
         self.input_rules = Vec::new();
         self.input_config = crate::config::InputConfig::default();
@@ -553,6 +556,7 @@ impl WindowManager {
         self.touchpad_view_swipe_tumble = false;
         self.touchpad_view_sensitivity = 1.0;
         self.touchpad_view_invert = false;
+        self.touchpad_hscroll_shift_apps = Vec::new();
         self.display = std::collections::HashMap::new();
         self.has_restored_focused_window = false;
         self.restored_focused_window_mapped = false;