Add mouse window controls and finer display scaling (#63)

Add configurable mouse and wheel bindings, window move and resize, tab drops, cross-output dragging, and device settings. Adjust display scaling in 0.05 increments.

Preserve opposite-edge positioning during scrolling resizes and show rows for edge drops into tabbed columns.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-25 14:14:35 +01:00
1 parent ab11269246
commit ad00e55fe4
35 files changed
+3454 -512

No files matched your search

+45 -2
View File
@@ -7,9 +7,11 @@ use super::{
mod index;
mod modes;
mod pointer;
pub(crate) use index::BindingSet;
pub(super) use modes::BindingModeConfig;
pub(crate) use modes::BindingModes;
pub(crate) use pointer::{PointerTrigger, ScrollDirection};
impl Config {
fn binding_commands(&self) -> HashMap<String, Vec<String>> {
@@ -120,6 +122,7 @@ pub(super) fn parse_binding(
let trigger = match identity.match_mode {
BindingMatch::Keysym => BindingTrigger::Keysym(identity.key),
BindingMatch::Physical => BindingTrigger::Physical(Keycode::new(identity.key)),
BindingMatch::Pointer => BindingTrigger::Pointer(PointerTrigger::from_key(identity.key)),
BindingMatch::Swipe => BindingTrigger::Swipe {
fingers: identity.key / 4,
direction: [
@@ -131,6 +134,15 @@ pub(super) fn parse_binding(
},
};
if matches!(
action,
BindingAction::PointerMove | BindingAction::PointerResize
) && !matches!(trigger, BindingTrigger::Pointer(PointerTrigger::Button(_)))
{
return Err(ConfigError::Binding(
"pointer-move and pointer-resize require a mouse button".into(),
));
}
Ok(Binding {
modifiers: identity.modifiers,
trigger,
@@ -143,6 +155,18 @@ pub(super) fn binding_identity(
keymap: &xkb::Keymap,
) -> Result<BindingIdentity, ConfigError> {
let (modifiers, key) = parse_chord(&configured.keys)?;
if let Some(trigger) = PointerTrigger::parse(&key)? {
if configured.match_mode != BindingMatch::Keysym {
return Err(ConfigError::Binding(
"mouse bindings cannot use physical key matching".into(),
));
}
return Ok(BindingIdentity {
modifiers,
match_mode: BindingMatch::Pointer,
key: trigger.key(),
});
}
if let Some((fingers, direction)) = parse_swipe(&key) {
if configured.match_mode == BindingMatch::Physical
|| modifiers != BindingModifiers::default()
@@ -168,7 +192,7 @@ pub(super) fn binding_identity(
configured.keys
))
})?,
BindingMatch::Swipe => {
BindingMatch::Swipe | BindingMatch::Pointer => {
return Err(ConfigError::Binding(
"gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(),
));
@@ -186,6 +210,7 @@ pub(super) 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::Pointer(trigger) => (BindingMatch::Pointer, trigger.key()),
BindingTrigger::Swipe { fingers, direction } => {
(BindingMatch::Swipe, fingers * 4 + direction as u32)
}
@@ -292,6 +317,14 @@ pub(super) fn parse_action(
};
match action {
"pointer-move" | "pointer-resize" => {
no_argument()?;
Ok(if action == "pointer-move" {
BindingAction::PointerMove
} else {
BindingAction::PointerResize
})
}
"binding-mode" => Ok(BindingAction::BindingMode(required_argument()?.to_owned())),
"none" => {
no_argument()?;
@@ -447,6 +480,8 @@ pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
pub(super) fn default_bindings() -> Vec<BindingConfig> {
let mut bindings = vec![
binding("Super+MouseLeft", "pointer-move", None),
binding("Super+MouseRight", "pointer-resize", None),
binding("Super+Space", "toggle-launcher", None),
binding("Super+Enter", "spawn", Some("terminal")),
binding("Super+Shift+S", "spawn", Some("screenshot")),
@@ -538,6 +573,7 @@ pub struct Binding {
pub(super) enum BindingTrigger {
Keysym(u32),
Physical(Keycode),
Pointer(PointerTrigger),
Swipe {
fingers: u32,
direction: crate::gestures::SwipeDirection,
@@ -563,6 +599,8 @@ pub enum BindingAction {
FocusLastWindow,
FocusFloating,
FocusMru(bool),
PointerMove,
PointerResize,
Move(ferese_layout::Direction),
Resize(ferese_layout::Direction),
SwitchWorkspace(u8),
@@ -664,7 +702,7 @@ impl Binding {
&& match self.trigger {
BindingTrigger::Keysym(expected) => keysyms.contains(&expected),
BindingTrigger::Physical(expected) => expected == keycode,
BindingTrigger::Swipe { .. } => false,
BindingTrigger::Swipe { .. } | BindingTrigger::Pointer(_) => false,
}
}
@@ -704,6 +742,8 @@ impl Binding {
"Next window in focus history"
}
.into(),
BindingAction::PointerMove => "Drag window".into(),
BindingAction::PointerResize => "Resize window with pointer".into(),
BindingAction::Move(direction) => format!("Move window {direction:?}"),
BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"),
@@ -757,6 +797,7 @@ impl Binding {
}
}
parts.push(match self.trigger {
BindingTrigger::Pointer(trigger) => trigger.label(),
BindingTrigger::Keysym(symbol) => {
let name = xkb::keysym_get_name(xkb::Keysym::new(symbol));
match name.as_str() {
@@ -798,6 +839,8 @@ pub(super) enum BindingMatch {
Keysym,
Physical,
Swipe,
#[serde(skip)]
Pointer,
}
#[derive(Clone, Debug, Deserialize)]
+32 -9
View File
@@ -10,27 +10,30 @@ use super::{
binding_identity_for_runtime,
};
/// Immutable bindings and their keyboard index are replaced together on reload.
/// Immutable bindings and their lookup index are replaced together on reload.
/// The ordered slice remains available for gestures and the shortcut guide.
#[derive(Clone)]
pub(crate) struct BindingSet {
ordered: Vec<Binding>,
keyboard: HashMap<BindingIdentity, usize>,
index: HashMap<BindingIdentity, usize>,
}
impl From<Vec<Binding>> for BindingSet {
fn from(ordered: Vec<Binding>) -> Self {
let mut keyboard = HashMap::with_capacity(ordered.len());
let mut index = HashMap::with_capacity(ordered.len());
for (index, binding) in ordered.iter().enumerate() {
for (position, binding) in ordered.iter().enumerate() {
let identity = binding_identity_for_runtime(binding);
if identity.match_mode != BindingMatch::Swipe {
keyboard.entry(identity).or_insert(index);
if matches!(
identity.match_mode,
BindingMatch::Keysym | BindingMatch::Physical | BindingMatch::Pointer
) {
index.entry(identity).or_insert(position);
}
}
Self { ordered, keyboard }
Self { ordered, index }
}
}
@@ -43,6 +46,26 @@ impl std::ops::Deref for BindingSet {
}
impl BindingSet {
pub(crate) fn binding_for_pointer(
&self,
trigger: super::PointerTrigger,
modifiers: smithay::input::keyboard::ModifiersState,
) -> Option<&Binding> {
let modifiers = BindingModifiers {
logo: modifiers.logo,
ctrl: modifiers.ctrl,
alt: modifiers.alt,
shift: modifiers.shift,
};
self.index
.get(&BindingIdentity {
modifiers,
match_mode: BindingMatch::Pointer,
key: trigger.key(),
})
.map(|index| &self.ordered[*index])
}
pub(crate) fn binding_for_key(
&self,
keycode: Keycode,
@@ -58,13 +81,13 @@ impl BindingSet {
alt,
shift,
};
let physical = self.keyboard.get(&BindingIdentity {
let physical = self.index.get(&BindingIdentity {
modifiers,
match_mode: BindingMatch::Physical,
key: keycode.raw(),
});
let symbolic = symbols.iter().filter_map(|&key| {
self.keyboard.get(&BindingIdentity {
self.index.get(&BindingIdentity {
modifiers,
match_mode: BindingMatch::Keysym,
key,
+161
View File
@@ -0,0 +1,161 @@
use super::ConfigError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u32)]
pub(crate) enum ScrollDirection {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PointerTrigger {
Button(u32),
Wheel(ScrollDirection),
Touchpad(ScrollDirection),
}
impl PointerTrigger {
pub(super) fn parse(name: &str) -> Result<Option<Self>, ConfigError> {
let name = name.to_ascii_lowercase();
let button = match name.as_str() {
"mouseleft" => Some(272),
"mouseright" => Some(273),
"mousemiddle" => Some(274),
"mouseside" | "mouseback" => Some(275),
"mouseextra" | "mouseforward" => Some(276),
_ => None,
};
if let Some(button) = button {
return Ok(Some(Self::Button(button)));
}
if let Some(code) = name.strip_prefix("mouse:") {
let code = code
.parse::<u32>()
.ok()
.filter(|code| (0x110..=0x11f).contains(code))
.ok_or_else(|| {
ConfigError::Binding(
"mouse button must be a Linux mouse button code (272–287)".into(),
)
})?;
return Ok(Some(Self::Button(code)));
}
for (prefix, touchpad) in [
("wheelscroll", false),
("wheel", false),
("touchpadscroll", true),
] {
if let Some(direction) = name.strip_prefix(prefix) {
let direction = match direction {
"left" => ScrollDirection::Left,
"right" => ScrollDirection::Right,
"up" => ScrollDirection::Up,
"down" => ScrollDirection::Down,
_ => {
return Err(ConfigError::Binding(format!(
"invalid scroll direction {direction:?}"
)));
}
};
return Ok(Some(if touchpad {
Self::Touchpad(direction)
} else {
Self::Wheel(direction)
}));
}
}
Ok(None)
}
pub(super) fn key(self) -> u32 {
match self {
Self::Button(code) => code,
Self::Wheel(direction) => 1024 + direction as u32,
Self::Touchpad(direction) => 1028 + direction as u32,
}
}
pub(super) fn from_key(key: u32) -> Self {
let directions = [
ScrollDirection::Left,
ScrollDirection::Right,
ScrollDirection::Up,
ScrollDirection::Down,
];
match key {
1024..=1027 => Self::Wheel(directions[(key - 1024) as usize]),
1028..=1031 => Self::Touchpad(directions[(key - 1028) as usize]),
code => Self::Button(code),
}
}
pub(super) fn label(self) -> String {
match self {
Self::Button(272) => "Mouse Left".into(),
Self::Button(273) => "Mouse Right".into(),
Self::Button(274) => "Mouse Middle".into(),
Self::Button(275) => "Mouse Back".into(),
Self::Button(276) => "Mouse Forward".into(),
Self::Button(code) => format!("Mouse {code}"),
Self::Wheel(direction) => format!("Wheel {direction:?}"),
Self::Touchpad(direction) => format!("Touchpad scroll {direction:?}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{BindingAction, Config};
#[test]
fn pointer_names_round_trip_without_colliding_with_keyboard_keys() {
for name in [
"MouseLeft",
"MouseBack",
"MouseForward",
"mouse:287",
"WheelUp",
"WheelScrollLeft",
"TouchpadScrollDown",
] {
let trigger = PointerTrigger::parse(name).unwrap().unwrap();
assert_eq!(PointerTrigger::from_key(trigger.key()), trigger);
}
for name in ["mouse:12", "mouse:288", "mouse:foo", "WheelSideways"] {
assert!(PointerTrigger::parse(name).is_err(), "{name}");
}
assert_eq!(PointerTrigger::parse("Return").unwrap(), None);
}
#[test]
fn pointer_defaults_can_be_overridden_and_disabled() {
let config = Config::parse_source(
r#"
binding "Super+MouseLeft" action="close"
binding "Super+MouseRight" disabled=#true
binding "Alt+MouseMiddle" action="pointer-resize"
binding "Super+WheelDown" action="workspace-next"
"#,
)
.unwrap();
let bindings = config.bindings(&config.input_settings().unwrap()).unwrap();
assert!(
!bindings
.iter()
.any(|binding| binding.action == BindingAction::PointerMove)
);
assert_eq!(
bindings
.iter()
.filter(|binding| binding.action == BindingAction::PointerResize)
.count(),
1
);
let config =
Config::parse_source(r#"binding "Super+WheelUp" action="pointer-move""#).unwrap();
assert!(config.bindings(&config.input_settings().unwrap()).is_err());
}
}
+122
View File
@@ -30,7 +30,44 @@ impl Config {
value: self.input.touchpad.swipe_threshold.to_string(),
});
}
let mouse = self.input.mouse;
for (field, value, valid) in [
(
"touchpad.accel-speed",
self.input.touchpad.accel_speed,
(-1.0..=1.0).contains(&self.input.touchpad.accel_speed),
),
(
"mouse.accel-speed",
mouse.accel_speed,
(-1.0..=1.0).contains(&mouse.accel_speed),
),
(
"mouse.scroll-factor",
mouse.scroll_factor,
(0.01..=100.0).contains(&mouse.scroll_factor),
),
(
"mouse.drag-threshold",
mouse.drag_threshold,
(0.0..=100.0).contains(&mouse.drag_threshold),
),
] {
if !value.is_finite() || !valid {
return Err(ConfigError::InputValue {
field,
value: value.to_string(),
});
}
}
if mouse.scroll_cooldown_ms > 5000 {
return Err(ConfigError::InputValue {
field: "mouse.scroll-cooldown-ms",
value: mouse.scroll_cooldown_ms.to_string(),
});
}
Ok(InputSettings {
mouse,
focus_follows_mouse: self.input.focus_follows_mouse,
xkb_layout: self.input.xkb_layout.clone(),
xkb_variant: self.input.xkb_variant.clone(),
@@ -39,6 +76,8 @@ impl Config {
repeat_delay_ms: self.input.repeat_delay_ms,
touchpad: TouchpadSettings {
tap: self.input.touchpad.tap,
accel_speed: self.input.touchpad.accel_speed,
disable_while_mouse_connected: self.input.touchpad.disable_while_mouse_connected,
natural_scroll: self.input.touchpad.natural_scroll,
disable_while_typing: self.input.touchpad.disable_while_typing,
swipe_threshold: self.input.touchpad.swipe_threshold,
@@ -85,11 +124,14 @@ pub struct InputSettings {
pub repeat_rate: i32,
pub repeat_delay_ms: i32,
pub touchpad: TouchpadSettings,
pub mouse: MouseSettings,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TouchpadSettings {
pub tap: bool,
pub accel_speed: f64,
pub disable_while_mouse_connected: bool,
pub natural_scroll: bool,
pub disable_while_typing: bool,
pub swipe_threshold: u16,
@@ -115,6 +157,8 @@ pub(super) struct InputConfig {
pub(super) repeat_delay_ms: i32,
#[serde(default)]
pub(super) touchpad: TouchpadConfig,
#[serde(default)]
pub(super) mouse: MouseSettings,
}
impl Default for InputConfig {
@@ -127,14 +171,19 @@ impl Default for InputConfig {
repeat_rate: default_repeat_rate(),
repeat_delay_ms: default_repeat_delay(),
touchpad: TouchpadConfig::default(),
mouse: MouseSettings::default(),
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct TouchpadConfig {
#[serde(default)]
pub(super) accel_speed: f64,
#[serde(default = "enabled_by_default")]
pub(super) tap: bool,
#[serde(default)]
pub(super) disable_while_mouse_connected: bool,
#[serde(default = "enabled_by_default")]
pub(super) natural_scroll: bool,
#[serde(default = "enabled_by_default")]
@@ -147,6 +196,8 @@ impl Default for TouchpadConfig {
fn default() -> Self {
Self {
tap: true,
accel_speed: 0.0,
disable_while_mouse_connected: false,
natural_scroll: true,
disable_while_typing: true,
swipe_threshold: default_swipe_threshold(),
@@ -160,6 +211,42 @@ pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
mod tests {
use super::*;
#[test]
fn mouse_settings_are_separate_from_touchpad_and_validate_ranges() {
let defaults = Config::parse_source("").unwrap().input_settings().unwrap();
assert!(!defaults.mouse.natural_scroll);
assert!(defaults.touchpad.natural_scroll);
assert!(!defaults.touchpad.disable_while_mouse_connected);
let config = Config::parse_source(r#"
input {
mouse { accel-speed -0.4; accel-profile "flat"; scroll-factor 0.5; left-handed #true; }
touchpad { disable-while-mouse-connected #true; accel-speed 0.35; }
}
"#).unwrap().input_settings().unwrap();
assert!(config.touchpad.disable_while_mouse_connected);
assert_eq!(config.touchpad.accel_speed, 0.35);
assert_eq!(config.mouse.accel_profile, Some(AccelerationProfile::Flat));
assert_eq!(config.mouse.accel_speed, -0.4);
assert_eq!(config.mouse.scroll_factor, 0.5);
assert!(config.mouse.left_handed);
for speed in [-1.01, 1.01] {
let config =
Config::parse_source(&format!("input {{ touchpad {{ accel-speed {speed}; }} }}"))
.unwrap();
assert!(config.input_settings().is_err());
}
for (key, value) in [
("accel-speed", "1.1"),
("scroll-factor", "0"),
("drag-threshold", "-1"),
("scroll-cooldown-ms", "5001"),
] {
let config =
Config::parse_source(&format!("input {{ mouse {{ {key} {value}; }} }}")).unwrap();
assert!(config.input_settings().is_err(), "{key}={value}");
}
}
#[test]
fn compositor_speed_uses_shared_motion_bounds() {
for speed in [1e-100, 0.009, 101.0, 1e100] {
@@ -169,3 +256,38 @@ mod tests {
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AccelerationProfile {
Adaptive,
Flat,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct MouseSettings {
pub natural_scroll: bool,
pub accel_speed: f64,
pub accel_profile: Option<AccelerationProfile>,
pub left_handed: bool,
pub middle_emulation: bool,
pub scroll_factor: f64,
pub drag_threshold: f64,
pub scroll_cooldown_ms: u64,
}
impl Default for MouseSettings {
fn default() -> Self {
Self {
natural_scroll: false,
accel_speed: 0.0,
accel_profile: None,
left_handed: false,
middle_emulation: false,
scroll_factor: 1.0,
drag_threshold: 4.0,
scroll_cooldown_ms: 150,
}
}
}