Make horizontal focus swipes follow touchpad motion

This commit is contained in:
Samuel Onoja committed 2026-10-01 11:34:30 +01:00
1 parent b29d271252
commit a14f222119
4 files changed
+208 -7

No files matched your search

+14 -2
View File
@@ -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,15 +188,24 @@ 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())
{
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(
self,
@@ -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
+189 -1
View File
@@ -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<WindowId, ClosingAnimation>,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
focus_swipe: Option<FocusSwipe>,
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
viewport_coupled_widths: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
@@ -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<SwipeDirection>) -> 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] {
+3 -2
View File
@@ -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.
+2 -2
View File
@@ -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.