From dbdfc5688e9f137a3f7fefebe88eddda8947d123 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 27 Sep 2026 05:21:52 +0100 Subject: [PATCH] Fix screenshots and workspace switching; add configurable swipe bindings --- README.md | 19 ++- compositor/src/config.rs | 171 +++++++++++++++++++++++++- compositor/src/gestures.rs | 134 ++++++++++++++++++++ compositor/src/handlers/screencopy.rs | 20 ++- compositor/src/input.rs | 133 +++++++++++++++++++- compositor/src/main.rs | 1 + compositor/src/state.rs | 29 ++--- docs/configuration.md | 57 ++++++++- docs/installation.md | 2 +- packaging/config.toml | 11 ++ packaging/ferese-screenshot | 50 ++++++-- shell/ferese-settings/src/main.rs | 43 ++++++- shell/ferese-settings/src/schema.rs | 11 ++ shell/ferese-shell/src/main.rs | 127 +++++++++++++------ 14 files changed, 707 insertions(+), 101 deletions(-) create mode 100644 compositor/src/gestures.rs diff --git a/README.md b/README.md index c5be601..dbab1d9 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ and native Settings app. - Scrolling columns, tree tiling and floating windows - Workspace overview +- Configurable three-finger workspace and window navigation - Configurable animations - Built-in bar and control center - Native Settings app - Desktop widgets -- Area screenshots saved to disk and copied to the clipboard +- Area and full-screen screenshots with Satty annotation - Themes, blur and gradient borders - Multi-monitor support and fractional scaling - Live configuration reload @@ -51,7 +52,7 @@ Requirements (Fedora package names): - Default terminal: `foot` (or configure another terminal). - Optional: `zenity` for wallpaper browsing, `swayidle` for idle actions, and `swaylock` or `swaylock-effects` for locking. -- Screenshots: `grim`, `slurp`, and `wl-clipboard`. +- Screenshots: `grim`, `slurp`, `satty`, and `wl-clipboard`. For a login-screen session alongside your existing desktop: @@ -69,6 +70,13 @@ session logs and rollback. Built-in defaults (your configuration can override them): +The bar shows workspaces 1–9 in a compact numbered strip, including empty ones. +Click a number to switch using the same behavior as Super+1–9. A filled button marks this monitor's +active workspace; an outlined button marks one active on another monitor. +Three-finger swipes up/down navigate workspaces on this monitor; left/right +navigate windows. Assign swipes to any keyboard-binding action (including +overview, move, or a launcher command) in **Settings → Shortcuts**. + | Shortcut | Action | | --- | --- | | Super + Enter | Open a terminal | @@ -80,10 +88,13 @@ Built-in defaults (your configuration can override them): | Super + Tab | Open workspace and window overview | | Super + M | Switch scrolling / tree layout | | Super + Shift + S | Select a screenshot area | +| Print Screen / Fn + PrtSc | Capture the whole screen | | Super + Shift + E | Log out immediately—save your work first | -In an installed session, screenshots are saved to `Pictures/Screenshots` and -copied to the clipboard. Press Escape to cancel the selection. +Both screenshot shortcuts open Satty for annotation. Press Enter in Satty to +save to `Pictures/Screenshots` and copy the edited image to the clipboard; +Escape discards it. Escape also cancels an area selection. +Run `ferese-screenshot --full` to capture the whole screen from a terminal. When focus follows the mouse is enabled, hovering changes keyboard focus without raising the window; clicking raises it within its layer. diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 7084c8d..81d0a00 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -440,6 +440,10 @@ pub struct Binding { enum BindingTrigger { Keysym(u32), Physical(Keycode), + Swipe { + fingers: u32, + direction: crate::gestures::SwipeDirection, + }, } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] @@ -452,6 +456,7 @@ struct BindingModifiers { #[derive(Clone, Debug, PartialEq)] pub enum BindingAction { + None, Spawn(Vec), Close, Exit, @@ -459,6 +464,7 @@ pub enum BindingAction { Move(ferese_layout::Direction), Resize(ferese_layout::Direction), SwitchWorkspace(u8), + SwitchRelativeWorkspace(bool), MoveToWorkspace(u8), ToggleFullscreen, ToggleMaximized, @@ -491,8 +497,21 @@ impl Binding { && match self.trigger { BindingTrigger::Keysym(expected) => keysyms.contains(&expected), BindingTrigger::Physical(expected) => expected == keycode, + BindingTrigger::Swipe { .. } => false, } } + + pub(crate) fn swipe_fingers(&self, count: u32) -> bool { + matches!(self.trigger, BindingTrigger::Swipe { fingers, .. } if fingers == count) + } + + pub(crate) fn matches_swipe( + &self, + count: u32, + target: crate::gestures::SwipeDirection, + ) -> bool { + matches!(self.trigger, BindingTrigger::Swipe { fingers, direction } if fingers == count && direction == target) + } } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)] @@ -501,6 +520,7 @@ enum BindingMatch { #[default] Keysym, Physical, + Swipe, } #[derive(Clone, Debug, Deserialize)] @@ -530,6 +550,11 @@ pub struct TouchpadSettings { pub tap: bool, pub natural_scroll: bool, pub disable_while_typing: bool, + pub swipe_threshold: u16, +} + +fn default_swipe_threshold() -> u16 { + 80 } #[derive(Debug, Deserialize)] @@ -572,6 +597,8 @@ struct TouchpadConfig { natural_scroll: bool, #[serde(default = "enabled_by_default")] disable_while_typing: bool, + #[serde(default = "default_swipe_threshold")] + swipe_threshold: u16, } impl Default for TouchpadConfig { @@ -580,6 +607,7 @@ impl Default for TouchpadConfig { tap: true, natural_scroll: true, disable_while_typing: true, + swipe_threshold: default_swipe_threshold(), } } } @@ -879,6 +907,12 @@ impl Config { }); } + if !(16..=1000).contains(&self.input.touchpad.swipe_threshold) { + return Err(ConfigError::InvalidInputValue { + field: "touchpad.swipe_threshold", + value: self.input.touchpad.swipe_threshold.to_string(), + }); + } Ok(InputSettings { focus_follows_mouse: self.input.focus_follows_mouse, xkb_layout: self.input.xkb_layout.clone(), @@ -890,6 +924,7 @@ impl Config { tap: self.input.touchpad.tap, natural_scroll: self.input.touchpad.natural_scroll, disable_while_typing: self.input.touchpad.disable_while_typing, + swipe_threshold: self.input.touchpad.swipe_threshold, }, }) } @@ -901,6 +936,10 @@ impl Config { "screenshot".to_owned(), vec!["ferese-screenshot".to_owned()], ), + ( + "screenshot-full".to_owned(), + vec!["ferese-screenshot".to_owned(), "--full".to_owned()], + ), ]); commands.extend(self.commands.clone()); validate_commands(&commands)?; @@ -1184,6 +1223,15 @@ fn parse_binding( let trigger = match identity.match_mode { BindingMatch::Keysym => BindingTrigger::Keysym(identity.key), BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)), + BindingMatch::Swipe => BindingTrigger::Swipe { + fingers: identity.key / 4, + direction: [ + crate::gestures::SwipeDirection::Up, + crate::gestures::SwipeDirection::Down, + crate::gestures::SwipeDirection::Left, + crate::gestures::SwipeDirection::Right, + ][(identity.key % 4) as usize], + }, }; Ok(Binding { @@ -1198,6 +1246,20 @@ fn binding_identity( keymap: &xkb::Keymap, ) -> Result { let (modifiers, key) = parse_chord(&configured.keys)?; + if let Some((fingers, direction)) = parse_swipe(&key) { + if configured.match_mode == BindingMatch::Physical + || modifiers != BindingModifiers::default() + { + return Err(ConfigError::InvalidBinding( + "swipes cannot use keyboard modifiers or physical key matching".into(), + )); + } + return Ok(BindingIdentity { + modifiers, + match_mode: BindingMatch::Swipe, + key: fingers * 4 + direction as u32, + }); + } let key = match configured.match_mode { BindingMatch::Keysym => parse_keysym(&key)?, BindingMatch::Physical => keymap @@ -1209,6 +1271,11 @@ fn binding_identity( configured.keys )) })?, + BindingMatch::Swipe => { + return Err(ConfigError::InvalidBinding( + "gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(), + )); + } }; Ok(BindingIdentity { @@ -1222,6 +1289,9 @@ fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity { let (match_mode, key) = match binding.trigger { BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key), BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()), + BindingTrigger::Swipe { fingers, direction } => { + (BindingMatch::Swipe, fingers * 4 + direction as u32) + } }; BindingIdentity { @@ -1269,6 +1339,24 @@ fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> { Ok((modifiers, key)) } +fn parse_swipe(key: &str) -> Option<(u32, crate::gestures::SwipeDirection)> { + use crate::gestures::SwipeDirection; + let lower = key.to_ascii_lowercase(); + let rest = lower.strip_prefix("swipe")?; + let fingers = rest.get(..1)?.parse::().ok()?; + if !(3..=5).contains(&fingers) { + return None; + } + let direction = match rest.get(1..)? { + "up" => SwipeDirection::Up, + "down" => SwipeDirection::Down, + "left" => SwipeDirection::Left, + "right" => SwipeDirection::Right, + _ => return None, + }; + Some((fingers, direction)) +} + fn parse_keysym(name: &str) -> Result { let normalized = match name { "Enter" | "enter" => "Return".to_owned(), @@ -1312,6 +1400,16 @@ fn parse_action( }; match action { + "none" => { + no_argument()?; + Ok(BindingAction::None) + } + "workspace-next" | "workspace-previous" => { + no_argument()?; + Ok(BindingAction::SwitchRelativeWorkspace( + action == "workspace-next", + )) + } "spawn" => { let command = required_argument()?; let argv = commands.get(command).ok_or_else(|| { @@ -1405,6 +1503,11 @@ fn default_bindings() -> Vec { let mut bindings = vec![ binding("Super+Enter", "spawn", Some("terminal")), binding("Super+Shift+S", "spawn", Some("screenshot")), + binding("Print", "spawn", Some("screenshot-full")), + binding("Swipe3Up", "workspace-next", None), + binding("Swipe3Down", "workspace-previous", None), + binding("Swipe3Left", "focus", Some("right")), + binding("Swipe3Right", "focus", Some("left")), binding("Super+Q", "close", None), binding("Super+F", "toggle-maximized", None), binding("Super+Shift+F", "toggle-fullscreen", None), @@ -2054,6 +2157,7 @@ mod tests { tap: false, natural_scroll: false, disable_while_typing: true, + swipe_threshold: 80, }, } ); @@ -2070,13 +2174,65 @@ mod tests { assert!(delay.input_settings().is_err()); } + #[test] + fn swipe_bindings_can_override_actions_disable_defaults_and_set_distance() { + use crate::gestures::SwipeDirection; + let config = parse( + "[input.touchpad]\nswipe_threshold = 120\n[[bindings]]\nkeys = 'Swipe3Up'\naction = 'toggle-overview'\n[[bindings]]\nkeys = 'Swipe3Left'\naction = 'move'\nargument = 'left'\n[[bindings]]\nkeys = 'Swipe3Down'\ndisabled = true", + ); + let input = config.input_settings().unwrap(); + assert_eq!(input.touchpad.swipe_threshold, 120); + let bindings = config.bindings(&input).unwrap(); + assert!( + bindings + .iter() + .any(|binding| binding.matches_swipe(3, SwipeDirection::Up) + && binding.action == BindingAction::ToggleOverview) + ); + assert!( + bindings + .iter() + .any(|binding| binding.matches_swipe(3, SwipeDirection::Left) + && binding.action == BindingAction::Move(ferese_layout::Direction::Left)) + ); + assert!( + !bindings + .iter() + .any(|binding| binding.matches_swipe(3, SwipeDirection::Down)) + ); + for threshold in [0, 15, 1001] { + assert!( + parse(&format!("[input.touchpad]\nswipe_threshold = {threshold}")) + .input_settings() + .is_err() + ); + } + } + + #[test] + fn gesture_bindings_validate_fingers_directions_and_actions() { + for keys in ["Swipe2Up", "Swipe3Diagonal", "Super+Swipe3Up"] { + let config = parse(&format!( + "[[bindings]]\nkeys = '{keys}'\naction = 'toggle-overview'" + )); + assert!(config.bindings(&config.input_settings().unwrap()).is_err()); + } + let config = parse("[[bindings]]\nkeys = 'Swipe4Up'\naction = 'toggle-overview'"); + let bindings = config.bindings(&config.input_settings().unwrap()).unwrap(); + assert!( + bindings + .iter() + .any(|binding| binding.matches_swipe(4, crate::gestures::SwipeDirection::Up)) + ); + } + #[test] fn supplies_complete_v0_bindings_and_terminal_command() { let config = parse(""); let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 43); + assert_eq!(bindings.len(), 48); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -2102,6 +2258,15 @@ mod tests { && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_s) && binding.action == BindingAction::Spawn(vec!["ferese-screenshot".to_owned()]) })); + assert!(bindings.iter().any(|binding| { + binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Print) + && binding.modifiers == BindingModifiers::default() + && binding.action + == BindingAction::Spawn(vec![ + "ferese-screenshot".to_owned(), + "--full".to_owned(), + ]) + })); assert!(bindings.iter().any(|binding| { binding.modifiers.logo && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Tab) @@ -2118,7 +2283,7 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 43); + assert_eq!(bindings.len(), 48); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec![ @@ -2130,7 +2295,7 @@ mod tests { let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 42); + assert_eq!(bindings.len(), 47); assert!( !bindings .iter() diff --git a/compositor/src/gestures.rs b/compositor/src/gestures.rs new file mode 100644 index 0000000..80effab --- /dev/null +++ b/compositor/src/gestures.rs @@ -0,0 +1,134 @@ +//! A swipe commits once on release; cancelled and ambiguous gestures do nothing. + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[repr(u32)] +pub(crate) enum SwipeDirection { + Up, + Down, + Left, + Right, +} + +#[derive(Default)] +pub(crate) struct Swipe { + delta: Option<(f64, f64)>, + threshold: f64, + fingers: u32, +} + +impl Swipe { + pub fn begin(&mut self, fingers: u32, blocked: bool, threshold: u16) { + self.delta = ((3..=5).contains(&fingers) && !blocked).then_some((0.0, 0.0)); + self.threshold = f64::from(threshold); + self.fingers = fingers; + } + + pub fn fingers(&self) -> u32 { + self.fingers + } + + pub fn active(&self) -> bool { + self.delta.is_some() + } + + pub fn update(&mut self, dx: f64, dy: f64) { + if let Some((x, y)) = &mut self.delta { + *x += dx; + *y += dy; + } + } + + pub fn finish(&mut self, cancelled: bool) -> Option { + let (x, y) = self.delta.take()?; + if cancelled || !x.is_finite() || !y.is_finite() { + 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 + }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_swipe_accumulates_motion_and_commits_only_once_on_release() { + let mut swipe = Swipe::default(); + swipe.begin(3, false, 80); + for _ in 0..10 { + swipe.update(-10.0, 1.0); + } + assert!(swipe.active()); + assert_eq!(swipe.finish(false), Some(SwipeDirection::Left)); + assert!(!swipe.active()); + assert_eq!(swipe.finish(false), None); + } + + #[test] + fn vertical_and_horizontal_swipes_are_distinct() { + for (delta, expected) in [ + ((0.0, -100.0), SwipeDirection::Up), + ((0.0, 100.0), SwipeDirection::Down), + ((-100.0, 0.0), SwipeDirection::Left), + ((100.0, 0.0), SwipeDirection::Right), + ] { + let mut swipe = Swipe::default(); + swipe.begin(3, false, 80); + swipe.update(delta.0, delta.1); + assert_eq!(swipe.finish(false), Some(expected)); + } + } + + #[test] + fn cancellation_jitter_diagonals_and_nonfinite_motion_do_not_navigate() { + for (x, y, cancelled) in [ + (200.0, 0.0, true), + (79.0, 0.0, false), + (100.0, 100.0, false), + (f64::NAN, 100.0, false), + ] { + let mut swipe = Swipe::default(); + swipe.begin(3, false, 80); + swipe.update(x, y); + assert_eq!(swipe.finish(cancelled), None); + assert!(!swipe.active()); + } + } + + #[test] + fn other_finger_counts_and_blocked_gestures_are_not_intercepted() { + for (fingers, blocked) in [(2, false), (6, false), (3, true)] { + let mut swipe = Swipe::default(); + swipe.begin(fingers, blocked, 80); + assert!(!swipe.active()); + swipe.update(200.0, 0.0); + assert_eq!(swipe.finish(false), None); + } + } + + #[test] + fn configured_distance_changes_when_a_swipe_commits() { + let mut swipe = Swipe::default(); + swipe.begin(3, false, 120); + swipe.update(100.0, 0.0); + assert_eq!(swipe.finish(false), None); + swipe.begin(3, false, 40); + swipe.update(100.0, 0.0); + assert_eq!(swipe.finish(false), Some(SwipeDirection::Right)); + } +} diff --git a/compositor/src/handlers/screencopy.rs b/compositor/src/handlers/screencopy.rs index dd52bb8..91a50e7 100644 --- a/compositor/src/handlers/screencopy.rs +++ b/compositor/src/handlers/screencopy.rs @@ -1,10 +1,7 @@ use std::sync::{Arc, Mutex}; use smithay::{ - backend::{ - allocator::Fourcc, - renderer::{ExportMem, TextureMapping}, - }, + backend::{allocator::Fourcc, renderer::ExportMem}, output::Output, reexports::{ wayland_protocols_wlr::screencopy::v1::server::{ @@ -355,8 +352,8 @@ where return false; } - // Preserve the renderer's row order and report it through the protocol - // instead of making another full-frame copy on the compositor thread. + // Smithay's output projection already accounts for OpenGL's Y axis. + // These rows are in output-buffer order, including its output transform. unsafe { std::ptr::copy_nonoverlapping( source.as_ptr(), @@ -377,12 +374,11 @@ where .frame .damage(0, 0, expected.w as u32, expected.h as u32); } - let flags = if mapping.flipped() { - zwlr_screencopy_frame_v1::Flags::YInvert - } else { - zwlr_screencopy_frame_v1::Flags::empty() - }; - capture.frame.flags(flags); + // TextureMapping::flipped describes importing the mapping as a texture; + // using it here would make clients flip the already-correct output rows. + capture + .frame + .flags(zwlr_screencopy_frame_v1::Flags::empty()); let seconds = timestamp.as_secs(); capture.frame.ready( diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 32a6a49..f1170e8 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -3,13 +3,17 @@ use std::process::Command; use smithay::{ backend::input::{ - AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent, - KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, - Switch, SwitchState, SwitchToggleEvent, TouchEvent, + AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent, + GestureEndEvent, GestureSwipeUpdateEvent as _, InputBackend, InputEvent, KeyState, + KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, Switch, + SwitchState, SwitchToggleEvent, TouchEvent, }, input::{ keyboard::{FilterResult, keysyms}, - pointer::{AxisFrame, ButtonEvent, MotionEvent, PointerHandle, RelativeMotionEvent}, + pointer::{ + AxisFrame, ButtonEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent, + GestureSwipeUpdateEvent, MotionEvent, PointerHandle, RelativeMotionEvent, + }, touch::{DownEvent, MotionEvent as TouchMotionEvent, UpEvent}, }, reexports::wayland_server::protocol::wl_surface::WlSurface, @@ -43,6 +47,71 @@ impl Ferese { } match event { + InputEvent::GestureSwipeBegin { event } => { + let pointer = seat.get_pointer().expect("seat has a pointer"); + self.swipe.begin( + event.fingers(), + self.swipe_navigation_blocked() + || !self + .bindings + .iter() + .any(|binding| binding.swipe_fingers(event.fingers())), + self.input_settings.touchpad.swipe_threshold, + ); + if self.swipe.active() { + self.focus_output_at(pointer.current_location()); + } else { + pointer.gesture_swipe_begin( + self, + &GestureSwipeBeginEvent { + serial: SERIAL_COUNTER.next_serial(), + time: event.time() as u32, + fingers: event.fingers(), + }, + ); + } + } + InputEvent::GestureSwipeUpdate { event } => { + if self.swipe.active() { + self.swipe.update(event.delta_x(), event.delta_y()); + } else { + seat.get_pointer() + .expect("seat has a pointer") + .gesture_swipe_update( + self, + &GestureSwipeUpdateEvent { + time: event.time() as u32, + delta: event.delta(), + }, + ); + } + } + InputEvent::GestureSwipeEnd { event } => { + 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 Some(action) = self + .bindings + .iter() + .find(|binding| binding.matches_swipe(fingers, direction)) + .map(|binding| binding.action.clone()) + { + self.execute_binding(action); + } + } else { + seat.get_pointer() + .expect("seat has a pointer") + .gesture_swipe_end( + self, + &GestureSwipeEndEvent { + serial: SERIAL_COUNTER.next_serial(), + time: event.time() as u32, + cancelled: event.cancelled(), + }, + ); + } + } InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => { crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On); } @@ -387,6 +456,60 @@ impl Ferese { Some(event.position_transformed(geometry.size) + geometry.loc.to_f64()) } + fn swipe_navigation_blocked(&self) -> bool { + self.session_lock.active + || self.active_shortcuts_inhibitor.is_some() + || self + .seat + .get_pointer() + .is_some_and(|pointer| pointer.is_grabbed()) + || self + .seat + .get_keyboard() + .is_some_and(|keyboard| keyboard.is_grabbed()) + } + + fn switch_relative_workspace(&mut self, next: bool) { + let Some(output) = self.output_workspaces.focused_output() else { + return; + }; + let Some(current) = self.output_workspaces.active_workspace(output) else { + return; + }; + let mut candidates = self + .workspaces + .iter() + .filter(|workspace| { + self.output_workspaces + .output_for_workspace(workspace.id) + .is_none_or(|owner| owner == output) + }) + .collect::>(); + candidates.sort_by_key(|workspace| { + ( + workspace.name.parse::().unwrap_or(u32::MAX), + workspace.id.0, + ) + }); + let Some(index) = candidates + .iter() + .position(|workspace| workspace.id == current) + else { + return; + }; + let index = 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) + { + self.activate_managed_workspace(workspace); + } + } + fn focus_window_at(&mut self, position: Point, serial: Serial, raise: bool) { if self.session_lock.active { self.focus_output_at(position); @@ -562,6 +685,7 @@ impl Ferese { fn execute_binding(&mut self, action: BindingAction) { match action { + BindingAction::None => {} BindingAction::Spawn(mut argv) => { let program = argv.remove(0); @@ -584,6 +708,7 @@ impl Ferese { BindingAction::Focus(direction) => self.focus_direction(direction), BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction), + BindingAction::SwitchRelativeWorkspace(next) => self.switch_relative_workspace(next), BindingAction::SwitchWorkspace(workspace) => { self.switch_workspace(u32::from(workspace)); } diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 691a5d9..b92723b 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -6,6 +6,7 @@ mod cursor; mod daemon; mod dimming; mod effects; +mod gestures; mod grabs; mod handlers; mod input; diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 1acb709..92839bb 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -167,6 +167,7 @@ pub struct Ferese { pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) named_cursors: HashMap, pub intercepted_keys: HashSet, + pub(crate) swipe: crate::gestures::Swipe, pub idle_inhibitors: HashMap, pub active_shortcuts_inhibitor: Option, pub direct_backend: Option, @@ -640,6 +641,7 @@ impl Ferese { cursor_theme, named_cursors, intercepted_keys: HashSet::new(), + swipe: crate::gestures::Swipe::default(), idle_inhibitors: HashMap::new(), active_shortcuts_inhibitor: None, direct_backend: None, @@ -682,6 +684,11 @@ impl Ferese { xdg_shell_state, xdg_toplevel_icon_manager, }; + // Keep the numbered shortcuts and bar selectors available from startup, + // including empty workspaces that have not been assigned to an output. + for index in 1..=9 { + state.workspaces.ensure_numeric(index)?; + } state._ipc_socket = Some(crate::ipc::init(event_loop)?); Ok(state) } @@ -2364,25 +2371,19 @@ impl Ferese { let output = self .output_workspaces - .output_for_workspace(workspace) - .or_else(|| self.output_workspaces.focused_output()); + .focused_output() + .or_else(|| self.output_workspaces.output_for_workspace(workspace)); let Some(output) = output else { return false; }; - if self - .output_workspaces - .output_for_workspace(workspace) - .is_none() - && self - .output_workspaces - .assign_workspace(output, workspace) - .is_err() - { - return false; - } + let owner = match self.output_workspaces.switch_workspace(output, workspace) { + Ok(ferese_core::WorkspaceSwitch::Activated(output)) + | Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output, + Err(_) => return false, + }; - self.activate_output_workspace(output, workspace); + self.activate_output_workspace(owner, workspace); self.relayout(); self.restore_keyboard_focus(); true diff --git a/docs/configuration.md b/docs/configuration.md index 9d7ae19..433f748 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -134,10 +134,47 @@ angle = 135.0 | `repeat_rate` | integer > 0 | `25` | Repeats per second | | `repeat_delay_ms` | integer ≥ 0 | `600` | Delay before repeat | -`[input.touchpad]` has three boolean keys, all defaulting to `true`: `tap`, +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 +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 +window grabs, or when an application inhibits shortcuts. + +Assign swipes in **Settings → Shortcuts** using the same actions and arguments as +keyboard bindings. Choose a "Customize swipe" button to add an override. The +gesture keys are `Swipe3Up`, `Swipe3Down`, `Swipe3Left`, and `Swipe3Right`; 4 or 5 +fingers are also supported. Changes reload live: + +```toml +[[bindings]] +keys = "Swipe3Up" +action = "toggle-overview" + +[[bindings]] +keys = "Swipe3Left" +action = "move" +argument = "left" + +[[bindings]] +keys = "Swipe3Down" +disabled = true +``` + +`spawn` uses a named entry from `[commands]`, just like keyboard shortcuts. To +disable a direction in Settings, set its action to `none` and leave Argument +empty. In TOML, `disabled = true` removes the default binding. Swipes do not use +keyboard modifiers or physical key matching. The trigger is automatically +recognized from its name. Gesture navigation uses native touchpad events in a +hardware session. + +Adjust recognition distance in **Settings → Keyboard & mouse**, or with +`swipe_threshold = 80` under `[input.touchpad]` (integer 16–1000 logical pixels). + ## Commands and bindings `[commands]` maps arbitrary names to nonempty argument arrays. The built-in @@ -145,7 +182,7 @@ previews use the host's physical input settings. | `[[bindings]]` key | Type / values | Default | | --- | --- | --- | -| `keys` | chord string, e.g. `"Super+Enter"` | required | +| `keys` | chord or gesture, e.g. `"Super+Enter"`, `"Swipe3Up"` | required | | `match` | `"keysym"`, `"physical"` | `"keysym"` | | `action` | action name below | required unless disabled | | `argument` | string | required only for actions listed below | @@ -161,6 +198,8 @@ invalid. A disabled binding must omit `action` and `argument`. | `spawn` | Name in `[commands]` | | `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 | +| `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | +| `none` | None; ignore this trigger | | `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None | ```toml @@ -178,13 +217,19 @@ Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace; Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center; Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; Super+M layout; Super+Shift+Space floating; Super+Tab overview; -Super+Shift+S area screenshot; Super+Shift+E immediate logout. +Super+Shift+S area screenshot; Print Screen whole-screen screenshot; +Super+Shift+E immediate logout. The screenshot shortcut runs `ferese-screenshot`: drag to select an area, or -press Escape to cancel. Captures are saved under your Pictures directory in -`Screenshots` and copied to the clipboard as PNG images. It requires `grim`, -`slurp`, and `wl-copy` (from `wl-clipboard`). Override the `screenshot` command +press Escape to cancel. Captures open in Satty for annotation. Press Enter to +save the edited PNG under your Pictures directory in `Screenshots` and copy it +to the clipboard; Escape discards the capture. It requires `grim`, `slurp`, +`satty`, and `wl-copy` (from `wl-clipboard`). Override the `screenshot` command to use another screenshot tool. +Print Screen runs `ferese-screenshot --full` and captures all enabled outputs +without a selector. Fn+PrtSc works when the keyboard emits the Print Screen key; +Fn is handled by the keyboard firmware. Override `screenshot-full` to customize +this command. Both modes open Satty with the same save and copy workflow. ## Window rules diff --git a/docs/installation.md b/docs/installation.md index df0a08c..83ca220 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,7 +5,7 @@ On Fedora with SDDM, install a Rust toolchain (1.93 or newer), native build dependencies, `desktop-file-utils`, D-Bus and `foot`. Wallpaper browsing also needs `zenity`; locking needs swaylock or swaylock-effects. -Area screenshots need `grim`, `slurp`, and `wl-clipboard`. +Screenshots need `grim`, `slurp`, `satty`, and `wl-clipboard`. From the checkout, run as your normal user: diff --git a/packaging/config.toml b/packaging/config.toml index 073a5b4..6c4c642 100644 --- a/packaging/config.toml +++ b/packaging/config.toml @@ -3,11 +3,15 @@ [commands] terminal = ["foot"] screenshot = ["ferese-screenshot"] +screenshot-full = ["ferese-screenshot", "--full"] [input] xkb_layout = "us" focus_follows_mouse = false +[input.touchpad] +swipe_threshold = 80 + [layout] smart_gaps = false @@ -38,3 +42,10 @@ margin_y = 80 time_size = 72.0 date_size = 18.0 opacity = 0.9 + +# Swipe bindings use the same actions and arguments as keyboard bindings. +# Defaults: up/down switch workspaces; left/right focus windows. +# Example override: +# [[bindings]] +# keys = "Swipe3Up" +# action = "toggle-overview" diff --git a/packaging/ferese-screenshot b/packaging/ferese-screenshot index e2647df..f16396a 100755 --- a/packaging/ferese-screenshot +++ b/packaging/ferese-screenshot @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Select an area, save it in Pictures/Screenshots, and copy it to the clipboard. +# Capture an area or the whole desktop and open it in Satty for annotation. set -euo pipefail umask 077 @@ -11,26 +11,50 @@ fail() { exit 1 } -for tool in slurp grim wl-copy flock; do +mode=area +case ${1:-} in + '') ;; + --full) mode=full; shift ;; + --help|-h) + echo 'Usage: ferese-screenshot [--full]' + echo 'Select an area, or use --full for the whole desktop, then edit in Satty.' + exit 0 + ;; + *) fail 'Usage: ferese-screenshot [--full]' ;; +esac +(($# == 0)) || fail 'Usage: ferese-screenshot [--full]' + +for tool in grim satty wl-copy flock; do command -v "$tool" >/dev/null || fail "Required command not found: $tool" done : "${XDG_RUNTIME_DIR:?A Wayland login session is required}" exec 9>"$XDG_RUNTIME_DIR/ferese-screenshot.lock" flock -n 9 || exit 0 -# Escape cancels without creating a file or changing the clipboard. -geometry=$(slurp) || exit 0 -[[ -n $geometry ]] || exit 0 +capture_args=() +if [[ $mode == area ]]; then + command -v slurp >/dev/null || fail 'Required command not found: slurp' + # Escape cancels without creating a file or changing the clipboard. + geometry=$(slurp) || exit 0 + [[ -n $geometry ]] || exit 0 + capture_args=(-g "$geometry") +fi pictures=$(xdg-user-dir PICTURES 2>/dev/null) || pictures="$HOME/Pictures" directory=${pictures:-$HOME/Pictures}/Screenshots mkdir -p -- "$directory" || fail 'Could not create the screenshot directory.' file=$(mktemp "$directory/Screenshot-$(date +%Y-%m-%d_%H-%M-%S)-XXXXXX.png") || fail 'Could not create the screenshot file.' -trap 'rm -f -- "$file"' EXIT -grim -g "$geometry" "$file" || fail 'Could not capture the screen. Log back into Ferese after enabling screen capture.' -trap - EXIT -# Do not pass the selector lock to wl-copy's background clipboard process. -wl-copy --type image/png <"$file" 9>&- || fail "Saved to $file, but could not copy to the clipboard." -if command -v notify-send >/dev/null; then - notify-send --app-name=Ferese 'Screenshot saved and copied' "$file" || true +input='' +trap '[[ -z $input ]] || rm -f -- "$input"; [[ -s $file ]] || rm -f -- "$file"' EXIT +input=$(mktemp "$XDG_RUNTIME_DIR/ferese-screenshot-XXXXXX.png") || fail 'Could not create the temporary capture.' +grim "${capture_args[@]}" "$input" || fail 'Could not capture the screen. Log back into Ferese after enabling screen capture.' +# Enter saves the annotated image and copies it; Escape discards the capture. +# Do not pass the selector lock to Satty or its clipboard subprocess. +satty --filename "$input" --output-filename "$file" \ + --fullscreen current-screen \ + --copy-command 'wl-copy --type image/png' \ + --save-after-copy \ + --actions-on-enter save-to-file,save-to-clipboard,exit \ + --actions-on-escape exit 9>&- || fail 'Could not open the screenshot in Satty.' +if [[ -s $file ]]; then + printf '%s\n' "$file" fi -printf '%s\n' "$file" diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 434765c..2c61741 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -67,6 +67,7 @@ enum Message { WallpaperPicked(Result, String>), NewCommand(String), AddCommand, + AddSwipe(&'static str), Remove(String, usize), Thumbnail(String, Result), } @@ -432,6 +433,22 @@ impl cosmic::Application for App { } } } + Message::AddSwipe(keys) if !self.saving => { + let (action, argument) = match keys { + "Swipe3Up" => ("workspace-next", None), + "Swipe3Down" => ("workspace-previous", None), + "Swipe3Left" => ("focus", Some("right")), + _ => ("focus", Some("left")), + }; + let mut fields = vec![ + ("keys".into(), keys.into()), + ("action".into(), action.into()), + ]; + if let Some(argument) = argument { + fields.push(("argument".into(), argument.into())); + } + return self.change(Edit::Add("bindings".into(), fields)); + } Message::Remove(table, index) if !self.saving => { self.inputs.clear(); let task = self.change(Edit::Remove(table, index)); @@ -705,7 +722,27 @@ impl cosmic::Application for App { ); } Page::Shortcuts => { - body = body.push(self.note("Custom shortcuts from your config. Built-in shortcuts remain available unless overridden. Edit a field, then press Enter or move focus to save.")); + body = body.push(self.note("Keyboard and swipe bindings share the same actions. Use Swipe3Up, Swipe3Down, Swipe3Left, or Swipe3Right as a shortcut (also supports 4 or 5 fingers). Actions include toggle-overview, spawn, focus, move, workspace-next, workspace-previous, and none. Leave Argument empty for actions such as toggle-overview or none.")); + let mut gestures = row([]).spacing(6); + for (keys, label) in [ + ("Swipe3Up", "Customize swipe up"), + ("Swipe3Down", "Customize swipe down"), + ("Swipe3Left", "Customize swipe left"), + ("Swipe3Right", "Customize swipe right"), + ] { + let exists = (0..self.draft.records("bindings")).any(|index| { + self.draft + .string(&format!("bindings.{index}.keys"), "") + .eq_ignore_ascii_case(keys) + }); + if !exists { + gestures = + gestures.push(button::standard(label).on_press_maybe( + (!self.saving).then_some(Message::AddSwipe(keys)), + )); + } + } + body = body.push(gestures); for index in 0..self.draft.records("bindings") { let prefix = format!("bindings.{index}"); let group = column([]) @@ -713,13 +750,13 @@ impl cosmic::Application for App { .push(self.field(schema::text( format!("{prefix}.keys"), "Shortcut", - "For example: Super+Return", + "For example: Super+Return or Swipe3Up", "", ))) .push(self.field(schema::text( format!("{prefix}.action"), "Action", - "Ferese command, such as spawn or focus.", + "Action such as toggle-overview, spawn, focus, move, or none.", "", ))) .push(self.field(schema::text( diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 621faf4..1ab9508 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -857,6 +857,17 @@ pub fn fields(page: Page) -> Vec { "Avoid accidental pointer movement.", true, ), + range( + "input.touchpad.swipe_threshold", + "Swipe distance", + "Distance needed to navigate. Larger values reduce accidental swipes.", + 80.0, + 16.0, + 1000.0, + 8.0, + " px", + true, + ), ], Page::Shortcuts | Page::Startup | Page::Displays => vec![], } diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 437ae72..81fae07 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1649,7 +1649,6 @@ impl FereseShell { } let focused_output = self.output_for_bar(id); - let focused_output_id = focused_output.map(|output| output.id); let shell_theme = self.config.theme.for_bar(); let bar = BarMetrics::from(shell_theme); let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) @@ -1665,23 +1664,33 @@ impl FereseShell { self.overview_active, 1.0, )); - for workspace in self - .snapshot - .workspaces - .iter() - .filter(|workspace| workspace_on_bar(workspace.output, focused_output_id)) - { + let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len()) + .spacing(1) + .align_y(cosmic::iced::Alignment::Center); + for workspace in &self.snapshot.workspaces { + let active = workspace_active_on_bar(workspace.id, focused_output); + let owner = workspace + .output + .and_then(|id| self.snapshot.outputs.iter().find(|output| output.id == id)); let occupied = self .snapshot .windows .iter() .any(|window| window.workspace == workspace.id); - let indicator = workspace_indicator(workspace.active, occupied, bar, shell_theme); + let indicator = workspace_indicator( + &workspace.name, + active, + workspace.active && !active, + occupied, + bar, + shell_theme, + ); let workspace_button = button::custom(indicator) .name(format!( - "Workspace {}{}", + "Workspace {}{}{}", workspace.name, - if workspace.active { ", active" } else { "" } + if active { ", active" } else { "" }, + owner.map_or(String::new(), |output| format!(", on {}", output.name)) )) .height(bar.control_height) .padding(0) @@ -1691,7 +1700,7 @@ impl FereseShell { let selector = motion::button( workspace_button, - color(if workspace.active { + color(if active { shell_theme.accent } else { shell_theme.text_primary @@ -1702,8 +1711,22 @@ impl FereseShell { // Iced tooltips are overlays inside this bar-height layer surface; // viewport clamping puts them back over the selector. Keep the // accessible button name, without an overlay stealing its target. - workspace_row = workspace_row.push(selector); + workspace_buttons = workspace_buttons.push(selector); } + workspace_row = workspace_row.push(container(workspace_buttons).padding([0, 3]).class( + theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(color_with_opacity( + shell_theme.text_primary, + 0.035, + ))), + border: Border { + color: color_with_opacity(shell_theme.text_muted, 0.12), + width: 1.0, + radius: 7.0.into(), + }, + ..Default::default() + }), + )); let foreground = color(shell_theme.text_primary); let left = cosmic::iced::widget::scrollable(workspace_row) @@ -1865,49 +1888,63 @@ fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option) -> bool { } fn workspace_indicator( + name: &str, active: bool, + active_elsewhere: bool, occupied: bool, bar: BarMetrics, shell_theme: ShellTheme, ) -> Element<'static, cosmic::Action> { - let marker = container(text("")) - .width(if active { 18.0 } else { 6.0 }) - .height(6) - .class(theme::Container::custom(move |_| { - workspace_selector_style(active, occupied, shell_theme) - })); - container(marker) - .width(28) - .height(bar.control_height) - .align_x(alignment::Horizontal::Center) - .align_y(alignment::Vertical::Center) - .into() + let foreground = if active { + shell_theme.accent + } else if occupied { + shell_theme.text_primary + } else { + shell_theme.text_muted + }; + container( + text(name.to_owned()) + .size(12) + .class(theme::Text::Color(color(foreground))), + ) + .width(24) + .height(bar.control_height) + .align_x(alignment::Horizontal::Center) + .align_y(alignment::Vertical::Center) + .class(theme::Container::custom(move |_| { + workspace_selector_style(active, active_elsewhere, occupied, shell_theme) + })) + .into() } -fn workspace_on_bar(workspace_output: Option, bar_output: Option) -> bool { - matches!((workspace_output, bar_output), (Some(workspace), Some(bar)) if workspace == bar) +fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool { + output.is_some_and(|output| output.active_workspace == workspace) } fn workspace_selector_style( active: bool, + active_elsewhere: bool, occupied: bool, shell_theme: ShellTheme, ) -> container::Style { container::Style { background: if active { - Some(Background::Color(color(shell_theme.accent))) + Some(Background::Color(color_with_opacity( + shell_theme.accent, + 0.16, + ))) } else if occupied { Some(Background::Color(color_with_opacity( shell_theme.text_primary, - 0.78, + 0.04, ))) } else { None }, border: Border { - color: color_with_opacity(shell_theme.text_muted, 0.55), - width: if !active && !occupied { 1.0 } else { 0.0 }, - radius: 3.0.into(), + color: color_with_opacity(shell_theme.accent, 0.55), + width: if active_elsewhere { 1.0 } else { 0.0 }, + radius: 6.0.into(), }, ..Default::default() } @@ -2434,25 +2471,33 @@ mod tests { #[test] fn workspace_selector_has_distinct_active_paint() { let theme = ShellTheme::default(); - let active = workspace_selector_style(true, false, theme); - let occupied = workspace_selector_style(false, true, theme); - let inactive = workspace_selector_style(false, false, theme); + let active = workspace_selector_style(true, false, false, theme); + let occupied = workspace_selector_style(false, false, true, theme); + let inactive = workspace_selector_style(false, false, false, theme); + let remote = workspace_selector_style(false, true, false, theme); assert!(active.background.is_some()); assert_eq!( active.background, - Some(Background::Color(color(theme.accent))) + Some(Background::Color(color_with_opacity(theme.accent, 0.16))) ); assert!(occupied.background.is_some()); assert!(inactive.background.is_none()); - assert_eq!(inactive.border.width, 1.0); + assert_eq!(inactive.border.width, 0.0); + assert_eq!(remote.border.width, 1.0); + assert!(remote.background.is_none()); } #[test] - fn workspace_selector_is_output_local() { - assert!(workspace_on_bar(Some(1), Some(1))); - assert!(!workspace_on_bar(Some(2), Some(1))); - assert!(!workspace_on_bar(None, Some(1))); - assert!(!workspace_on_bar(None, None)); + fn workspace_highlight_tracks_the_bars_output() { + let output = control::OutputSnapshot { + id: 1, + name: "eDP-1".into(), + active_workspace: 2, + focused: false, + }; + assert!(workspace_active_on_bar(2, Some(&output))); + assert!(!workspace_active_on_bar(1, Some(&output))); + assert!(!workspace_active_on_bar(2, None)); } #[test]