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
+314 -45

No files matched your search

+89 -5
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)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[repr(u32)] #[repr(u32)]
@@ -14,6 +14,8 @@ pub(crate) struct Swipe {
delta: Option<(f64, f64)>, delta: Option<(f64, f64)>,
threshold: f64, threshold: f64,
fingers: u32, fingers: u32,
direction: Option<SwipeDirection>,
preview_started: bool,
} }
impl Swipe { impl Swipe {
@@ -21,6 +23,8 @@ impl Swipe {
self.delta = ((3..=5).contains(&fingers) && !blocked).then_some((0.0, 0.0)); self.delta = ((3..=5).contains(&fingers) && !blocked).then_some((0.0, 0.0));
self.threshold = f64::from(threshold); self.threshold = f64::from(threshold);
self.fingers = fingers; self.fingers = fingers;
self.direction = None;
self.preview_started = false;
} }
pub fn fingers(&self) -> u32 { pub fn fingers(&self) -> u32 {
@@ -35,22 +39,56 @@ impl Swipe {
if let Some((x, y)) = &mut self.delta { if let Some((x, y)) = &mut self.delta {
*x += dx; *x += dx;
*y += dy; *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> { pub fn finish(&mut self, cancelled: bool) -> Option<SwipeDirection> {
let (x, y) = self.delta.take()?; let (x, y) = self.delta.take()?;
if cancelled || !x.is_finite() || !y.is_finite() { if cancelled {
return None;
}
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; return None;
} }
const DOMINANCE: f64 = 1.25; const DOMINANCE: f64 = 1.25;
if x.abs() >= self.threshold && x.abs() > y.abs() * DOMINANCE { if x.abs() >= threshold && x.abs() > y.abs() * DOMINANCE {
Some(if x < 0.0 { Some(if x < 0.0 {
SwipeDirection::Left SwipeDirection::Left
} else { } else {
SwipeDirection::Right SwipeDirection::Right
}) })
} else if y.abs() >= self.threshold && y.abs() > x.abs() * DOMINANCE { } else if y.abs() >= threshold && y.abs() > x.abs() * DOMINANCE {
Some(if y < 0.0 { Some(if y < 0.0 {
SwipeDirection::Up SwipeDirection::Up
} else { } else {
@@ -59,13 +97,59 @@ impl Swipe {
} else { } else {
None None
} }
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn a_swipe_accumulates_motion_and_commits_only_once_on_release() { fn a_swipe_accumulates_motion_and_commits_only_once_on_release() {
let mut swipe = Swipe::default(); let mut swipe = Swipe::default();
+37 -29
View File
@@ -160,6 +160,7 @@ impl Ferese {
match event { match event {
InputEvent::GestureSwipeBegin { event } => { InputEvent::GestureSwipeBegin { event } => {
let pointer = seat.get_pointer().expect("seat has a pointer"); let pointer = seat.get_pointer().expect("seat has a pointer");
self.finish_workspace_swipe(None);
self.swipe.begin( self.swipe.begin(
event.fingers(), event.fingers(),
self.swipe_navigation_blocked() self.swipe_navigation_blocked()
@@ -185,6 +186,17 @@ impl Ferese {
InputEvent::GestureSwipeUpdate { event } => { InputEvent::GestureSwipeUpdate { event } => {
if self.swipe.active() { if self.swipe.active() {
self.swipe.update(event.delta_x(), event.delta_y()); 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 { } else {
seat.get_pointer().expect("seat has a pointer").gesture_swipe_update( seat.get_pointer().expect("seat has a pointer").gesture_swipe_update(
self, self,
@@ -199,7 +211,10 @@ impl Ferese {
if self.swipe.active() { if self.swipe.active() {
let fingers = self.swipe.fingers(); let fingers = self.swipe.fingers();
let cancelled = event.cancelled() || self.swipe_navigation_blocked(); 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 && let Some(action) = self
.bindings .bindings
.iter() .iter()
@@ -721,55 +736,48 @@ impl Ferese {
} }
fn switch_relative_workspace(&mut self, next: bool, slide: Option<SwipeDirection>) { fn switch_relative_workspace(&mut self, next: bool, slide: Option<SwipeDirection>) {
let Some(output) = self.output_workspaces.focused_output() else { if let Some(workspace) = self.relative_workspace_target(next) {
return; if let Some(direction) = slide {
}; self.activate_managed_workspace_from_swipe(workspace, direction);
let Some(current) = self.output_workspaces.active_workspace(output) else { } else {
return; 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(); let mut candidates = self.workspaces.ordered();
candidates.retain(|workspace| { candidates.retain(|workspace| {
self.output_workspaces self.output_workspaces
.output_for_workspace(workspace.id) .output_for_workspace(workspace.id)
.is_none_or(|owner| owner == output) .is_none_or(|owner| owner == output)
}); });
let Some(index) = candidates.iter().position(|workspace| workspace.id == current) else { let index = candidates.iter().position(|workspace| workspace.id == current)?;
return; let neighbor = if next {
};
let index = if next {
index.checked_add(1) index.checked_add(1)
} else { } else {
index.checked_sub(1) index.checked_sub(1)
}; };
if let Some(workspace) = index if let Some(workspace) = neighbor.and_then(|index| candidates.get(index)) {
.and_then(|index| candidates.get(index)) return Some(workspace.id);
.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 next
&& self.workspaces.workspace(current).is_some_and(|workspace| { && self.workspaces.workspace(current).is_some_and(|workspace| {
workspace.layout.window_ids().next().is_some() || !workspace.floating.is_empty() workspace.layout.window_ids().next().is_some() || !workspace.floating.is_empty()
}) })
{ {
let next_number = self let number = self
.workspaces .workspaces
.iter() .iter()
.filter_map(|workspace| workspace.name.parse::<u32>().ok()) .filter_map(|workspace| workspace.name.parse::<u32>().ok())
.max() .max()
.unwrap_or(1) .unwrap_or(1)
.checked_add(1); .checked_add(1)?;
if let Some(number) = next_number { return self.workspaces.ensure_numeric(number).ok();
if let Some(direction) = slide {
self.switch_workspace_from_swipe(number, direction);
} else {
self.switch_workspace(number);
}
}
} }
None
} }
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) { fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) {
+182 -6
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_DURATION: Duration = Duration::from_millis(220);
const WORKSPACE_SLIDE_FIRST_FRAME: Duration = Duration::from_millis(8); 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)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
struct SlideOffset { struct SlideOffset {
x: f64, x: f64,
@@ -129,6 +140,8 @@ struct WorkspaceSlideItem {
struct WorkspaceSlide { struct WorkspaceSlide {
items: Vec<WorkspaceSlideItem>, items: Vec<WorkspaceSlideItem>,
elapsed: Duration, elapsed: Duration,
held_progress: Option<f64>,
gesture: Option<(WorkspaceId, WorkspaceId, SwipeDirection)>,
} }
impl WorkspaceSlide { impl WorkspaceSlide {
@@ -170,11 +183,33 @@ impl WorkspaceSlide {
Self { Self {
items, items,
elapsed: WORKSPACE_SLIDE_FIRST_FRAME, elapsed: WORKSPACE_SLIDE_FIRST_FRAME,
held_progress: None,
gesture: None,
} }
} }
fn progress(&self) -> f64 { fn progress(&self) -> f64 {
self.held_progress.unwrap_or_else(|| {
smoothstep((self.elapsed.as_secs_f64() / WORKSPACE_SLIDE_DURATION.as_secs_f64()).min(1.0)) 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 { fn advance(&mut self, delta: Duration) -> bool {
@@ -1071,7 +1106,12 @@ impl Ferese {
let Some(workspace) = self.workspaces.workspace_for_window(window) else { let Some(workspace) = self.workspaces.workspace_for_window(window) else {
return (0.0, 0.0); 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); return (0.0, 0.0);
}; };
let Some(slide) = self.workspace_slides.get(&output_id) else { let Some(slide) = self.workspace_slides.get(&output_id) else {
@@ -1878,6 +1918,9 @@ impl Ferese {
let mut active_animation = false; let mut active_animation = false;
let mut completed_slides = Vec::new(); let mut completed_slides = Vec::new();
for (output, slide) in &mut self.workspace_slides { for (output, slide) in &mut self.workspace_slides {
if slide.held_progress.is_some() {
continue;
}
if slide.advance(delta) { if slide.advance(delta) {
active_animation = true; active_animation = true;
} else { } 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( fn switch_workspace_internal(
&mut self, &mut self,
index: u32, index: u32,
@@ -2833,6 +2872,70 @@ impl Ferese {
self.restore_keyboard_focus(); 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( fn update_workspace_slide(
&mut self, &mut self,
requested_output: OutputId, requested_output: OutputId,
@@ -2841,7 +2944,18 @@ impl Ferese {
workspace: WorkspaceId, workspace: WorkspaceId,
slide_direction: Option<SwipeDirection>, 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 if owner == requested_output
&& let (Some(from), Some(direction)) = (previous, slide_direction) && let (Some(from), Some(direction)) = (previous, slide_direction)
&& from != workspace && from != workspace
@@ -3188,6 +3302,31 @@ impl ClientData for ClientState {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn selecting_a_window_activates_its_workspace_on_its_owner() { fn selecting_a_window_activates_its_workspace_on_its_owner() {
for assignment in [None, Some(OutputId(1)), Some(OutputId(2))] { 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))); 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] #[test]
fn workspace_slide_moves_both_workspaces_without_a_gap() { fn workspace_slide_moves_both_workspaces_without_a_gap() {
let width = 1600.0; 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 `natural-scroll` and `disable-while-typing`. These apply to DRM devices; nested
previews use the host's physical input settings. 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, 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 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 by other monitors and stop at the first/last workspace. Short or cancelled workspace
diagonal swipes do nothing. Navigation pauses while locked, during swipes slide back; diagonal swipes do not navigate. Navigation pauses while locked, during
window grabs, or when an application inhibits shortcuts. window grabs, or when an application inhibits shortcuts.
Assign swipes in **Settings → Shortcuts** using the same actions and arguments as 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 | | 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 | | 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 as keyboard bindings, including overview or moving a window. Four- and five-finger
bindings are supported too. bindings are supported too.