From a14f22211990bef92895c2804836326060bef3cc Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 11:34:30 +0100 Subject: [PATCH] Make horizontal focus swipes follow touchpad motion --- compositor/src/input.rs | 18 +++- compositor/src/state.rs | 190 +++++++++++++++++++++++++++++++++++++++- docs/configuration.md | 5 +- docs/shortcuts.md | 4 +- 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/compositor/src/input.rs b/compositor/src/input.rs index bda836e..7827418 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -160,6 +160,7 @@ impl Ferese { InputEvent::GestureSwipeBegin { event } => { let pointer = seat.get_pointer().expect("seat has a pointer"); self.finish_workspace_swipe(None); + self.finish_focus_swipe(None); self.swipe.begin( event.fingers(), self.swipe_navigation_blocked() @@ -187,14 +188,23 @@ impl Ferese { self.swipe.update(event.delta_x(), event.delta_y()); if self.swipe_navigation_blocked() { self.finish_workspace_swipe(None); + self.finish_focus_swipe(None); } else if let Some((direction, progress)) = self.swipe.preview() - && let Some(BindingAction::SwitchRelativeWorkspace(next)) = self + && let Some(action) = self .bindings .iter() .find(|binding| binding.matches_swipe(self.swipe.fingers(), direction)) .map(|binding| binding.action.clone()) { - self.preview_workspace_swipe(next, direction, progress); + match action { + BindingAction::SwitchRelativeWorkspace(next) => { + self.preview_workspace_swipe(next, direction, progress); + } + BindingAction::Focus(focus @ (Direction::Left | Direction::Right)) => { + self.preview_focus_swipe(focus, direction, progress); + } + _ => {} + } } } else { seat.get_pointer().expect("seat has a pointer").gesture_swipe_update( @@ -211,7 +221,9 @@ impl Ferese { let fingers = self.swipe.fingers(); let cancelled = event.cancelled() || self.swipe_navigation_blocked(); let direction = self.swipe.finish(cancelled); - let handled = self.finish_workspace_swipe(direction) || self.swipe.preview_started(); + let workspace_handled = self.finish_workspace_swipe(direction); + let focus_handled = self.finish_focus_swipe(direction); + let handled = workspace_handled || focus_handled || self.swipe.preview_started(); if !handled && let Some(direction) = direction && let Some(action) = self diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 5f18737..9f6c2ff 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -131,6 +131,35 @@ impl SlideOffset { } } +struct FocusSwipe { + workspace: WorkspaceId, + from: WindowId, + to: WindowId, + direction: Direction, + gesture_direction: SwipeDirection, + start: f64, + destination: f64, + progress: f64, +} + +impl FocusSwipe { + fn position(&self) -> f64 { + let distance = self.destination - self.start; + // When both columns already fit, give bounded drag feedback and settle + // back to the unchanged viewport after selecting the next window. + let distance = if distance.abs() < 0.001 { + if self.direction == Direction::Right { + 64.0 + } else { + -64.0 + } + } else { + distance + }; + self.start + distance * self.progress + } +} + struct WorkspaceSlideItem { workspace: WorkspaceId, start: SlideOffset, @@ -308,6 +337,7 @@ pub struct Ferese { pub(crate) backdrop_generation: u64, closing_windows: HashMap, viewport_animations: HashMap, + focus_swipe: Option, workspace_slides: HashMap, scrolling_world_x: HashMap, viewport_coupled_widths: HashMap, @@ -537,6 +567,7 @@ impl Ferese { backdrop_generation: 0, closing_windows: HashMap::new(), viewport_animations: HashMap::new(), + focus_swipe: None, workspace_slides: HashMap::new(), scrolling_world_x: HashMap::new(), viewport_coupled_widths: HashMap::new(), @@ -1960,6 +1991,13 @@ impl Ferese { fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool { let delta = delta.mul_f64(self.animation_speed); + if self + .focus_swipe + .as_ref() + .is_some_and(|swipe| !self.focus_swipe_is_current(swipe)) + { + self.focus_swipe = None; + } let visible_workspaces = if self.viewport_animations.is_empty() { HashSet::new() } else { @@ -2112,7 +2150,10 @@ impl Ferese { if blocked_workspaces.contains(workspace) { continue; } - if self.animations_enabled { + if let Some(swipe) = self.focus_swipe.as_ref().filter(|swipe| swipe.workspace == *workspace) { + viewport.current = swipe.position(); + viewport.velocity = 0.0; + } else if self.animations_enabled { active_animation |= viewport.advance(delta, self.viewport_spring_config); } else { viewport.snap(); @@ -2419,6 +2460,103 @@ impl Ferese { self.focus_direction_with_slide(direction, false); } + fn focus_swipe_is_current(&self, swipe: &FocusSwipe) -> bool { + self.animations_enabled + && !self.session_lock.active + && !self.overview.is_presenting() + && self.workspaces.active_id() == swipe.workspace + && self.focused_window == Some(swipe.from) + && self.workspaces.workspace(swipe.workspace).is_some_and(|workspace| { + workspace.fullscreen.is_none() + && workspace.layout.contains(swipe.from) + && workspace.layout.contains(swipe.to) + }) + } + + pub(crate) fn preview_focus_swipe( + &mut self, + direction: Direction, + gesture_direction: SwipeDirection, + progress: f64, + ) { + if self.focus_swipe.is_none() { + if self.swipe.preview_started() || !self.animations_enabled || self.overview.is_presenting() { + return; + } + let Some(from) = self.focused_window else { return }; + let Some(bounds) = self.output_bounds() else { return }; + let workspace = self.workspaces.active(); + if workspace.fullscreen.is_some() || !matches!(workspace.layout, WorkspaceLayout::Scrolling(_)) { + return; + } + let Ok(Some(to)) = workspace.layout.directional_neighbor(from, direction, bounds) else { + return; + }; + let workspace_id = workspace.id; + let start = self + .viewport_animations + .get(&workspace_id) + .map(|viewport| viewport.current) + .or_else(|| workspace.layout.viewport_x()) + .unwrap_or(0.0); + let mut candidate = workspace.layout.clone(); + if let WorkspaceLayout::Scrolling(layout) = &mut candidate + && layout.slide_focus_from(from, to).is_err() + { + return; + } + if candidate + .geometry_with_constraints(bounds, self.gap_config, &self.window_constraints(), Some(to)) + .is_err() + { + return; + } + self.focus_swipe = Some(FocusSwipe { + workspace: workspace_id, + from, + to, + direction, + gesture_direction, + start, + destination: candidate.viewport_x().unwrap_or(start), + progress, + }); + self.swipe.mark_preview_started(); + } + if let Some(swipe) = &mut self.focus_swipe { + swipe.progress = progress; + } + self.backdrop_generation = self.backdrop_generation.wrapping_add(1); + self.cursor_redraw_pending = true; + } + + pub(crate) fn finish_focus_swipe(&mut self, direction: Option) -> bool { + let Some(swipe) = self.focus_swipe.take() else { + return false; + }; + let current = self.focus_swipe_is_current(&swipe); + // Release from the last input position even if no frame rendered that update. + if current && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) { + viewport.current = swipe.position(); + viewport.velocity = 0.0; + } + let neighbor = self.output_bounds().and_then(|bounds| { + self.workspaces + .active() + .layout + .directional_neighbor(swipe.from, swipe.direction, bounds) + .ok() + .flatten() + }); + self.last_animation_tick = Instant::now(); + if current && direction == Some(swipe.gesture_direction) && neighbor == Some(swipe.to) { + self.focus_direction_from_swipe(swipe.direction); + } else { + self.relayout(); + } + true + } + pub(crate) fn focus_direction_from_swipe(&mut self, direction: Direction) { self.focus_direction_with_slide(direction, true); } @@ -3677,6 +3815,56 @@ mod tests { assert_eq!(workspaces.active().last_focused, Some(WindowId(2))); } + #[test] + fn focus_drag_reverses_and_settles_from_the_release_position() { + for (direction, gesture_direction, destination) in [ + (Direction::Right, SwipeDirection::Left, 900.0), + (Direction::Left, SwipeDirection::Right, 100.0), + ] { + for committed in [false, true] { + let mut swipe = FocusSwipe { + workspace: WorkspaceId(1), + from: WindowId(1), + to: WindowId(2), + direction, + gesture_direction, + start: 500.0, + destination, + progress: 0.75, + }; + let far = swipe.position(); + swipe.progress = 0.25; + let near = swipe.position(); + assert!((near - swipe.start).abs() < (far - swipe.start).abs()); + let mut viewport = AnimatedValue::new(near); + viewport.set_target(if committed { destination } else { swipe.start }); + assert_eq!(viewport.current, near); + for _ in 0..300 { + viewport.advance(Duration::from_millis(16), SpringConfig::default()); + } + assert_eq!(viewport.current, viewport.target); + } + } + } + + #[test] + fn fully_visible_columns_still_give_bounded_horizontal_drag_feedback() { + for (direction, sign) in [(Direction::Right, 1.0), (Direction::Left, -1.0)] { + let swipe = FocusSwipe { + workspace: WorkspaceId(1), + from: WindowId(1), + to: WindowId(2), + direction, + gesture_direction: SwipeDirection::Left, + start: 0.0, + destination: 0.0, + progress: 0.5, + }; + assert_eq!(swipe.position(), sign * 32.0); + assert_eq!(swipe.destination, 0.0); + } + } + #[test] fn workspace_drag_tracks_progress_and_releases_without_a_jump() { for committed in [false, true] { diff --git a/docs/configuration.md b/docs/configuration.md index 442bd04..9b49b89 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -259,10 +259,11 @@ The touchpad device keys are booleans, all defaulting to `true`: `tap`, `natural-scroll` and `disable-while-typing`. These apply to DRM devices; nested previews use the host's physical input settings. -Workspace swipes follow your fingers and settle when released. Up goes to the next workspace on the +Workspace swipes and left/right window-focus swipes follow your fingers and settle +when released. Up goes to the next workspace on the current monitor, down to the previous; left focuses the window to the right, and right focuses the window to the left. Workspace swipes skip workspaces owned -by other monitors and stop at the first/last workspace. Short or cancelled workspace +by other monitors and stop at the first/last workspace. Short or cancelled navigation swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during window grabs, or when an application inhibits shortcuts. diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 9d42771..5592359 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -87,9 +87,9 @@ for dependencies and custom screenshot commands. | Three-finger swipe | Action on release | | --- | --- | | Up / down | Drag to the next / previous workspace on this monitor; release to settle | -| Left / right | Focus the window to the right / left | +| Left / right | Drag the window row; release to focus the window to the right / left | -Short or cancelled workspace swipes slide back. Diagonal swipes do not navigate. +Short or cancelled navigation swipes slide back. Diagonal swipes do not navigate. Gestures can use the same actions as keyboard bindings, including overview or moving a window. Four- and five-finger bindings are supported too.