Add continuous touchpad workspace swiping

This commit is contained in:
Samuel Onoja committed 2026-10-01 10:28:14 +01:00
1 parent 17b8482433
commit 464a1a635b
5 files changed
+328 -59

No files matched your search

+101 -17
View File
@@ -1,4 +1,4 @@
//! A swipe commits once on release; cancelled and ambiguous gestures do nothing.
//! Navigation previews follow finger motion; actions commit once on release.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[repr(u32)]
@@ -14,6 +14,8 @@ pub(crate) struct Swipe {
delta: Option<(f64, f64)>,
threshold: f64,
fingers: u32,
direction: Option<SwipeDirection>,
preview_started: bool,
}
impl Swipe {
@@ -21,6 +23,8 @@ impl Swipe {
self.delta = ((3..=5).contains(&fingers) && !blocked).then_some((0.0, 0.0));
self.threshold = f64::from(threshold);
self.fingers = fingers;
self.direction = None;
self.preview_started = false;
}
pub fn fingers(&self) -> u32 {
@@ -35,30 +39,63 @@ impl Swipe {
if let Some((x, y)) = &mut self.delta {
*x += dx;
*y += dy;
if self.direction.is_none() {
self.direction = dominant_direction(*x, *y, 6.0);
}
}
}
pub fn mark_preview_started(&mut self) {
self.preview_started = true;
}
pub fn preview_started(&self) -> bool {
self.preview_started
}
pub fn preview(&self) -> Option<(SwipeDirection, f64)> {
let (x, y) = self.delta?;
if !x.is_finite() || !y.is_finite() {
return None;
}
let direction = self.direction?;
let distance = match direction {
SwipeDirection::Left => -x,
SwipeDirection::Right => x,
SwipeDirection::Up => -y,
SwipeDirection::Down => y,
};
Some((direction, (distance / (self.threshold.max(1.0) * 2.0)).clamp(0.0, 1.0)))
}
pub fn finish(&mut self, cancelled: bool) -> Option<SwipeDirection> {
let (x, y) = self.delta.take()?;
if cancelled || !x.is_finite() || !y.is_finite() {
if cancelled {
return None;
}
const DOMINANCE: f64 = 1.25;
if x.abs() >= self.threshold && x.abs() > y.abs() * DOMINANCE {
Some(if x < 0.0 {
SwipeDirection::Left
} else {
SwipeDirection::Right
})
} else if y.abs() >= self.threshold && y.abs() > x.abs() * DOMINANCE {
Some(if y < 0.0 {
SwipeDirection::Up
} else {
SwipeDirection::Down
})
dominant_direction(x, y, self.threshold).filter(|direction| Some(*direction) == self.direction)
}
}
fn dominant_direction(x: f64, y: f64, threshold: f64) -> Option<SwipeDirection> {
if !x.is_finite() || !y.is_finite() {
return None;
}
const DOMINANCE: f64 = 1.25;
if x.abs() >= threshold && x.abs() > y.abs() * DOMINANCE {
Some(if x < 0.0 {
SwipeDirection::Left
} else {
None
}
SwipeDirection::Right
})
} else if y.abs() >= threshold && y.abs() > x.abs() * DOMINANCE {
Some(if y < 0.0 {
SwipeDirection::Up
} else {
SwipeDirection::Down
})
} else {
None
}
}
@@ -66,6 +103,53 @@ impl Swipe {
mod tests {
use super::*;
#[test]
fn cancellation_and_new_gestures_reset_preview_state() {
let mut swipe = Swipe::default();
swipe.begin(3, false, 80);
swipe.update(0.0, -200.0);
swipe.mark_preview_started();
assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 1.0)));
assert_eq!(swipe.finish(true), None);
assert!(
swipe.preview_started(),
"release must suppress a second action after a preview"
);
assert_eq!(swipe.preview(), None);
swipe.begin(4, false, 80);
assert!(!swipe.preview_started());
swipe.update(-100.0, 0.0);
assert_eq!(swipe.finish(false), Some(SwipeDirection::Left));
swipe.begin(3, true, 80);
swipe.update(0.0, -200.0);
assert!(!swipe.active());
assert_eq!(swipe.finish(false), None);
}
#[test]
fn preview_tracks_motion_and_reversal_before_release() {
let mut swipe = Swipe::default();
swipe.begin(3, false, 80);
swipe.update(0.0, -40.0);
assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 0.25)));
swipe.update(0.0, -40.0);
assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 0.5)));
swipe.update(0.0, 60.0);
assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 0.125)));
assert_eq!(swipe.finish(false), None);
assert_eq!(swipe.preview(), None);
}
#[test]
fn reversal_across_the_origin_cancels_instead_of_committing_another_axis() {
let mut swipe = Swipe::default();
swipe.begin(3, false, 80);
swipe.update(0.0, -40.0);
swipe.update(0.0, 160.0);
assert_eq!(swipe.preview(), Some((SwipeDirection::Up, 0.0)));
assert_eq!(swipe.finish(false), None);
}
#[test]
fn a_swipe_accumulates_motion_and_commits_only_once_on_release() {
let mut swipe = Swipe::default();
+38 -30
View File
@@ -160,6 +160,7 @@ impl Ferese {
match event {
InputEvent::GestureSwipeBegin { event } => {
let pointer = seat.get_pointer().expect("seat has a pointer");
self.finish_workspace_swipe(None);
self.swipe.begin(
event.fingers(),
self.swipe_navigation_blocked()
@@ -185,6 +186,17 @@ impl Ferese {
InputEvent::GestureSwipeUpdate { event } => {
if self.swipe.active() {
self.swipe.update(event.delta_x(), event.delta_y());
if self.swipe_navigation_blocked() {
self.finish_workspace_swipe(None);
} else if let Some((direction, progress)) = self.swipe.preview()
&& let Some(BindingAction::SwitchRelativeWorkspace(next)) = self
.bindings
.iter()
.find(|binding| binding.matches_swipe(self.swipe.fingers(), direction))
.map(|binding| binding.action.clone())
{
self.preview_workspace_swipe(next, direction, progress);
}
} else {
seat.get_pointer().expect("seat has a pointer").gesture_swipe_update(
self,
@@ -199,7 +211,10 @@ impl Ferese {
if self.swipe.active() {
let fingers = self.swipe.fingers();
let cancelled = event.cancelled() || self.swipe_navigation_blocked();
if let Some(direction) = self.swipe.finish(cancelled)
let direction = self.swipe.finish(cancelled);
let handled = self.finish_workspace_swipe(direction) || self.swipe.preview_started();
if !handled
&& let Some(direction) = direction
&& let Some(action) = self
.bindings
.iter()
@@ -721,55 +736,48 @@ impl Ferese {
}
fn switch_relative_workspace(&mut self, next: bool, slide: Option<SwipeDirection>) {
let Some(output) = self.output_workspaces.focused_output() else {
return;
};
let Some(current) = self.output_workspaces.active_workspace(output) else {
return;
};
if let Some(workspace) = self.relative_workspace_target(next) {
if let Some(direction) = slide {
self.activate_managed_workspace_from_swipe(workspace, direction);
} else {
self.activate_managed_workspace(workspace);
}
}
}
pub(crate) fn relative_workspace_target(&mut self, next: bool) -> Option<ferese_core::WorkspaceId> {
let output = self.output_workspaces.focused_output()?;
let current = self.output_workspaces.active_workspace(output)?;
let mut candidates = self.workspaces.ordered();
candidates.retain(|workspace| {
self.output_workspaces
.output_for_workspace(workspace.id)
.is_none_or(|owner| owner == output)
});
let Some(index) = candidates.iter().position(|workspace| workspace.id == current) else {
return;
};
let index = if next {
let index = candidates.iter().position(|workspace| workspace.id == current)?;
let neighbor = if next {
index.checked_add(1)
} else {
index.checked_sub(1)
};
if let Some(workspace) = index
.and_then(|index| candidates.get(index))
.map(|workspace| workspace.id)
{
if let Some(direction) = slide {
self.activate_managed_workspace_from_swipe(workspace, direction);
} else {
self.activate_managed_workspace(workspace);
}
} else if next
if let Some(workspace) = neighbor.and_then(|index| candidates.get(index)) {
return Some(workspace.id);
}
if next
&& self.workspaces.workspace(current).is_some_and(|workspace| {
workspace.layout.window_ids().next().is_some() || !workspace.floating.is_empty()
})
{
let next_number = self
let number = self
.workspaces
.iter()
.filter_map(|workspace| workspace.name.parse::<u32>().ok())
.max()
.unwrap_or(1)
.checked_add(1);
if let Some(number) = next_number {
if let Some(direction) = slide {
self.switch_workspace_from_swipe(number, direction);
} else {
self.switch_workspace(number);
}
}
.checked_add(1)?;
return self.workspaces.ensure_numeric(number).ok();
}
None
}
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) {
+183 -7
View File
@@ -92,6 +92,17 @@ const CLOSE_ANIMATION_DURATION: Duration = Duration::from_millis(140);
const WORKSPACE_SLIDE_DURATION: Duration = Duration::from_millis(220);
const WORKSPACE_SLIDE_FIRST_FRAME: Duration = Duration::from_millis(8);
fn workspace_swipe_is_current(
outputs: &OutputWorkspaceMap,
output: OutputId,
from: WorkspaceId,
to: WorkspaceId,
) -> bool {
outputs.focused_output() == Some(output)
&& outputs.active_workspace(output) == Some(from)
&& outputs.output_for_workspace(to).is_none_or(|owner| owner == output)
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct SlideOffset {
x: f64,
@@ -129,6 +140,8 @@ struct WorkspaceSlideItem {
struct WorkspaceSlide {
items: Vec<WorkspaceSlideItem>,
elapsed: Duration,
held_progress: Option<f64>,
gesture: Option<(WorkspaceId, WorkspaceId, SwipeDirection)>,
}
impl WorkspaceSlide {
@@ -170,11 +183,33 @@ impl WorkspaceSlide {
Self {
items,
elapsed: WORKSPACE_SLIDE_FIRST_FRAME,
held_progress: None,
gesture: None,
}
}
fn progress(&self) -> f64 {
smoothstep((self.elapsed.as_secs_f64() / WORKSPACE_SLIDE_DURATION.as_secs_f64()).min(1.0))
self.held_progress.unwrap_or_else(|| {
smoothstep((self.elapsed.as_secs_f64() / WORKSPACE_SLIDE_DURATION.as_secs_f64()).min(1.0))
})
}
fn release(&mut self, committed: bool) {
let progress = self.progress();
for item in &mut self.items {
let position = item.start.between(item.target, progress);
if !committed && let Some((from, to, direction)) = self.gesture {
if item.workspace == from {
item.target = SlideOffset::default();
} else if item.workspace == to {
item.target = SlideOffset::for_swipe(direction).opposite();
}
}
item.start = position;
}
self.held_progress = None;
self.gesture = None;
self.elapsed = Duration::ZERO;
}
fn advance(&mut self, delta: Duration) -> bool {
@@ -1071,7 +1106,12 @@ impl Ferese {
let Some(workspace) = self.workspaces.workspace_for_window(window) else {
return (0.0, 0.0);
};
let Some(output_id) = self.output_workspaces.output_for_workspace(workspace) else {
let Some(output_id) = self
.workspace_slides
.iter()
.find_map(|(output, slide)| slide.contains(workspace).then_some(*output))
.or_else(|| self.output_workspaces.output_for_workspace(workspace))
else {
return (0.0, 0.0);
};
let Some(slide) = self.workspace_slides.get(&output_id) else {
@@ -1878,6 +1918,9 @@ impl Ferese {
let mut active_animation = false;
let mut completed_slides = Vec::new();
for (output, slide) in &mut self.workspace_slides {
if slide.held_progress.is_some() {
continue;
}
if slide.advance(delta) {
active_animation = true;
} else {
@@ -2790,10 +2833,6 @@ impl Ferese {
}
}
pub(crate) fn switch_workspace_from_swipe(&mut self, index: u32, direction: SwipeDirection) {
self.switch_workspace_internal(index, Some(direction), false);
}
fn switch_workspace_internal(
&mut self,
index: u32,
@@ -2833,6 +2872,70 @@ impl Ferese {
self.restore_keyboard_focus();
}
pub(crate) fn preview_workspace_swipe(&mut self, next: bool, direction: SwipeDirection, progress: f64) {
if !self.animations_enabled || self.overview.is_presenting() {
return;
}
if let Some((output, (from, to, _))) = self
.workspace_slides
.iter()
.find_map(|(output, slide)| slide.gesture.map(|gesture| (*output, gesture)))
{
if !workspace_swipe_is_current(&self.output_workspaces, output, from, to) {
self.finish_workspace_swipe(None);
return;
}
self.workspace_slides
.get_mut(&output)
.expect("gesture output exists")
.held_progress = Some(progress);
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
self.cursor_redraw_pending = true;
return;
}
if self.swipe.preview_started() {
return;
}
let Some(output) = self.output_workspaces.focused_output() else {
return;
};
let Some(from) = self.output_workspaces.active_workspace(output) else {
return;
};
let Some(to) = self.relative_workspace_target(next) else {
return;
};
let previous = self.workspace_slides.remove(&output);
let mut slide = WorkspaceSlide::new(previous, from, to, direction);
slide.held_progress = Some(progress);
slide.gesture = Some((from, to, direction));
self.workspace_slides.insert(output, slide);
self.swipe.mark_preview_started();
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
self.relayout();
}
pub(crate) fn finish_workspace_swipe(&mut self, direction: Option<SwipeDirection>) -> bool {
let Some((output, (from, to, expected))) = self
.workspace_slides
.iter()
.find_map(|(output, slide)| slide.gesture.map(|gesture| (*output, gesture)))
else {
return false;
};
let committed = direction == Some(expected)
&& workspace_swipe_is_current(&self.output_workspaces, output, from, to)
&& self.activate_managed_workspace_from_swipe(to, expected);
if !committed {
if let Some(slide) = self.workspace_slides.get_mut(&output) {
slide.release(false);
}
self.last_animation_tick = Instant::now();
self.relayout();
}
true
}
fn update_workspace_slide(
&mut self,
requested_output: OutputId,
@@ -2841,7 +2944,18 @@ impl Ferese {
workspace: WorkspaceId,
slide_direction: Option<SwipeDirection>,
) {
let previous_slide = self.workspace_slides.remove(&owner);
let mut previous_slide = self.workspace_slides.remove(&owner);
if owner == requested_output
&& let Some(slide) = previous_slide.as_mut()
&& slide
.gesture
.is_some_and(|(_, to, direction)| to == workspace && Some(direction) == slide_direction)
{
slide.release(true);
self.last_animation_tick = Instant::now();
self.workspace_slides.insert(owner, previous_slide.unwrap());
return;
}
if owner == requested_output
&& let (Some(from), Some(direction)) = (previous, slide_direction)
&& from != workspace
@@ -3188,6 +3302,31 @@ impl ClientData for ClientState {
#[cfg(test)]
mod tests {
#[test]
fn workspace_drag_cancels_when_focus_or_target_ownership_changes() {
let mut outputs = OutputWorkspaceMap::default();
let left = OutputId(1);
let right = OutputId(2);
let from = WorkspaceId(1);
let to = WorkspaceId(3);
outputs
.connect(left, OutputGeometry::new(0, 0, 1000, 1000), from)
.unwrap();
outputs
.connect(right, OutputGeometry::new(1000, 0, 1000, 1000), WorkspaceId(2))
.unwrap();
assert!(workspace_swipe_is_current(&outputs, left, from, to));
outputs.focus_output(right).unwrap();
assert!(!workspace_swipe_is_current(&outputs, left, from, to));
outputs.focus_output(left).unwrap();
outputs.assign_workspace(right, to).unwrap();
assert!(!workspace_swipe_is_current(&outputs, left, from, to));
assert_eq!(outputs.active_workspace(left), Some(from));
assert_eq!(outputs.output_for_workspace(to), Some(right));
outputs.switch_workspace(left, WorkspaceId(4)).unwrap();
assert!(!workspace_swipe_is_current(&outputs, left, from, WorkspaceId(5)));
}
#[test]
fn selecting_a_window_activates_its_workspace_on_its_owner() {
for assignment in [None, Some(OutputId(1)), Some(OutputId(2))] {
@@ -3251,6 +3390,43 @@ mod tests {
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
}
#[test]
fn workspace_drag_tracks_progress_and_releases_without_a_jump() {
for committed in [false, true] {
let from = WorkspaceId(1);
let to = WorkspaceId(2);
let mut slide = WorkspaceSlide::new(None, from, to, SwipeDirection::Up);
slide.gesture = Some((from, to, SwipeDirection::Up));
slide.held_progress = Some(0.4);
slide.advance(Duration::from_secs(10));
assert_eq!(slide.offset(from, 1000.0, 1000.0), (0.0, -400.0));
assert_eq!(slide.offset(to, 1000.0, 1000.0), (0.0, 600.0));
slide.release(committed);
assert_eq!(slide.offset(from, 1000.0, 1000.0), (0.0, -400.0));
assert_eq!(slide.offset(to, 1000.0, 1000.0), (0.0, 600.0));
assert!(!slide.advance(WORKSPACE_SLIDE_DURATION));
let active = if committed { to } else { from };
assert_eq!(slide.offset(active, 1000.0, 1000.0), (0.0, 0.0));
}
}
#[test]
fn cancelling_a_chained_drag_returns_to_the_active_workspace() {
let first = WorkspaceId(1);
let second = WorkspaceId(2);
let third = WorkspaceId(3);
let mut previous = WorkspaceSlide::new(None, first, second, SwipeDirection::Up);
previous.advance(WORKSPACE_SLIDE_DURATION / 3);
let mut slide = WorkspaceSlide::new(Some(previous), second, third, SwipeDirection::Up);
slide.gesture = Some((second, third, SwipeDirection::Up));
slide.held_progress = Some(0.25);
let position = slide.offset(second, 1000.0, 1000.0);
slide.release(false);
assert_eq!(slide.offset(second, 1000.0, 1000.0), position);
slide.advance(WORKSPACE_SLIDE_DURATION);
assert_eq!(slide.offset(second, 1000.0, 1000.0), (0.0, 0.0));
}
#[test]
fn workspace_slide_moves_both_workspaces_without_a_gap() {
let width = 1600.0;
+3 -3
View File
@@ -255,11 +255,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.
Three-finger swipes navigate on release: up goes to the next workspace on the
Workspace 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. Cancelled, short and
diagonal swipes do nothing. Navigation pauses while locked, during
by other monitors and stop at the first/last workspace. Short or cancelled workspace
swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during
window grabs, or when an application inhibits shortcuts.
Assign swipes in **Settings → Shortcuts** using the same actions and arguments as
+3 -2
View File
@@ -83,10 +83,11 @@ for dependencies and custom screenshot commands.
| Three-finger swipe | Action on release |
| --- | --- |
| Up / down | Next / previous workspace on this monitor |
| Up / down | Drag to the next / previous workspace on this monitor; release to settle |
| Left / right | Focus the window to the right / left |
Short, diagonal, or cancelled swipes do nothing. Gestures can use the same actions
Short or cancelled workspace 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.