Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat(gestures): aim the focus swipe in the fingers' direction
A three-finger focus swipe now picks its window along the direction the
fingers moved, not one of four sides. When a focus_* bind fires, the
step's travel becomes a ray from the focused window's center, and the
policy crate's vector_focus focuses the nearest window center within
window_manager { swipe_focus_cone } degrees of it (default 45). A swipe
toward nothing in the cone changes nothing.
Each axis's sense comes from the bind table, so mirrored binds mirror
the ray and an axis without focus binds does not aim. With no focused
window the four-way action runs for its entry rule. Keyboard focus
chords stay four-way.
Repins cce-window-manager to dfde584, which adds vector_focus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 13 ++++++-
Cargo.toml | 2 +-
src/server/config.rs | 14 ++++++-
src/server/cursor.rs | 92 +++++++++++++++++++++++++++++++++++++++++++-
src/server/window_manager.rs | 32 +++++++++++++++
5 files changed, 149 insertions(+), 4 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 322829ba..6a04c611 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -570,7 +570,18 @@ with whichever threshold is in force. The lean after a step rides on the focus e
the step started (its pan target moves with the fingers) instead of
freezing it, and a lift short of the next threshold eases only that lean
back out; the steps stay. Clients are sent one cancelled `swipe_end` at
-the first fire and hear nothing more of the gesture. Only binds whose
+the first fire and hear nothing more of the gesture. **A focus swipe aims
+where the fingers went** (since 2026-09-24): when the bind that fires is
+a `focus_*`, the step's travel becomes a direction
+(`cursor::swipe_focus_vector`, each axis's sense read from the bind table,
+so mirrored binds mirror it and an axis without focus binds does not aim),
+and `WindowManager::focus_toward` hands it to the policy crate's
+`vector_focus`: the nearest window center within `window_manager {
+swipe_focus_cone }` degrees (default 45; `swipe_focus_cone_deg`) of a ray
+from the focused window's center takes focus, and a swipe toward nothing
+in the cone changes nothing (logged `focus_toward …: no window within`).
+With no focused window there is no ray, and the four-way action runs for
+its entry rule. The keyboard's focus chords stay four-way. Only binds whose
action `cursor::action_navigates` (focus/pan left/right/up/down) peek, and
only toward a direction that has one; a four-finger overview toggle leaves
the desktop still. When the bind fires (`handle_swipe_update`) the action
diff --git a/Cargo.toml b/Cargo.toml
index 99454d78..ce848597 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ doctest = false
[dependencies]
cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "a530574f7fa1f0938049db92053a17df27756acb" }
-cce-window-manager = { git = "https://github.com/lsgalante/cce-window-manager.git", rev = "416f42c78fb63131121d8f2a86a607c31c829fc5" }
+cce-window-manager = { git = "https://github.com/lsgalante/cce-window-manager.git", rev = "dfde58422441b56e4219147a6deea1ee1b099bea" }
xkbcommon = "0.7"
serde = { version = "1", features = ["derive"] }
nix = { version = "0.29", features = ["signal", "process", "fs", "poll", "resource"] }
diff --git a/src/server/config.rs b/src/server/config.rs
index 13adb9c2..39c05894 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -457,6 +457,11 @@ pub struct WindowManagerConfig {
/// lean, so a swipe that has just switched reads as settled. KDL:
/// `swipe_repeat_peek (f64)30.0`.
pub swipe_repeat_peek: Option<f64>,
+ /// How far off the swipe's own direction (degrees) a window center may
+ /// lie and still take focus from a three-finger focus swipe (default
+ /// 45; clamped to 1-89). A swipe toward nothing within it changes
+ /// nothing. KDL: `swipe_focus_cone (f64)45.0`.
+ pub swipe_focus_cone: Option<f64>,
/// Accumulated travel (libinput units, roughly mm) at which a swipe
/// bind fires (default 70). KDL: `swipe_threshold (f64)70.0`.
pub swipe_threshold: Option<f64>,
@@ -2458,11 +2463,12 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
let touchpad_view_sensitivity = get_child_arg_f64_opt(node, "touchpad_view_sensitivity");
let swipe_peek = get_child_arg_f64_opt(node, "swipe_peek");
let swipe_repeat_peek = get_child_arg_f64_opt(node, "swipe_repeat_peek");
+ let swipe_focus_cone = get_child_arg_f64_opt(node, "swipe_focus_cone");
let swipe_threshold = get_child_arg_f64_opt(node, "swipe_threshold");
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_threshold, swipe_repeat_threshold, touchpad_view_invert, 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 });
}
Ok(Config {
@@ -2544,6 +2550,10 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
.and_then(|w| w.swipe_repeat_peek)
.filter(|v| v.is_finite() && *v >= 0.0)
.unwrap_or(state.swipe_peek_px * 0.5);
+ state.swipe_focus_cone_deg = tv
+ .and_then(|w| w.swipe_focus_cone)
+ .filter(|v| v.is_finite())
+ .map_or(45.0, |v| v.clamp(1.0, 89.0));
state.swipe_threshold = tv
.and_then(|w| w.swipe_threshold)
.filter(|v| v.is_finite() && *v > 0.0)
@@ -3084,6 +3094,7 @@ style {
toggle_overview ("menu:swipe_up,swipe_down,swipe_left,swipe_right,pinch_in,pinch_out")"swipe_up"
swipe_peek (f64)40.0
swipe_repeat_peek (f64)15.0
+ swipe_focus_cone (f64)30.0
swipe_threshold (f64)80.0
swipe_repeat_threshold (f64)200.0
}
@@ -3096,6 +3107,7 @@ style {
assert_eq!(wm.toggle_overview, Some("swipe_up".to_string()));
assert_eq!(wm.swipe_peek, Some(40.0));
assert_eq!(wm.swipe_repeat_peek, Some(15.0));
+ assert_eq!(wm.swipe_focus_cone, Some(30.0));
assert_eq!(wm.swipe_threshold, Some(80.0));
assert_eq!(wm.swipe_repeat_threshold, Some(200.0));
// Absent means "unset", which the apply step reads as the centring default.
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index cff7eea8..ecc6dfbf 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -3610,6 +3610,47 @@ fn swipe_peek_for(d: f64, neg: bool, pos: bool, threshold: f64, peek_px: f64, zo
}
}
+fn is_directional_focus(action: crate::config::Action) -> bool {
+ use crate::config::Action;
+ matches!(action, Action::FocusLeft | Action::FocusRight | Action::FocusUp | Action::FocusDown)
+}
+
+/// The sense a swipe along `finger_dir` ("left", "right", "up", "down")
+/// gives its axis when aiming focus: +1 when its bind focuses the way the
+/// fingers moved, -1 when it focuses the opposite way, `None` when the
+/// first bind that way is not a focus on that axis (so the axis does not
+/// aim). First match wins, as in the fire itself.
+fn focus_axis_sense(binds: &[crate::config::GestureBind], fingers: u32, mods: u32, finger_dir: &str) -> Option<f64> {
+ use crate::config::Action;
+ let gb = binds
+ .iter()
+ .find(|gb| gb.gesture_type == "swipe" && gb.fingers == fingers && gb.mods == mods && gb.direction == finger_dir)?;
+ let focus_dir = match gb.action {
+ Action::FocusLeft => "left",
+ Action::FocusRight => "right",
+ Action::FocusUp => "up",
+ Action::FocusDown => "down",
+ _ => return None,
+ };
+ let axis = |d: &str| if d == "left" || d == "right" { 0 } else { 1 };
+ if axis(focus_dir) != axis(finger_dir) {
+ return None;
+ }
+ Some(if focus_dir == finger_dir { 1.0 } else { -1.0 })
+}
+
+/// The direction a focus swipe aims in, from this step's `travel`: each
+/// axis's travel times its sense (`focus_axis_sense`), zero on an axis
+/// with no focus bind the way the fingers went. `None` when neither axis
+/// aims.
+fn swipe_focus_vector(binds: &[crate::config::GestureBind], fingers: u32, mods: u32, travel: (f64, f64)) -> Option<(f64, f64)> {
+ let (dx, dy) = travel;
+ let sx = if dx != 0.0 { focus_axis_sense(binds, fingers, mods, if dx < 0.0 { "left" } else { "right" }) } else { None };
+ let sy = if dy != 0.0 { focus_axis_sense(binds, fingers, mods, if dy < 0.0 { "up" } else { "down" }) } else { None };
+ let v = (sx.map_or(0.0, |s| s * dx), sy.map_or(0.0, |s| s * dy));
+ (v != (0.0, 0.0)).then_some(v)
+}
+
/// Slope (minor over major travel) below which a swipe counts as straight:
/// tan 15°. Within it the lean stays on the dominant axis, since a hand
/// swiping left drifts a little up or down, and leaning with that drift
@@ -3757,6 +3798,9 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
if matched_action != crate::config::Action::None {
log::info!("Swipe gesture matched action: {:?}", matched_action);
+ // This step's travel, before it restarts below: a focus swipe
+ // aims along it (`swipe_focus_vector`).
+ let travel = (cursor.gesture_dx, cursor.gesture_dy);
let first_fire = !cursor.gesture_triggered;
cursor.gesture_triggered = true;
// The next step needs a full threshold of fresh travel from here,
@@ -3801,7 +3845,16 @@ unsafe extern "C" fn handle_swipe_update(listener: *mut ffi::wl_listener, data:
} else {
matched_action
};
- (*seat.server).wm.execute_action(&matched_action, matched_command.as_deref());
+ // A focus swipe aims where the fingers went, not at one of four
+ // directions: the bind table gives each axis its sense (a
+ // `focus_left` on `swipe3_left` follows the fingers, one on
+ // `swipe3_right` mirrors them), and the window manager picks the
+ // nearest window center along the result.
+ let focus_vector = swipe_focus_vector(&(*seat.server).wm.gesture_binds, (*event).fingers, modifiers, travel);
+ match focus_vector {
+ Some(v) if is_directional_focus(matched_action) => (*seat.server).wm.focus_toward(v, &matched_action),
+ _ => (*seat.server).wm.execute_action(&matched_action, matched_command.as_deref()),
+ }
// No reversal at the fire. The action set a pan target if the
// window it focused needs one (from the leaned camera — so a
@@ -4467,4 +4520,41 @@ mod tests {
let [x, y] = swipe_lean(140.0, 140.0, ALL, 70.0, 120.0, 0.5);
assert!((x - 240.0).abs() < EPS && (y - 240.0).abs() < EPS, "{x} {y}");
}
+
+ fn bind(direction: &str, action: crate::config::Action) -> crate::config::GestureBind {
+ crate::config::GestureBind {
+ mods: 0,
+ gesture_type: "swipe".into(),
+ fingers: 3,
+ direction: direction.into(),
+ action,
+ command: None,
+ }
+ }
+
+ #[test]
+ fn focus_vector_follows_natural_binds() {
+ use crate::config::Action::*;
+ let binds = [bind("left", FocusLeft), bind("right", FocusRight), bind("up", FocusUp), bind("down", FocusDown)];
+ assert_eq!(swipe_focus_vector(&binds, 3, 0, (50.0, -30.0)), Some((50.0, -30.0)));
+ // Another finger count or chord has no binds here.
+ assert_eq!(swipe_focus_vector(&binds, 4, 0, (50.0, -30.0)), Option::None);
+ }
+
+ #[test]
+ fn focus_vector_mirrors_mirrored_binds() {
+ use crate::config::Action::*;
+ let binds = [bind("left", FocusRight), bind("right", FocusLeft), bind("up", FocusDown), bind("down", FocusUp)];
+ assert_eq!(swipe_focus_vector(&binds, 3, 0, (50.0, -30.0)), Some((-50.0, 30.0)));
+ }
+
+ #[test]
+ fn focus_vector_ignores_an_axis_without_focus_binds() {
+ use crate::config::Action::*;
+ // Up/down pan rather than focus: a diagonal swipe aims sideways only.
+ let binds = [bind("left", FocusLeft), bind("right", FocusRight), bind("up", PanUp), bind("down", PanDown)];
+ assert_eq!(swipe_focus_vector(&binds, 3, 0, (50.0, -30.0)), Some((50.0, 0.0)));
+ assert_eq!(swipe_focus_vector(&binds, 3, 0, (0.0, -30.0)), Option::None);
+ }
+
}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index a47228d4..a2f35d69 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -221,6 +221,10 @@ pub struct WindowManager {
/// step once a swipe has switched focus, screen px at
/// `swipe_repeat_threshold` (default half of `swipe_peek_px`).
pub swipe_repeat_peek_px: f64,
+ /// `window_manager { swipe_focus_cone }`: degrees off a focus swipe's
+ /// direction within which a window center can take focus (default 45).
+ /// See `focus_toward`.
+ pub swipe_focus_cone_deg: f64,
/// `window_manager { swipe_threshold }`: accumulated swipe travel
/// (libinput units) at which a swipe bind fires (default 70).
pub swipe_threshold: f64,
@@ -541,6 +545,7 @@ impl WindowManager {
self.touchpad_view_sensitivity = 1.0;
self.swipe_peek_px = 60.0;
self.swipe_repeat_peek_px = 30.0;
+ self.swipe_focus_cone_deg = 45.0;
self.swipe_threshold = 70.0;
self.swipe_repeat_threshold = 280.0;
self.touchpad_view_invert = false;
@@ -625,6 +630,7 @@ impl WindowManager {
self.touchpad_view_sensitivity = 1.0;
self.swipe_peek_px = 60.0;
self.swipe_repeat_peek_px = 30.0;
+ self.swipe_focus_cone_deg = 45.0;
self.swipe_threshold = 70.0;
self.swipe_repeat_threshold = 280.0;
self.touchpad_view_invert = false;
@@ -4659,6 +4665,32 @@ impl WindowManager {
}
}
+ /// Focus toward a free direction `v` (virtual units, y down): the
+ /// three-finger focus swipe's path, which has a vector where a key has
+ /// one of four directions (`policy::focus::vector_focus`). The nearest
+ /// window center within `swipe_focus_cone_deg` of the ray from the
+ /// focused window's center takes focus; with none, focus stays. With
+ /// no focused window in the focus ring there is no ray, so `fallback`
+ /// (the four-way action the swipe was bound to) runs instead, for its
+ /// entry rule.
+ pub unsafe fn focus_toward(&mut self, v: (f64, f64), fallback: &crate::config::Action) {
+ use crate::policy::api::Compositor;
+ self.stop_panning_animation();
+ let ctx = self.build_action_ctx();
+ let has_ray = ctx.windows.iter().any(|w| w.focus_cyclable && Some(w.id) == ctx.focused);
+ if !has_ray {
+ self.execute_action(fallback, None);
+ return;
+ }
+ let cmds = crate::policy::actions::focus_toward(&ctx, v, self.swipe_focus_cone_deg);
+ if cmds.is_empty() {
+ log::info!("focus_toward {:?}: no window within {}°", v, self.swipe_focus_cone_deg);
+ }
+ for cmd in &cmds {
+ self.apply(cmd);
+ }
+ }
+
pub unsafe fn execute_action(&mut self, action: &crate::config::Action, command: Option<&str>) {
use crate::config::Action;
self.stop_panning_animation();