Fix screenshots and workspace switching; add configurable swipe bindings

This commit is contained in:
Samuel Onoja committed 2026-09-27 05:21:52 +01:00
1 parent 5d6c610fec
commit dbdfc5688e
14 files changed
+707 -101

No files matched your search

+15 -4
View File
@@ -20,11 +20,12 @@ and native Settings app.
- Scrolling columns, tree tiling and floating windows - Scrolling columns, tree tiling and floating windows
- Workspace overview - Workspace overview
- Configurable three-finger workspace and window navigation
- Configurable animations - Configurable animations
- Built-in bar and control center - Built-in bar and control center
- Native Settings app - Native Settings app
- Desktop widgets - 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 - Themes, blur and gradient borders
- Multi-monitor support and fractional scaling - Multi-monitor support and fractional scaling
- Live configuration reload - Live configuration reload
@@ -51,7 +52,7 @@ Requirements (Fedora package names):
- Default terminal: `foot` (or configure another terminal). - Default terminal: `foot` (or configure another terminal).
- Optional: `zenity` for wallpaper browsing, `swayidle` for idle actions, - Optional: `zenity` for wallpaper browsing, `swayidle` for idle actions,
and `swaylock` or `swaylock-effects` for locking. 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: 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): 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 | | Shortcut | Action |
| --- | --- | | --- | --- |
| Super + Enter | Open a terminal | | 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 + Tab | Open workspace and window overview |
| Super + M | Switch scrolling / tree layout | | Super + M | Switch scrolling / tree layout |
| Super + Shift + S | Select a screenshot area | | 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 | | Super + Shift + E | Log out immediately—save your work first |
In an installed session, screenshots are saved to `Pictures/Screenshots` and Both screenshot shortcuts open Satty for annotation. Press Enter in Satty to
copied to the clipboard. Press Escape to cancel the selection. 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 When focus follows the mouse is enabled, hovering changes keyboard focus without
raising the window; clicking raises it within its layer. raising the window; clicking raises it within its layer.
+168 -3
View File
@@ -440,6 +440,10 @@ pub struct Binding {
enum BindingTrigger { enum BindingTrigger {
Keysym(u32), Keysym(u32),
Physical(Keycode), Physical(Keycode),
Swipe {
fingers: u32,
direction: crate::gestures::SwipeDirection,
},
} }
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
@@ -452,6 +456,7 @@ struct BindingModifiers {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum BindingAction { pub enum BindingAction {
None,
Spawn(Vec<String>), Spawn(Vec<String>),
Close, Close,
Exit, Exit,
@@ -459,6 +464,7 @@ pub enum BindingAction {
Move(ferese_layout::Direction), Move(ferese_layout::Direction),
Resize(ferese_layout::Direction), Resize(ferese_layout::Direction),
SwitchWorkspace(u8), SwitchWorkspace(u8),
SwitchRelativeWorkspace(bool),
MoveToWorkspace(u8), MoveToWorkspace(u8),
ToggleFullscreen, ToggleFullscreen,
ToggleMaximized, ToggleMaximized,
@@ -491,8 +497,21 @@ impl Binding {
&& match self.trigger { && match self.trigger {
BindingTrigger::Keysym(expected) => keysyms.contains(&expected), BindingTrigger::Keysym(expected) => keysyms.contains(&expected),
BindingTrigger::Physical(expected) => expected == keycode, 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)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq)]
@@ -501,6 +520,7 @@ enum BindingMatch {
#[default] #[default]
Keysym, Keysym,
Physical, Physical,
Swipe,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -530,6 +550,11 @@ pub struct TouchpadSettings {
pub tap: bool, pub tap: bool,
pub natural_scroll: bool, pub natural_scroll: bool,
pub disable_while_typing: bool, pub disable_while_typing: bool,
pub swipe_threshold: u16,
}
fn default_swipe_threshold() -> u16 {
80
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -572,6 +597,8 @@ struct TouchpadConfig {
natural_scroll: bool, natural_scroll: bool,
#[serde(default = "enabled_by_default")] #[serde(default = "enabled_by_default")]
disable_while_typing: bool, disable_while_typing: bool,
#[serde(default = "default_swipe_threshold")]
swipe_threshold: u16,
} }
impl Default for TouchpadConfig { impl Default for TouchpadConfig {
@@ -580,6 +607,7 @@ impl Default for TouchpadConfig {
tap: true, tap: true,
natural_scroll: true, natural_scroll: true,
disable_while_typing: 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 { Ok(InputSettings {
focus_follows_mouse: self.input.focus_follows_mouse, focus_follows_mouse: self.input.focus_follows_mouse,
xkb_layout: self.input.xkb_layout.clone(), xkb_layout: self.input.xkb_layout.clone(),
@@ -890,6 +924,7 @@ impl Config {
tap: self.input.touchpad.tap, tap: self.input.touchpad.tap,
natural_scroll: self.input.touchpad.natural_scroll, natural_scroll: self.input.touchpad.natural_scroll,
disable_while_typing: self.input.touchpad.disable_while_typing, 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(), "screenshot".to_owned(),
vec!["ferese-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()); commands.extend(self.commands.clone());
validate_commands(&commands)?; validate_commands(&commands)?;
@@ -1184,6 +1223,15 @@ fn parse_binding(
let trigger = match identity.match_mode { let trigger = match identity.match_mode {
BindingMatch::Keysym => BindingTrigger::Keysym(identity.key), BindingMatch::Keysym => BindingTrigger::Keysym(identity.key),
BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(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 { Ok(Binding {
@@ -1198,6 +1246,20 @@ fn binding_identity(
keymap: &xkb::Keymap, keymap: &xkb::Keymap,
) -> Result<BindingIdentity, ConfigError> { ) -> Result<BindingIdentity, ConfigError> {
let (modifiers, key) = parse_chord(&configured.keys)?; 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 { let key = match configured.match_mode {
BindingMatch::Keysym => parse_keysym(&key)?, BindingMatch::Keysym => parse_keysym(&key)?,
BindingMatch::Physical => keymap BindingMatch::Physical => keymap
@@ -1209,6 +1271,11 @@ fn binding_identity(
configured.keys configured.keys
)) ))
})?, })?,
BindingMatch::Swipe => {
return Err(ConfigError::InvalidBinding(
"gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(),
));
}
}; };
Ok(BindingIdentity { Ok(BindingIdentity {
@@ -1222,6 +1289,9 @@ fn binding_identity_for_runtime(binding: &Binding) -> BindingIdentity {
let (match_mode, key) = match binding.trigger { let (match_mode, key) = match binding.trigger {
BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key), BindingTrigger::Keysym(key) => (BindingMatch::Keysym, key),
BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()), BindingTrigger::Physical(key) => (BindingMatch::Physical, key.raw()),
BindingTrigger::Swipe { fingers, direction } => {
(BindingMatch::Swipe, fingers * 4 + direction as u32)
}
}; };
BindingIdentity { BindingIdentity {
@@ -1269,6 +1339,24 @@ fn parse_chord(chord: &str) -> Result<(BindingModifiers, String), ConfigError> {
Ok((modifiers, key)) 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::<u32>().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<u32, ConfigError> { fn parse_keysym(name: &str) -> Result<u32, ConfigError> {
let normalized = match name { let normalized = match name {
"Enter" | "enter" => "Return".to_owned(), "Enter" | "enter" => "Return".to_owned(),
@@ -1312,6 +1400,16 @@ fn parse_action(
}; };
match action { match action {
"none" => {
no_argument()?;
Ok(BindingAction::None)
}
"workspace-next" | "workspace-previous" => {
no_argument()?;
Ok(BindingAction::SwitchRelativeWorkspace(
action == "workspace-next",
))
}
"spawn" => { "spawn" => {
let command = required_argument()?; let command = required_argument()?;
let argv = commands.get(command).ok_or_else(|| { let argv = commands.get(command).ok_or_else(|| {
@@ -1405,6 +1503,11 @@ fn default_bindings() -> Vec<BindingConfig> {
let mut bindings = vec![ let mut bindings = vec![
binding("Super+Enter", "spawn", Some("terminal")), binding("Super+Enter", "spawn", Some("terminal")),
binding("Super+Shift+S", "spawn", Some("screenshot")), 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+Q", "close", None),
binding("Super+F", "toggle-maximized", None), binding("Super+F", "toggle-maximized", None),
binding("Super+Shift+F", "toggle-fullscreen", None), binding("Super+Shift+F", "toggle-fullscreen", None),
@@ -2054,6 +2157,7 @@ mod tests {
tap: false, tap: false,
natural_scroll: false, natural_scroll: false,
disable_while_typing: true, disable_while_typing: true,
swipe_threshold: 80,
}, },
} }
); );
@@ -2070,13 +2174,65 @@ mod tests {
assert!(delay.input_settings().is_err()); 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] #[test]
fn supplies_complete_v0_bindings_and_terminal_command() { fn supplies_complete_v0_bindings_and_terminal_command() {
let config = parse(""); let config = parse("");
let input = config.input_settings().unwrap(); let input = config.input_settings().unwrap();
let bindings = config.bindings(&input).unwrap(); let bindings = config.bindings(&input).unwrap();
assert_eq!(bindings.len(), 43); assert_eq!(bindings.len(), 48);
for (shift, action) in [ for (shift, action) in [
(false, BindingAction::ToggleMaximized), (false, BindingAction::ToggleMaximized),
(true, BindingAction::ToggleFullscreen), (true, BindingAction::ToggleFullscreen),
@@ -2102,6 +2258,15 @@ mod tests {
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_s) && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_s)
&& binding.action == BindingAction::Spawn(vec!["ferese-screenshot".to_owned()]) && 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| { assert!(bindings.iter().any(|binding| {
binding.modifiers.logo binding.modifiers.logo
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Tab) && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Tab)
@@ -2118,7 +2283,7 @@ mod tests {
let input = replaced.input_settings().unwrap(); let input = replaced.input_settings().unwrap();
let bindings = replaced.bindings(&input).unwrap(); let bindings = replaced.bindings(&input).unwrap();
assert_eq!(bindings.len(), 43); assert_eq!(bindings.len(), 48);
assert!(bindings.iter().any(|binding| { assert!(bindings.iter().any(|binding| {
binding.action binding.action
== BindingAction::Spawn(vec![ == BindingAction::Spawn(vec![
@@ -2130,7 +2295,7 @@ mod tests {
let input = unbound.input_settings().unwrap(); let input = unbound.input_settings().unwrap();
let bindings = unbound.bindings(&input).unwrap(); let bindings = unbound.bindings(&input).unwrap();
assert_eq!(bindings.len(), 42); assert_eq!(bindings.len(), 47);
assert!( assert!(
!bindings !bindings
.iter() .iter()
+134
View File
@@ -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<SwipeDirection> {
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));
}
}
+8 -12
View File
@@ -1,10 +1,7 @@
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use smithay::{ use smithay::{
backend::{ backend::{allocator::Fourcc, renderer::ExportMem},
allocator::Fourcc,
renderer::{ExportMem, TextureMapping},
},
output::Output, output::Output,
reexports::{ reexports::{
wayland_protocols_wlr::screencopy::v1::server::{ wayland_protocols_wlr::screencopy::v1::server::{
@@ -355,8 +352,8 @@ where
return false; return false;
} }
// Preserve the renderer's row order and report it through the protocol // Smithay's output projection already accounts for OpenGL's Y axis.
// instead of making another full-frame copy on the compositor thread. // These rows are in output-buffer order, including its output transform.
unsafe { unsafe {
std::ptr::copy_nonoverlapping( std::ptr::copy_nonoverlapping(
source.as_ptr(), source.as_ptr(),
@@ -377,12 +374,11 @@ where
.frame .frame
.damage(0, 0, expected.w as u32, expected.h as u32); .damage(0, 0, expected.w as u32, expected.h as u32);
} }
let flags = if mapping.flipped() { // TextureMapping::flipped describes importing the mapping as a texture;
zwlr_screencopy_frame_v1::Flags::YInvert // using it here would make clients flip the already-correct output rows.
} else { capture
zwlr_screencopy_frame_v1::Flags::empty() .frame
}; .flags(zwlr_screencopy_frame_v1::Flags::empty());
capture.frame.flags(flags);
let seconds = timestamp.as_secs(); let seconds = timestamp.as_secs();
capture.frame.ready( capture.frame.ready(
+129 -4
View File
@@ -3,13 +3,17 @@ use std::process::Command;
use smithay::{ use smithay::{
backend::input::{ backend::input::{
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent, AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent,
KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, GestureEndEvent, GestureSwipeUpdateEvent as _, InputBackend, InputEvent, KeyState,
Switch, SwitchState, SwitchToggleEvent, TouchEvent, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, Switch,
SwitchState, SwitchToggleEvent, TouchEvent,
}, },
input::{ input::{
keyboard::{FilterResult, keysyms}, keyboard::{FilterResult, keysyms},
pointer::{AxisFrame, ButtonEvent, MotionEvent, PointerHandle, RelativeMotionEvent}, pointer::{
AxisFrame, ButtonEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent,
GestureSwipeUpdateEvent, MotionEvent, PointerHandle, RelativeMotionEvent,
},
touch::{DownEvent, MotionEvent as TouchMotionEvent, UpEvent}, touch::{DownEvent, MotionEvent as TouchMotionEvent, UpEvent},
}, },
reexports::wayland_server::protocol::wl_surface::WlSurface, reexports::wayland_server::protocol::wl_surface::WlSurface,
@@ -43,6 +47,71 @@ impl Ferese {
} }
match event { 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) => { InputEvent::SwitchToggle { event } if event.switch() == Some(Switch::Lid) => {
crate::backends::direct::set_lid_closed(self, event.state() == SwitchState::On); 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()) 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::<Vec<_>>();
candidates.sort_by_key(|workspace| {
(
workspace.name.parse::<u32>().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<f64, Logical>, serial: Serial, raise: bool) { fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) {
if self.session_lock.active { if self.session_lock.active {
self.focus_output_at(position); self.focus_output_at(position);
@@ -562,6 +685,7 @@ impl Ferese {
fn execute_binding(&mut self, action: BindingAction) { fn execute_binding(&mut self, action: BindingAction) {
match action { match action {
BindingAction::None => {}
BindingAction::Spawn(mut argv) => { BindingAction::Spawn(mut argv) => {
let program = argv.remove(0); let program = argv.remove(0);
@@ -584,6 +708,7 @@ impl Ferese {
BindingAction::Focus(direction) => self.focus_direction(direction), BindingAction::Focus(direction) => self.focus_direction(direction),
BindingAction::Move(direction) => self.move_direction(direction), BindingAction::Move(direction) => self.move_direction(direction),
BindingAction::Resize(direction) => self.resize_direction(direction), BindingAction::Resize(direction) => self.resize_direction(direction),
BindingAction::SwitchRelativeWorkspace(next) => self.switch_relative_workspace(next),
BindingAction::SwitchWorkspace(workspace) => { BindingAction::SwitchWorkspace(workspace) => {
self.switch_workspace(u32::from(workspace)); self.switch_workspace(u32::from(workspace));
} }
+1
View File
@@ -6,6 +6,7 @@ mod cursor;
mod daemon; mod daemon;
mod dimming; mod dimming;
mod effects; mod effects;
mod gestures;
mod grabs; mod grabs;
mod handlers; mod handlers;
mod input; mod input;
+15 -14
View File
@@ -167,6 +167,7 @@ pub struct Ferese {
pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) cursor_theme: xcursor::CursorTheme,
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>, pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
pub intercepted_keys: HashSet<smithay::input::keyboard::Keycode>, pub intercepted_keys: HashSet<smithay::input::keyboard::Keycode>,
pub(crate) swipe: crate::gestures::Swipe,
pub idle_inhibitors: HashMap<WlSurface, usize>, pub idle_inhibitors: HashMap<WlSurface, usize>,
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>, pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
pub direct_backend: Option<crate::backends::direct::DirectBackendState>, pub direct_backend: Option<crate::backends::direct::DirectBackendState>,
@@ -640,6 +641,7 @@ impl Ferese {
cursor_theme, cursor_theme,
named_cursors, named_cursors,
intercepted_keys: HashSet::new(), intercepted_keys: HashSet::new(),
swipe: crate::gestures::Swipe::default(),
idle_inhibitors: HashMap::new(), idle_inhibitors: HashMap::new(),
active_shortcuts_inhibitor: None, active_shortcuts_inhibitor: None,
direct_backend: None, direct_backend: None,
@@ -682,6 +684,11 @@ impl Ferese {
xdg_shell_state, xdg_shell_state,
xdg_toplevel_icon_manager, 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)?); state._ipc_socket = Some(crate::ipc::init(event_loop)?);
Ok(state) Ok(state)
} }
@@ -2364,25 +2371,19 @@ impl Ferese {
let output = self let output = self
.output_workspaces .output_workspaces
.output_for_workspace(workspace) .focused_output()
.or_else(|| self.output_workspaces.focused_output()); .or_else(|| self.output_workspaces.output_for_workspace(workspace));
let Some(output) = output else { let Some(output) = output else {
return false; return false;
}; };
if self let owner = match self.output_workspaces.switch_workspace(output, workspace) {
.output_workspaces Ok(ferese_core::WorkspaceSwitch::Activated(output))
.output_for_workspace(workspace) | Ok(ferese_core::WorkspaceSwitch::FocusedExisting(output)) => output,
.is_none() Err(_) => return false,
&& self };
.output_workspaces
.assign_workspace(output, workspace)
.is_err()
{
return false;
}
self.activate_output_workspace(output, workspace); self.activate_output_workspace(owner, workspace);
self.relayout(); self.relayout();
self.restore_keyboard_focus(); self.restore_keyboard_focus();
true true
+51 -6
View File
@@ -134,10 +134,47 @@ angle = 135.0
| `repeat_rate` | integer > 0 | `25` | Repeats per second | | `repeat_rate` | integer > 0 | `25` | Repeats per second |
| `repeat_delay_ms` | integer ≥ 0 | `600` | Delay before repeat | | `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 `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
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 and bindings
`[commands]` maps arbitrary names to nonempty argument arrays. The built-in `[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 | | `[[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"` | | `match` | `"keysym"`, `"physical"` | `"keysym"` |
| `action` | action name below | required unless disabled | | `action` | action name below | required unless disabled |
| `argument` | string | required only for actions listed below | | `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]` | | `spawn` | Name in `[commands]` |
| `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | | `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` |
| `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `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 | | `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None |
```toml ```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+Shift+1–9 move to workspace; Super+R width cycle; Super+C center;
Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen;
Super+M layout; Super+Shift+Space floating; Super+Tab overview; 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 The screenshot shortcut runs `ferese-screenshot`: drag to select an area, or
press Escape to cancel. Captures are saved under your Pictures directory in press Escape to cancel. Captures open in Satty for annotation. Press Enter to
`Screenshots` and copied to the clipboard as PNG images. It requires `grim`, save the edited PNG under your Pictures directory in `Screenshots` and copy it
`slurp`, and `wl-copy` (from `wl-clipboard`). Override the `screenshot` command 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. 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 ## Window rules
+1 -1
View File
@@ -5,7 +5,7 @@
On Fedora with SDDM, install a Rust toolchain (1.93 or newer), native build 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 dependencies, `desktop-file-utils`, D-Bus and `foot`. Wallpaper browsing also
needs `zenity`; locking needs swaylock or swaylock-effects. 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: From the checkout, run as your normal user:
+11
View File
@@ -3,11 +3,15 @@
[commands] [commands]
terminal = ["foot"] terminal = ["foot"]
screenshot = ["ferese-screenshot"] screenshot = ["ferese-screenshot"]
screenshot-full = ["ferese-screenshot", "--full"]
[input] [input]
xkb_layout = "us" xkb_layout = "us"
focus_follows_mouse = false focus_follows_mouse = false
[input.touchpad]
swipe_threshold = 80
[layout] [layout]
smart_gaps = false smart_gaps = false
@@ -38,3 +42,10 @@ margin_y = 80
time_size = 72.0 time_size = 72.0
date_size = 18.0 date_size = 18.0
opacity = 0.9 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"
+37 -13
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
umask 077 umask 077
@@ -11,26 +11,50 @@ fail() {
exit 1 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" command -v "$tool" >/dev/null || fail "Required command not found: $tool"
done done
: "${XDG_RUNTIME_DIR:?A Wayland login session is required}" : "${XDG_RUNTIME_DIR:?A Wayland login session is required}"
exec 9>"$XDG_RUNTIME_DIR/ferese-screenshot.lock" exec 9>"$XDG_RUNTIME_DIR/ferese-screenshot.lock"
flock -n 9 || exit 0 flock -n 9 || exit 0
# Escape cancels without creating a file or changing the clipboard. capture_args=()
geometry=$(slurp) || exit 0 if [[ $mode == area ]]; then
[[ -n $geometry ]] || exit 0 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" pictures=$(xdg-user-dir PICTURES 2>/dev/null) || pictures="$HOME/Pictures"
directory=${pictures:-$HOME/Pictures}/Screenshots directory=${pictures:-$HOME/Pictures}/Screenshots
mkdir -p -- "$directory" || fail 'Could not create the screenshot directory.' 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.' 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 input=''
grim -g "$geometry" "$file" || fail 'Could not capture the screen. Log back into Ferese after enabling screen capture.' trap '[[ -z $input ]] || rm -f -- "$input"; [[ -s $file ]] || rm -f -- "$file"' EXIT
trap - EXIT input=$(mktemp "$XDG_RUNTIME_DIR/ferese-screenshot-XXXXXX.png") || fail 'Could not create the temporary capture.'
# Do not pass the selector lock to wl-copy's background clipboard process. grim "${capture_args[@]}" "$input" || fail 'Could not capture the screen. Log back into Ferese after enabling screen capture.'
wl-copy --type image/png <"$file" 9>&- || fail "Saved to $file, but could not copy to the clipboard." # Enter saves the annotated image and copies it; Escape discards the capture.
if command -v notify-send >/dev/null; then # Do not pass the selector lock to Satty or its clipboard subprocess.
notify-send --app-name=Ferese 'Screenshot saved and copied' "$file" || true 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 fi
printf '%s\n' "$file"
+40 -3
View File
@@ -67,6 +67,7 @@ enum Message {
WallpaperPicked(Result<Option<String>, String>), WallpaperPicked(Result<Option<String>, String>),
NewCommand(String), NewCommand(String),
AddCommand, AddCommand,
AddSwipe(&'static str),
Remove(String, usize), Remove(String, usize),
Thumbnail(String, Result<widget::image::Handle, String>), Thumbnail(String, Result<widget::image::Handle, String>),
} }
@@ -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 => { Message::Remove(table, index) if !self.saving => {
self.inputs.clear(); self.inputs.clear();
let task = self.change(Edit::Remove(table, index)); let task = self.change(Edit::Remove(table, index));
@@ -705,7 +722,27 @@ impl cosmic::Application for App {
); );
} }
Page::Shortcuts => { 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") { for index in 0..self.draft.records("bindings") {
let prefix = format!("bindings.{index}"); let prefix = format!("bindings.{index}");
let group = column([]) let group = column([])
@@ -713,13 +750,13 @@ impl cosmic::Application for App {
.push(self.field(schema::text( .push(self.field(schema::text(
format!("{prefix}.keys"), format!("{prefix}.keys"),
"Shortcut", "Shortcut",
"For example: Super+Return", "For example: Super+Return or Swipe3Up",
"", "",
))) )))
.push(self.field(schema::text( .push(self.field(schema::text(
format!("{prefix}.action"), format!("{prefix}.action"),
"Action", "Action",
"Ferese command, such as spawn or focus.", "Action such as toggle-overview, spawn, focus, move, or none.",
"", "",
))) )))
.push(self.field(schema::text( .push(self.field(schema::text(
+11
View File
@@ -857,6 +857,17 @@ pub fn fields(page: Page) -> Vec<Field> {
"Avoid accidental pointer movement.", "Avoid accidental pointer movement.",
true, 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![], Page::Shortcuts | Page::Startup | Page::Displays => vec![],
} }
+86 -41
View File
@@ -1649,7 +1649,6 @@ impl FereseShell {
} }
let focused_output = self.output_for_bar(id); 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 shell_theme = self.config.theme.for_bar();
let bar = BarMetrics::from(shell_theme); let bar = BarMetrics::from(shell_theme);
let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1) let mut workspace_row = row::with_capacity(self.snapshot.workspaces.len() + 1)
@@ -1665,23 +1664,33 @@ impl FereseShell {
self.overview_active, self.overview_active,
1.0, 1.0,
)); ));
for workspace in self let mut workspace_buttons = row::with_capacity(self.snapshot.workspaces.len())
.snapshot .spacing(1)
.workspaces .align_y(cosmic::iced::Alignment::Center);
.iter() for workspace in &self.snapshot.workspaces {
.filter(|workspace| workspace_on_bar(workspace.output, focused_output_id)) 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 let occupied = self
.snapshot .snapshot
.windows .windows
.iter() .iter()
.any(|window| window.workspace == workspace.id); .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) let workspace_button = button::custom(indicator)
.name(format!( .name(format!(
"Workspace {}{}", "Workspace {}{}{}",
workspace.name, 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) .height(bar.control_height)
.padding(0) .padding(0)
@@ -1691,7 +1700,7 @@ impl FereseShell {
let selector = motion::button( let selector = motion::button(
workspace_button, workspace_button,
color(if workspace.active { color(if active {
shell_theme.accent shell_theme.accent
} else { } else {
shell_theme.text_primary shell_theme.text_primary
@@ -1702,8 +1711,22 @@ impl FereseShell {
// Iced tooltips are overlays inside this bar-height layer surface; // Iced tooltips are overlays inside this bar-height layer surface;
// viewport clamping puts them back over the selector. Keep the // viewport clamping puts them back over the selector. Keep the
// accessible button name, without an overlay stealing its target. // 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 foreground = color(shell_theme.text_primary);
let left = cosmic::iced::widget::scrollable(workspace_row) let left = cosmic::iced::widget::scrollable(workspace_row)
@@ -1865,49 +1888,63 @@ fn output_bar_hidden(snapshot: &ShellSnapshot, output: Option<u64>) -> bool {
} }
fn workspace_indicator( fn workspace_indicator(
name: &str,
active: bool, active: bool,
active_elsewhere: bool,
occupied: bool, occupied: bool,
bar: BarMetrics, bar: BarMetrics,
shell_theme: ShellTheme, shell_theme: ShellTheme,
) -> Element<'static, cosmic::Action<Message>> { ) -> Element<'static, cosmic::Action<Message>> {
let marker = container(text("")) let foreground = if active {
.width(if active { 18.0 } else { 6.0 }) shell_theme.accent
.height(6) } else if occupied {
.class(theme::Container::custom(move |_| { shell_theme.text_primary
workspace_selector_style(active, occupied, shell_theme) } else {
})); shell_theme.text_muted
container(marker) };
.width(28) container(
.height(bar.control_height) text(name.to_owned())
.align_x(alignment::Horizontal::Center) .size(12)
.align_y(alignment::Vertical::Center) .class(theme::Text::Color(color(foreground))),
.into() )
.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<u64>, bar_output: Option<u64>) -> bool { fn workspace_active_on_bar(workspace: u64, output: Option<&control::OutputSnapshot>) -> bool {
matches!((workspace_output, bar_output), (Some(workspace), Some(bar)) if workspace == bar) output.is_some_and(|output| output.active_workspace == workspace)
} }
fn workspace_selector_style( fn workspace_selector_style(
active: bool, active: bool,
active_elsewhere: bool,
occupied: bool, occupied: bool,
shell_theme: ShellTheme, shell_theme: ShellTheme,
) -> container::Style { ) -> container::Style {
container::Style { container::Style {
background: if active { background: if active {
Some(Background::Color(color(shell_theme.accent))) Some(Background::Color(color_with_opacity(
shell_theme.accent,
0.16,
)))
} else if occupied { } else if occupied {
Some(Background::Color(color_with_opacity( Some(Background::Color(color_with_opacity(
shell_theme.text_primary, shell_theme.text_primary,
0.78, 0.04,
))) )))
} else { } else {
None None
}, },
border: Border { border: Border {
color: color_with_opacity(shell_theme.text_muted, 0.55), color: color_with_opacity(shell_theme.accent, 0.55),
width: if !active && !occupied { 1.0 } else { 0.0 }, width: if active_elsewhere { 1.0 } else { 0.0 },
radius: 3.0.into(), radius: 6.0.into(),
}, },
..Default::default() ..Default::default()
} }
@@ -2434,25 +2471,33 @@ mod tests {
#[test] #[test]
fn workspace_selector_has_distinct_active_paint() { fn workspace_selector_has_distinct_active_paint() {
let theme = ShellTheme::default(); let theme = ShellTheme::default();
let active = workspace_selector_style(true, false, theme); let active = workspace_selector_style(true, false, false, theme);
let occupied = workspace_selector_style(false, true, theme); let occupied = workspace_selector_style(false, false, true, theme);
let inactive = workspace_selector_style(false, false, 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!(active.background.is_some());
assert_eq!( assert_eq!(
active.background, active.background,
Some(Background::Color(color(theme.accent))) Some(Background::Color(color_with_opacity(theme.accent, 0.16)))
); );
assert!(occupied.background.is_some()); assert!(occupied.background.is_some());
assert!(inactive.background.is_none()); 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] #[test]
fn workspace_selector_is_output_local() { fn workspace_highlight_tracks_the_bars_output() {
assert!(workspace_on_bar(Some(1), Some(1))); let output = control::OutputSnapshot {
assert!(!workspace_on_bar(Some(2), Some(1))); id: 1,
assert!(!workspace_on_bar(None, Some(1))); name: "eDP-1".into(),
assert!(!workspace_on_bar(None, None)); 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] #[test]