From 464a1a635bccc1f27217d662bab264b2125626ae Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 10:28:14 +0100 Subject: [PATCH] Add continuous touchpad workspace swiping --- compositor/src/gestures.rs | 118 +++++++++++++++++++---- compositor/src/input.rs | 68 +++++++------ compositor/src/state.rs | 190 +++++++++++++++++++++++++++++++++++-- docs/configuration.md | 6 +- docs/shortcuts.md | 5 +- 5 files changed, 328 insertions(+), 59 deletions(-) diff --git a/compositor/src/gestures.rs b/compositor/src/gestures.rs index 80effab..daf686e 100644 --- a/compositor/src/gestures.rs +++ b/compositor/src/gestures.rs @@ -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, + 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 { 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 { + 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(); diff --git a/compositor/src/input.rs b/compositor/src/input.rs index c65dafa..d37af9e 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -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) { - 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 { + 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::().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, serial: Serial, raise: bool) { diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 797054f..f9aa344 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -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, elapsed: Duration, + held_progress: Option, + 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) -> 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, ) { - 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; diff --git a/docs/configuration.md b/docs/configuration.md index c414a7d..333c568 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/shortcuts.md b/docs/shortcuts.md index a236d00..f35f14f 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -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.