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

+58 -1
View File
@@ -424,6 +424,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
} }
InputEvent::DeviceRemoved { device } => { InputEvent::DeviceRemoved { device } => {
state.cancel_window_interaction();
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
backend backend
.input_devices .input_devices
@@ -437,6 +438,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
.handle() .handle()
.insert_source(notifier, move |event, _, state| match event { .insert_source(notifier, move |event, _, state| match event {
SessionEvent::PauseSession => { SessionEvent::PauseSession => {
state.cancel_window_interaction();
state.set_binding_mode("default"); state.set_binding_mode("default");
let handle = state.loop_handle.clone(); let handle = state.loop_handle.clone();
if let Some(backend) = state.direct_backend.as_mut() { if let Some(backend) = state.direct_backend.as_mut() {
@@ -522,6 +524,57 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
fn configure_libinput_device(state: &Ferese, device: &mut LibinputDevice) { fn configure_libinput_device(state: &Ferese, device: &mut LibinputDevice) {
let settings = state.input_settings.touchpad; let settings = state.input_settings.touchpad;
let touchpad = device.config_tap_finger_count() > 0 || device.config_dwt_is_available();
let mouse = state.input_settings.mouse;
if touchpad {
if device.config_accel_is_available()
&& let Err(error) = device.config_accel_set_speed(settings.accel_speed)
{
tracing::warn!(device = %device.name(), ?error, "failed to configure touchpad acceleration");
}
use smithay::reexports::input::SendEventsMode;
let mode = if settings.disable_while_mouse_connected {
SendEventsMode::DISABLED_ON_EXTERNAL_MOUSE
} else {
SendEventsMode::ENABLED
};
if device
.config_send_events_modes()
.contains(SendEventsMode::DISABLED_ON_EXTERNAL_MOUSE)
&& let Err(error) = device.config_send_events_set_mode(mode)
{
tracing::warn!(device = %device.name(), ?error, "failed to configure touchpad external-mouse policy");
}
}
if !touchpad && device.has_capability(smithay::reexports::input::DeviceCapability::Pointer) {
if device.config_accel_is_available() {
if let Err(error) = device.config_accel_set_speed(mouse.accel_speed) {
tracing::warn!(device = %device.name(), ?error, "failed to configure mouse acceleration");
}
use smithay::reexports::input::AccelProfile;
let profile = match mouse.accel_profile {
Some(crate::config::AccelerationProfile::Adaptive) => Some(AccelProfile::Adaptive),
Some(crate::config::AccelerationProfile::Flat) => Some(AccelProfile::Flat),
None => device.config_accel_default_profile(),
};
if let Some(profile) =
profile.filter(|profile| device.config_accel_profiles().contains(profile))
&& let Err(error) = device.config_accel_set_profile(profile)
{
tracing::warn!(device = %device.name(), ?error, "failed to configure mouse acceleration profile");
}
}
if device.config_left_handed_is_available()
&& let Err(error) = device.config_left_handed_set(mouse.left_handed)
{
tracing::warn!(device = %device.name(), ?error, "failed to configure mouse handedness");
}
if device.config_middle_emulation_is_available()
&& let Err(error) = device.config_middle_emulation_set_enabled(mouse.middle_emulation)
{
tracing::warn!(device = %device.name(), ?error, "failed to configure middle button emulation");
}
}
if device.config_tap_finger_count() > 0 if device.config_tap_finger_count() > 0
&& let Err(error) = device.config_tap_set_enabled(settings.tap) && let Err(error) = device.config_tap_set_enabled(settings.tap)
@@ -529,7 +582,11 @@ fn configure_libinput_device(state: &Ferese, device: &mut LibinputDevice) {
tracing::warn!(device = %device.name(), ?error, "failed to configure tap-to-click"); tracing::warn!(device = %device.name(), ?error, "failed to configure tap-to-click");
} }
if device.config_scroll_has_natural_scroll() if device.config_scroll_has_natural_scroll()
&& let Err(error) = device.config_scroll_set_natural_scroll_enabled(settings.natural_scroll) && let Err(error) = device.config_scroll_set_natural_scroll_enabled(if touchpad {
settings.natural_scroll
} else {
mouse.natural_scroll
})
{ {
tracing::warn!(device = %device.name(), ?error, "failed to configure natural scrolling"); tracing::warn!(device = %device.name(), ?error, "failed to configure natural scrolling");
} }
+8 -4
View File
@@ -19,10 +19,11 @@ pub use appearance::{BorderGradient, FocusEffectSettings, MaterialStyle, ThemeSe
pub use bindings::{Binding, BindingAction}; pub use bindings::{Binding, BindingAction};
use bindings::{BindingConfig, BindingMatch, BindingModeConfig, BindingModifiers}; use bindings::{BindingConfig, BindingMatch, BindingModeConfig, BindingModifiers};
pub(crate) use bindings::{BindingModes, BindingSet, physical_keymap}; pub(crate) use bindings::{BindingModes, BindingSet, physical_keymap};
pub(crate) use bindings::{PointerTrigger, ScrollDirection};
use ferese_animation::SpringConfig; use ferese_animation::SpringConfig;
use ferese_core::LayoutMode; use ferese_core::LayoutMode;
use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy}; use ferese_layout::{ColumnWidth, Direction, GapConfig, ViewportFocusStrategy};
pub use input_motion::InputSettings; pub use input_motion::{AccelerationProfile, InputSettings};
use input_motion::{AnimationsConfig, InputConfig}; use input_motion::{AnimationsConfig, InputConfig};
use outputs::OutputProfileConfig; use outputs::OutputProfileConfig;
pub use outputs::{ pub use outputs::{
@@ -1134,13 +1135,16 @@ mod tests {
config.input_settings().unwrap(), config.input_settings().unwrap(),
InputSettings { InputSettings {
focus_follows_mouse: true, focus_follows_mouse: true,
mouse: Default::default(),
xkb_layout: "us,de".to_owned(), xkb_layout: "us,de".to_owned(),
xkb_variant: ",nodeadkeys".to_owned(), xkb_variant: ",nodeadkeys".to_owned(),
xkb_options: vec!["grp:alt_shift_toggle".to_owned()], xkb_options: vec!["grp:alt_shift_toggle".to_owned()],
repeat_rate: 30, repeat_rate: 30,
repeat_delay_ms: 450, repeat_delay_ms: 450,
touchpad: TouchpadSettings { touchpad: TouchpadSettings {
accel_speed: 0.0,
tap: false, tap: false,
disable_while_mouse_connected: false,
natural_scroll: false, natural_scroll: false,
disable_while_typing: true, disable_while_typing: true,
swipe_threshold: 80, swipe_threshold: 80,
@@ -1303,7 +1307,7 @@ mod tests {
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(), 63); assert_eq!(bindings.len(), 65);
for (shift, action) in [ for (shift, action) in [
(false, BindingAction::ToggleMaximized), (false, BindingAction::ToggleMaximized),
(true, BindingAction::ToggleFullscreen), (true, BindingAction::ToggleFullscreen),
@@ -1362,7 +1366,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(), 63); assert_eq!(bindings.len(), 65);
assert!(bindings.iter().any(|binding| { assert!(bindings.iter().any(|binding| {
binding.action binding.action
== BindingAction::Spawn( == BindingAction::Spawn(
@@ -1372,7 +1376,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(), 62); assert_eq!(bindings.len(), 64);
assert!( assert!(
!bindings !bindings
.iter() .iter()
+45 -2
View File
@@ -7,9 +7,11 @@ use super::{
mod index; mod index;
mod modes; mod modes;
mod pointer;
pub(crate) use index::BindingSet; pub(crate) use index::BindingSet;
pub(super) use modes::BindingModeConfig; pub(super) use modes::BindingModeConfig;
pub(crate) use modes::BindingModes; pub(crate) use modes::BindingModes;
pub(crate) use pointer::{PointerTrigger, ScrollDirection};
impl Config { impl Config {
fn binding_commands(&self) -> HashMap<String, Vec<String>> { fn binding_commands(&self) -> HashMap<String, Vec<String>> {
@@ -120,6 +122,7 @@ pub(super) 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::Pointer => BindingTrigger::Pointer(PointerTrigger::from_key(identity.key)),
BindingMatch::Swipe => BindingTrigger::Swipe { BindingMatch::Swipe => BindingTrigger::Swipe {
fingers: identity.key / 4, fingers: identity.key / 4,
direction: [ 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 { Ok(Binding {
modifiers: identity.modifiers, modifiers: identity.modifiers,
trigger, trigger,
@@ -143,6 +155,18 @@ pub(super) 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(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 let Some((fingers, direction)) = parse_swipe(&key) {
if configured.match_mode == BindingMatch::Physical if configured.match_mode == BindingMatch::Physical
|| modifiers != BindingModifiers::default() || modifiers != BindingModifiers::default()
@@ -168,7 +192,7 @@ pub(super) fn binding_identity(
configured.keys configured.keys
)) ))
})?, })?,
BindingMatch::Swipe => { BindingMatch::Swipe | BindingMatch::Pointer => {
return Err(ConfigError::Binding( return Err(ConfigError::Binding(
"gesture keys must use Swipe3Left/Right/Up/Down (3–5 fingers)".into(), "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 { 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::Pointer(trigger) => (BindingMatch::Pointer, trigger.key()),
BindingTrigger::Swipe { fingers, direction } => { BindingTrigger::Swipe { fingers, direction } => {
(BindingMatch::Swipe, fingers * 4 + direction as u32) (BindingMatch::Swipe, fingers * 4 + direction as u32)
} }
@@ -292,6 +317,14 @@ pub(super) fn parse_action(
}; };
match 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())), "binding-mode" => Ok(BindingAction::BindingMode(required_argument()?.to_owned())),
"none" => { "none" => {
no_argument()?; no_argument()?;
@@ -447,6 +480,8 @@ pub(super) fn parse_workspace(argument: &str) -> Result<u8, ConfigError> {
pub(super) fn default_bindings() -> Vec<BindingConfig> { pub(super) fn default_bindings() -> Vec<BindingConfig> {
let mut bindings = vec![ let mut bindings = vec![
binding("Super+MouseLeft", "pointer-move", None),
binding("Super+MouseRight", "pointer-resize", None),
binding("Super+Space", "toggle-launcher", None), binding("Super+Space", "toggle-launcher", None),
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")),
@@ -538,6 +573,7 @@ pub struct Binding {
pub(super) enum BindingTrigger { pub(super) enum BindingTrigger {
Keysym(u32), Keysym(u32),
Physical(Keycode), Physical(Keycode),
Pointer(PointerTrigger),
Swipe { Swipe {
fingers: u32, fingers: u32,
direction: crate::gestures::SwipeDirection, direction: crate::gestures::SwipeDirection,
@@ -563,6 +599,8 @@ pub enum BindingAction {
FocusLastWindow, FocusLastWindow,
FocusFloating, FocusFloating,
FocusMru(bool), FocusMru(bool),
PointerMove,
PointerResize,
Move(ferese_layout::Direction), Move(ferese_layout::Direction),
Resize(ferese_layout::Direction), Resize(ferese_layout::Direction),
SwitchWorkspace(u8), SwitchWorkspace(u8),
@@ -664,7 +702,7 @@ 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, BindingTrigger::Swipe { .. } | BindingTrigger::Pointer(_) => false,
} }
} }
@@ -704,6 +742,8 @@ impl Binding {
"Next window in focus history" "Next window in focus history"
} }
.into(), .into(),
BindingAction::PointerMove => "Drag window".into(),
BindingAction::PointerResize => "Resize window with pointer".into(),
BindingAction::Move(direction) => format!("Move window {direction:?}"), BindingAction::Move(direction) => format!("Move window {direction:?}"),
BindingAction::Resize(direction) => format!("Resize window {direction:?}"), BindingAction::Resize(direction) => format!("Resize window {direction:?}"),
BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"), BindingAction::SwitchWorkspace(index) => format!("Go to workspace {index}"),
@@ -757,6 +797,7 @@ impl Binding {
} }
} }
parts.push(match self.trigger { parts.push(match self.trigger {
BindingTrigger::Pointer(trigger) => trigger.label(),
BindingTrigger::Keysym(symbol) => { BindingTrigger::Keysym(symbol) => {
let name = xkb::keysym_get_name(xkb::Keysym::new(symbol)); let name = xkb::keysym_get_name(xkb::Keysym::new(symbol));
match name.as_str() { match name.as_str() {
@@ -798,6 +839,8 @@ pub(super) enum BindingMatch {
Keysym, Keysym,
Physical, Physical,
Swipe, Swipe,
#[serde(skip)]
Pointer,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
+32 -9
View File
@@ -10,27 +10,30 @@ use super::{
binding_identity_for_runtime, 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. /// The ordered slice remains available for gestures and the shortcut guide.
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct BindingSet { pub(crate) struct BindingSet {
ordered: Vec<Binding>, ordered: Vec<Binding>,
keyboard: HashMap<BindingIdentity, usize>, index: HashMap<BindingIdentity, usize>,
} }
impl From<Vec<Binding>> for BindingSet { impl From<Vec<Binding>> for BindingSet {
fn from(ordered: Vec<Binding>) -> Self { 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); let identity = binding_identity_for_runtime(binding);
if identity.match_mode != BindingMatch::Swipe { if matches!(
keyboard.entry(identity).or_insert(index); 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 { 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( pub(crate) fn binding_for_key(
&self, &self,
keycode: Keycode, keycode: Keycode,
@@ -58,13 +81,13 @@ impl BindingSet {
alt, alt,
shift, shift,
}; };
let physical = self.keyboard.get(&BindingIdentity { let physical = self.index.get(&BindingIdentity {
modifiers, modifiers,
match_mode: BindingMatch::Physical, match_mode: BindingMatch::Physical,
key: keycode.raw(), key: keycode.raw(),
}); });
let symbolic = symbols.iter().filter_map(|&key| { let symbolic = symbols.iter().filter_map(|&key| {
self.keyboard.get(&BindingIdentity { self.index.get(&BindingIdentity {
modifiers, modifiers,
match_mode: BindingMatch::Keysym, match_mode: BindingMatch::Keysym,
key, 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(), 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 { Ok(InputSettings {
mouse,
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(),
xkb_variant: self.input.xkb_variant.clone(), xkb_variant: self.input.xkb_variant.clone(),
@@ -39,6 +76,8 @@ impl Config {
repeat_delay_ms: self.input.repeat_delay_ms, repeat_delay_ms: self.input.repeat_delay_ms,
touchpad: TouchpadSettings { touchpad: TouchpadSettings {
tap: self.input.touchpad.tap, 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, 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, swipe_threshold: self.input.touchpad.swipe_threshold,
@@ -85,11 +124,14 @@ pub struct InputSettings {
pub repeat_rate: i32, pub repeat_rate: i32,
pub repeat_delay_ms: i32, pub repeat_delay_ms: i32,
pub touchpad: TouchpadSettings, pub touchpad: TouchpadSettings,
pub mouse: MouseSettings,
} }
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct TouchpadSettings { pub struct TouchpadSettings {
pub tap: bool, pub tap: bool,
pub accel_speed: f64,
pub disable_while_mouse_connected: bool,
pub natural_scroll: bool, pub natural_scroll: bool,
pub disable_while_typing: bool, pub disable_while_typing: bool,
pub swipe_threshold: u16, pub swipe_threshold: u16,
@@ -115,6 +157,8 @@ pub(super) struct InputConfig {
pub(super) repeat_delay_ms: i32, pub(super) repeat_delay_ms: i32,
#[serde(default)] #[serde(default)]
pub(super) touchpad: TouchpadConfig, pub(super) touchpad: TouchpadConfig,
#[serde(default)]
pub(super) mouse: MouseSettings,
} }
impl Default for InputConfig { impl Default for InputConfig {
@@ -127,14 +171,19 @@ impl Default for InputConfig {
repeat_rate: default_repeat_rate(), repeat_rate: default_repeat_rate(),
repeat_delay_ms: default_repeat_delay(), repeat_delay_ms: default_repeat_delay(),
touchpad: TouchpadConfig::default(), touchpad: TouchpadConfig::default(),
mouse: MouseSettings::default(),
} }
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(super) struct TouchpadConfig { pub(super) struct TouchpadConfig {
#[serde(default)]
pub(super) accel_speed: f64,
#[serde(default = "enabled_by_default")] #[serde(default = "enabled_by_default")]
pub(super) tap: bool, pub(super) tap: bool,
#[serde(default)]
pub(super) disable_while_mouse_connected: bool,
#[serde(default = "enabled_by_default")] #[serde(default = "enabled_by_default")]
pub(super) natural_scroll: bool, pub(super) natural_scroll: bool,
#[serde(default = "enabled_by_default")] #[serde(default = "enabled_by_default")]
@@ -147,6 +196,8 @@ impl Default for TouchpadConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
tap: true, tap: true,
accel_speed: 0.0,
disable_while_mouse_connected: false,
natural_scroll: true, natural_scroll: true,
disable_while_typing: true, disable_while_typing: true,
swipe_threshold: default_swipe_threshold(), swipe_threshold: default_swipe_threshold(),
@@ -160,6 +211,42 @@ pub(super) type AnimationsConfig = ferese_animation::MotionSettings;
mod tests { mod tests {
use super::*; 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] #[test]
fn compositor_speed_uses_shared_motion_bounds() { fn compositor_speed_uses_shared_motion_bounds() {
for speed in [1e-100, 0.009, 101.0, 1e100] { 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,
}
}
}
+5 -3
View File
@@ -1,5 +1,7 @@
mod move_grab;
mod resize_grab; mod resize_grab;
pub use move_grab::MoveSurfaceGrab; pub use resize_grab::ResizeEdge;
pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab};
mod window_grab;
pub(crate) use resize_grab::{constrained_size, resized_rect};
pub(crate) use window_grab::WindowPointerGrab;
+15 -285
View File
@@ -1,17 +1,5 @@
use smithay::desktop::Window;
use smithay::input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent,
GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent,
GestureSwipeUpdateEvent, GrabStartData, MotionEvent, PointerGrab, PointerInnerHandle,
RelativeMotionEvent,
};
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Rectangle, Size}; use smithay::utils::{Logical, Point, Rectangle, Size};
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::SurfaceCachedState;
use crate::Ferese;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ResizeEdge(xdg_toplevel::ResizeEdge); pub struct ResizeEdge(xdg_toplevel::ResizeEdge);
@@ -23,6 +11,15 @@ impl From<xdg_toplevel::ResizeEdge> for ResizeEdge {
} }
impl ResizeEdge { impl ResizeEdge {
pub(crate) fn cursor(self) -> smithay::input::pointer::CursorIcon {
use smithay::input::pointer::CursorIcon;
match (self.left(), self.right(), self.top(), self.bottom()) {
(true, _, true, _) | (_, true, _, true) => CursorIcon::NwseResize,
(true, _, _, true) | (_, true, true, _) => CursorIcon::NeswResize,
(true, _, _, _) | (_, true, _, _) => CursorIcon::EwResize,
_ => CursorIcon::NsResize,
}
}
pub fn at(point: Point<f64, Logical>, rect: Rectangle<i32, Logical>) -> Self { pub fn at(point: Point<f64, Logical>, rect: Rectangle<i32, Logical>) -> Self {
use xdg_toplevel::ResizeEdge::*; use xdg_toplevel::ResizeEdge::*;
let x = (point.x - rect.loc.x as f64) / rect.size.w.max(1) as f64; let x = (point.x - rect.loc.x as f64) / rect.size.w.max(1) as f64;
@@ -46,7 +43,7 @@ impl ResizeEdge {
_ => Bottom, _ => Bottom,
}) })
} }
fn left(self) -> bool { pub(crate) fn left(self) -> bool {
matches!( matches!(
self.0, self.0,
xdg_toplevel::ResizeEdge::Left xdg_toplevel::ResizeEdge::Left
@@ -55,7 +52,7 @@ impl ResizeEdge {
) )
} }
fn right(self) -> bool { pub(crate) fn right(self) -> bool {
matches!( matches!(
self.0, self.0,
xdg_toplevel::ResizeEdge::Right xdg_toplevel::ResizeEdge::Right
@@ -64,7 +61,7 @@ impl ResizeEdge {
) )
} }
fn top(self) -> bool { pub(crate) fn top(self) -> bool {
matches!( matches!(
self.0, self.0,
xdg_toplevel::ResizeEdge::Top xdg_toplevel::ResizeEdge::Top
@@ -73,7 +70,7 @@ impl ResizeEdge {
) )
} }
fn bottom(self) -> bool { pub(crate) fn bottom(self) -> bool {
matches!( matches!(
self.0, self.0,
xdg_toplevel::ResizeEdge::Bottom xdg_toplevel::ResizeEdge::Bottom
@@ -83,274 +80,7 @@ impl ResizeEdge {
} }
} }
pub struct ResizeSurfaceGrab { pub(crate) fn constrained_size(
start_data: GrabStartData<Ferese>,
window: Window,
edges: ResizeEdge,
initial_rect: Rectangle<i32, Logical>,
last_size: Size<i32, Logical>,
finished: bool,
snap_x: crate::floating::AxisSnap,
snap_y: crate::floating::AxisSnap,
}
impl ResizeSurfaceGrab {
pub fn new(
start_data: GrabStartData<Ferese>,
window: Window,
edges: ResizeEdge,
initial_rect: Rectangle<i32, Logical>,
) -> Self {
Self {
start_data,
window,
edges,
initial_rect,
last_size: initial_rect.size,
finished: false,
snap_x: Default::default(),
snap_y: Default::default(),
}
}
}
impl PointerGrab<Ferese> for ResizeSurfaceGrab {
fn motion(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
_focus: Option<(WlSurface, Point<f64, Logical>)>,
event: &MotionEvent,
) {
handle.motion(data, None, event);
let mut delta = event.location - self.start_data.location;
let initial = self.initial_rect;
let raw_left = initial.loc.x as f64 + if self.edges.left() { delta.x } else { 0. };
let raw_top = initial.loc.y as f64 + if self.edges.top() { delta.y } else { 0. };
let raw_right = initial.loc.x as f64
+ initial.size.w as f64
+ if self.edges.right() { delta.x } else { 0. };
let raw_bottom = initial.loc.y as f64
+ initial.size.h as f64
+ if self.edges.bottom() { delta.y } else { 0. };
let raw = ferese_layout::Rect::new(
raw_left,
raw_top,
(raw_right - raw_left).max(1.),
(raw_bottom - raw_top).max(1.),
);
let (xs, ys) = data.floating_snap_lines(&self.window, raw);
if data.floating_snap_bypassed() {
self.snap_x.clear();
self.snap_y.clear();
} else {
if self.edges.left() || self.edges.right() {
let moving = if self.edges.left() {
raw_left
} else {
raw_right
};
delta.x += self.snap_x.apply(moving, 0., &xs) - moving;
}
if self.edges.top() || self.edges.bottom() {
let moving = if self.edges.top() {
raw_top
} else {
raw_bottom
};
delta.y += self.snap_y.apply(moving, 0., &ys) - moving;
}
}
let surface = self
.window
.toplevel()
.expect("managed window has a toplevel");
let (minimum, maximum) = with_states(surface.wl_surface(), |states| {
let mut cached = states.cached_state.get::<SurfaceCachedState>();
let state = cached.current();
(state.min_size, state.max_size)
});
let (minimum, maximum) = data.effective_size_constraints(
&self.window,
(minimum.w, minimum.h),
(maximum.w, maximum.h),
);
self.last_size = constrained_size(
self.initial_rect.size,
delta,
self.edges,
minimum,
(maximum.0, maximum.1),
);
let rect = resized_rect(self.initial_rect, self.last_size, self.edges);
data.set_floating_window_geometry(&self.window, rect.loc, rect.size);
if let Some(id) = data.windows.ids().get(&self.window).copied() {
data.windows.update(id, |w| {
w.resize_anchor = Some((
self.edges.left(),
self.edges.top(),
ferese_layout::Rect::new(
initial.loc.x as f64,
initial.loc.y as f64,
initial.size.w as f64,
initial.size.h as f64,
),
))
});
}
surface.with_pending_state(|state| {
state.states.set(xdg_toplevel::State::Resizing);
state.size = Some(self.last_size);
});
surface.send_pending_configure();
}
fn relative_motion(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
focus: Option<(WlSurface, Point<f64, Logical>)>,
event: &RelativeMotionEvent,
) {
handle.relative_motion(data, focus, event);
}
fn button(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &ButtonEvent,
) {
handle.button(data, event);
if handle.current_pressed().is_empty() {
self.finished = true;
data.remember_floating(&self.window);
handle.unset_grab(self, data, event.serial, event.time, true);
let surface = self
.window
.toplevel()
.expect("managed window has a toplevel");
surface.with_pending_state(|state| {
state.states.unset(xdg_toplevel::State::Resizing);
state.size = Some(self.last_size);
});
surface.send_pending_configure();
}
}
fn axis(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
frame: AxisFrame,
) {
handle.axis(data, frame);
}
fn frame(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>) {
handle.frame(data);
}
fn gesture_swipe_begin(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureSwipeBeginEvent,
) {
handle.gesture_swipe_begin(data, event);
}
fn gesture_swipe_update(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureSwipeUpdateEvent,
) {
handle.gesture_swipe_update(data, event);
}
fn gesture_swipe_end(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureSwipeEndEvent,
) {
handle.gesture_swipe_end(data, event);
}
fn gesture_pinch_begin(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GesturePinchBeginEvent,
) {
handle.gesture_pinch_begin(data, event);
}
fn gesture_pinch_update(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GesturePinchUpdateEvent,
) {
handle.gesture_pinch_update(data, event);
}
fn gesture_pinch_end(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GesturePinchEndEvent,
) {
handle.gesture_pinch_end(data, event);
}
fn gesture_hold_begin(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureHoldBeginEvent,
) {
handle.gesture_hold_begin(data, event);
}
fn gesture_hold_end(
&mut self,
data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>,
event: &GestureHoldEndEvent,
) {
handle.gesture_hold_end(data, event);
}
fn start_data(&self) -> &GrabStartData<Ferese> {
&self.start_data
}
fn unset(&mut self, data: &mut Ferese) {
if self.finished {
return;
}
let surface = self
.window
.toplevel()
.expect("managed window has a toplevel");
surface.with_pending_state(|state| {
state.states.unset(xdg_toplevel::State::Resizing);
state.size = Some(self.initial_rect.size);
});
surface.send_pending_configure();
data.set_floating_window_geometry(
&self.window,
self.initial_rect.loc,
self.initial_rect.size,
);
}
}
fn constrained_size(
initial: Size<i32, Logical>, initial: Size<i32, Logical>,
delta: Point<f64, Logical>, delta: Point<f64, Logical>,
edges: ResizeEdge, edges: ResizeEdge,
@@ -392,7 +122,7 @@ fn constrained_size(
.into() .into()
} }
fn resized_rect( pub(crate) fn resized_rect(
initial: Rectangle<i32, Logical>, initial: Rectangle<i32, Logical>,
size: Size<i32, Logical>, size: Size<i32, Logical>,
edges: ResizeEdge, edges: ResizeEdge,
@@ -1,4 +1,3 @@
use smithay::desktop::Window;
use smithay::input::pointer::{ use smithay::input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent,
GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent, GesturePinchEndEvent, GesturePinchUpdateEvent, GestureSwipeBeginEvent, GestureSwipeEndEvent,
@@ -6,22 +5,16 @@ use smithay::input::pointer::{
RelativeMotionEvent, RelativeMotionEvent,
}; };
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::{Logical, Point, Size}; use smithay::utils::{Logical, Point};
use crate::Ferese; use crate::Ferese;
use crate::floating::AxisSnap;
pub struct MoveSurfaceGrab { pub(crate) struct WindowPointerGrab {
pub start_data: GrabStartData<Ferese>, pub start_data: GrabStartData<Ferese>,
pub window: Window,
pub initial_location: Point<i32, Logical>,
pub initial_size: Size<i32, Logical>,
pub finished: bool, pub finished: bool,
pub snap_x: AxisSnap,
pub snap_y: AxisSnap,
} }
impl PointerGrab<Ferese> for MoveSurfaceGrab { impl PointerGrab<Ferese> for WindowPointerGrab {
fn motion( fn motion(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
@@ -30,36 +23,21 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
event: &MotionEvent, event: &MotionEvent,
) { ) {
handle.motion(data, None, event); handle.motion(data, None, event);
let location = self.initial_location.to_f64() + (event.location - self.start_data.location); if !self.finished
let raw = ferese_layout::Rect::new( && let Some(operation) = data.pointer_operation.as_mut()
location.x, {
location.y, operation.location = event.location;
self.initial_size.w as f64,
self.initial_size.h as f64,
);
let (xs, ys) = data.floating_snap_lines(&self.window, raw);
let location = if data.floating_snap_bypassed() {
self.snap_x.clear();
self.snap_y.clear();
location
} else {
Point::from((
self.snap_x.apply(raw.x, raw.width, &xs),
self.snap_y.apply(raw.y, raw.height, &ys),
))
} }
.to_i32_round();
data.set_floating_window_geometry(&self.window, location, self.initial_size);
} }
fn relative_motion( fn relative_motion(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>, handle: &mut PointerInnerHandle<'_, Ferese>,
focus: Option<(WlSurface, Point<f64, Logical>)>, _focus: Option<(WlSurface, Point<f64, Logical>)>,
event: &RelativeMotionEvent, event: &RelativeMotionEvent,
) { ) {
handle.relative_motion(data, focus, event); handle.relative_motion(data, None, event);
} }
fn button( fn button(
@@ -68,21 +46,46 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
handle: &mut PointerInnerHandle<'_, Ferese>, handle: &mut PointerInnerHandle<'_, Ferese>,
event: &ButtonEvent, event: &ButtonEvent,
) { ) {
if let Some(operation) = data.pointer_operation.as_mut() {
if event.state == smithay::backend::input::ButtonState::Pressed {
operation.pressed.insert(event.button);
if event.button == 0x111 && event.button != self.start_data.button {
operation.toggle_drop_mode();
}
} else {
operation.pressed.remove(&event.button);
}
}
handle.button(data, event); handle.button(data, event);
if handle.current_pressed().is_empty() { if event.button == self.start_data.button
&& event.state == smithay::backend::input::ButtonState::Released
{
self.finished = true; self.finished = true;
data.remember_floating(&self.window); if let Some(operation) = data.pointer_operation.as_mut() {
handle.unset_grab(self, data, event.serial, event.time, true); operation.finished = true;
}
}
// Extra buttons are drained with focus cleared, so their releases cannot
// reach a client that never received the corresponding presses.
if handle.current_pressed().is_empty() {
handle.unset_grab(self, data, event.serial, event.time, false);
} }
} }
fn axis( fn axis(
&mut self, &mut self,
data: &mut Ferese, data: &mut Ferese,
handle: &mut PointerInnerHandle<'_, Ferese>, _handle: &mut PointerInnerHandle<'_, Ferese>,
frame: AxisFrame, frame: AxisFrame,
) { ) {
handle.axis(data, frame); if let Some(operation) = data.pointer_operation.as_mut() {
let amount = if frame.axis.0 != 0.0 {
frame.axis.0
} else {
frame.axis.1
};
operation.scroll(amount);
}
} }
fn frame(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>) { fn frame(&mut self, data: &mut Ferese, handle: &mut PointerInnerHandle<'_, Ferese>) {
@@ -166,10 +169,12 @@ impl PointerGrab<Ferese> for MoveSurfaceGrab {
} }
fn unset(&mut self, data: &mut Ferese) { fn unset(&mut self, data: &mut Ferese) {
if self.finished { // Resolve focus after the layout settles and the pointer mutex is released.
return; data.window_grab_focus_pending = true;
if !self.finished
&& let Some(operation) = data.pointer_operation.as_mut()
{
operation.cancelled = true;
} }
data.set_floating_window_geometry(&self.window, self.initial_location, self.initial_size);
} }
} }
+10 -32
View File
@@ -19,7 +19,7 @@ use smithay::wayland::shell::xdg::{
}; };
use crate::Ferese; use crate::Ferese;
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab}; use crate::grabs::ResizeEdge;
impl XdgShellHandler for Ferese { impl XdgShellHandler for Ferese {
fn xdg_shell_state(&mut self) -> &mut XdgShellState { fn xdg_shell_state(&mut self) -> &mut XdgShellState {
@@ -150,26 +150,7 @@ impl XdgShellHandler for Ferese {
else { else {
return; return;
}; };
let Some(initial_rect) = self.visual_rect_for_window(&window) else { self.begin_window_interaction(&pointer, window, start_data, serial, None);
return;
};
if !self.is_floating_window(&window) {
return;
}
pointer.set_grab(
self,
MoveSurfaceGrab {
start_data,
window,
initial_location: initial_rect.loc,
initial_size: initial_rect.size,
finished: false,
snap_x: Default::default(),
snap_y: Default::default(),
},
serial,
Focus::Clear,
);
} }
fn resize_request( fn resize_request(
@@ -203,18 +184,15 @@ impl XdgShellHandler for Ferese {
else { else {
return; return;
}; };
let Some(rect) = self.visual_rect_for_window(&window) else { if edges != xdg_toplevel::ResizeEdge::None {
return; self.begin_window_interaction(
}; &pointer,
if !self.is_floating_window(&window) { window,
return; start_data,
serial,
Some(ResizeEdge::from(edges)),
);
} }
pointer.set_grab(
self,
ResizeSurfaceGrab::new(start_data, window, ResizeEdge::from(edges), rect),
serial,
Focus::Clear,
);
} }
fn maximize_request(&mut self, surface: ToplevelSurface) { fn maximize_request(&mut self, surface: ToplevelSurface) {
+101 -80
View File
@@ -1,6 +1,10 @@
mod window;
pub(crate) use window::WindowInteraction;
mod pointer;
use ferese_layout::Direction; use ferese_layout::Direction;
pub(crate) use pointer::ScrollBindings;
use smithay::backend::input::{ use smithay::backend::input::{
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent, AbsolutePositionEvent, Axis, AxisSource, ButtonState, Device, Event, GestureBeginEvent,
GestureEndEvent, GestureSwipeUpdateEvent as _, InputBackend, InputEvent, KeyState, GestureEndEvent, GestureSwipeUpdateEvent as _, InputBackend, InputEvent, KeyState,
KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, Switch, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, Switch,
SwitchState, SwitchToggleEvent, TouchEvent, SwitchState, SwitchToggleEvent, TouchEvent,
@@ -20,11 +24,10 @@ use smithay::wayland::pointer_constraints::{PointerConstraint, with_pointer_cons
use crate::Ferese; use crate::Ferese;
use crate::config::BindingAction; use crate::config::BindingAction;
use crate::gestures::SwipeDirection; use crate::gestures::SwipeDirection;
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab}; use crate::grabs::ResizeEdge;
use crate::process::spawn_client; use crate::process::spawn_client;
const BTN_LEFT: u32 = 0x110; const BTN_LEFT: u32 = 0x110;
const BTN_RIGHT: u32 = 0x111;
#[cfg(test)] #[cfg(test)]
pub(crate) mod binding_mode_tests; pub(crate) mod binding_mode_tests;
@@ -130,6 +133,11 @@ impl Ferese {
} }
pub fn process_input_event<I: InputBackend>(&mut self, event: InputEvent<I>) { pub fn process_input_event<I: InputBackend>(&mut self, event: InputEvent<I>) {
self.process_input_event_inner(event);
self.poll_window_interaction();
}
fn process_input_event_inner<I: InputBackend>(&mut self, event: InputEvent<I>) {
if self.input_capture.active() { if self.input_capture.active() {
self.set_binding_mode("default"); self.set_binding_mode("default");
} }
@@ -372,6 +380,11 @@ impl Ferese {
}; };
} }
let symbol = keysym.modified_sym().raw(); let symbol = keysym.modified_sym().raw();
if state == KeyState::Pressed && symbol == keysyms::KEY_Escape && let Some(operation) = data.pointer_operation.as_mut() {
operation.cancelled = true;
data.intercepted_keys.insert(keycode);
return FilterResult::Intercept(());
}
if data.binding_modes.active().is_some() && symbol == keysyms::KEY_Escape { if data.binding_modes.active().is_some() && symbol == keysyms::KEY_Escape {
if state == KeyState::Pressed { if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode); data.intercepted_keys.insert(keycode);
@@ -586,17 +599,21 @@ impl Ferese {
} }
InputEvent::PointerButton { event, .. } => { InputEvent::PointerButton { event, .. } => {
self.last_pointer_time = event.time(); self.last_pointer_time = event.time();
if self.input_capture.active() { if event.state() == ButtonState::Pressed {
if self.input_capture.captures(2) { // A disconnected device may never deliver its consumed release.
self.input_capture.push(serde_json::json!({"type":"button", "button":event.button_code(), "pressed":event.state() == ButtonState::Pressed})); self.consumed_pointer_buttons.remove(&event.button_code());
}
return;
} }
if event.state() == ButtonState::Released if event.state() == ButtonState::Released
&& self.consumed_pointer_buttons.remove(&event.button_code()) && self.consumed_pointer_buttons.remove(&event.button_code())
{ {
return; return;
} }
if self.input_capture.active() {
if self.input_capture.captures(2) {
self.input_capture.push(serde_json::json!({"type":"button", "button":event.button_code(), "pressed":event.state() == ButtonState::Pressed}));
}
return;
}
let pointer = self.seat.get_pointer().expect("seat has a pointer"); let pointer = self.seat.get_pointer().expect("seat has a pointer");
let serial = SERIAL_COUNTER.next_serial(); let serial = SERIAL_COUNTER.next_serial();
if self.session_lock.active() { if self.session_lock.active() {
@@ -709,8 +726,11 @@ impl Ferese {
} }
if event.state() == ButtonState::Pressed if event.state() == ButtonState::Pressed
&& self.start_floating_pointer_grab(&pointer, event.button_code(), serial) && self.handle_pointer_binding(&pointer, event.button_code(), serial)
{ {
if self.consumed_pointer_buttons.contains(&event.button_code()) {
return;
}
pointer.button( pointer.button(
self, self,
&ButtonEvent { &ButtonEvent {
@@ -764,47 +784,82 @@ impl Ferese {
} }
return; return;
} }
let consumed = self.filter_pointer_scroll(
event.device().id(),
source,
[horizontal, vertical],
[
event.amount_v120(Axis::Horizontal),
event.amount_v120(Axis::Vertical),
],
[
source == AxisSource::Finger && event.amount(Axis::Horizontal) == Some(0.0),
source == AxisSource::Finger && event.amount(Axis::Vertical) == Some(0.0),
],
);
let horizontal = if consumed[0] { 0.0 } else { horizontal };
let vertical = if consumed[1] { 0.0 } else { vertical };
if consumed == [true, true] {
return;
}
let pointer = self.seat.get_pointer().expect("seat has a pointer"); let pointer = self.seat.get_pointer().expect("seat has a pointer");
if self.scroll_tab_indicator( if !pointer.is_grabbed()
pointer.current_location(), && self.scroll_tab_indicator(
if vertical != 0.0 { pointer.current_location(),
vertical if vertical != 0.0 {
} else { vertical
horizontal } else {
}, horizontal
) { },
)
{
return; return;
} }
if self.scroll_overview_strip( if !pointer.is_grabbed()
pointer.current_location(), && self.scroll_overview_strip(
if horizontal != 0.0 { pointer.current_location(),
horizontal if horizontal != 0.0 {
} else { horizontal
vertical } else {
}, vertical
) { },
)
{
return; return;
} }
let ([horizontal, vertical], scaled_v120) = self.scale_mouse_scroll(
event.device().id(),
source,
[horizontal, vertical],
[
(!consumed[0])
.then(|| event.amount_v120(Axis::Horizontal))
.flatten(),
(!consumed[1])
.then(|| event.amount_v120(Axis::Vertical))
.flatten(),
],
);
let mut frame = AxisFrame::new(event.time()).source(source); let mut frame = AxisFrame::new(event.time()).source(source);
if horizontal != 0.0 { if horizontal != 0.0 {
frame = frame.value(Axis::Horizontal, horizontal); frame = frame.value(Axis::Horizontal, horizontal);
if let Some(value) = event.amount_v120(Axis::Horizontal) { if let Some(value) = scaled_v120[0] {
frame = frame.v120(Axis::Horizontal, value as i32); frame = frame.v120(Axis::Horizontal, value);
} }
} }
if vertical != 0.0 { if vertical != 0.0 {
frame = frame.value(Axis::Vertical, vertical); frame = frame.value(Axis::Vertical, vertical);
if let Some(value) = event.amount_v120(Axis::Vertical) { if let Some(value) = scaled_v120[1] {
frame = frame.v120(Axis::Vertical, value as i32); frame = frame.v120(Axis::Vertical, value);
} }
} }
if source == AxisSource::Finger { if source == AxisSource::Finger {
if event.amount(Axis::Horizontal) == Some(0.0) { if !consumed[0] && event.amount(Axis::Horizontal) == Some(0.0) {
frame = frame.stop(Axis::Horizontal); frame = frame.stop(Axis::Horizontal);
} }
if event.amount(Axis::Vertical) == Some(0.0) { if !consumed[1] && event.amount(Axis::Vertical) == Some(0.0) {
frame = frame.stop(Axis::Vertical); frame = frame.stop(Axis::Vertical);
} }
} }
@@ -925,23 +980,13 @@ impl Ferese {
true true
} }
fn start_floating_pointer_grab( fn start_pointer_window_grab(
&mut self, &mut self,
pointer: &PointerHandle<Self>, pointer: &PointerHandle<Self>,
button: u32, button: u32,
serial: Serial, serial: Serial,
resize: bool,
) -> bool { ) -> bool {
if self.session_lock.active()
|| pointer.is_grabbed()
|| !matches!(button, BTN_LEFT | BTN_RIGHT)
|| !self
.seat
.get_keyboard()
.is_some_and(|keyboard| keyboard.modifier_state().logo)
{
return false;
}
let location = pointer.current_location(); let location = pointer.current_location();
if self.layer_under(location).is_some() { if self.layer_under(location).is_some() {
return false; return false;
@@ -949,46 +994,21 @@ impl Ferese {
let Some(window) = self.window_under_visual(location) else { let Some(window) = self.window_under_visual(location) else {
return false; return false;
}; };
if !self.is_floating_window(&window) {
return false;
}
let Some(rect) = self.visual_rect_for_window(&window) else { let Some(rect) = self.visual_rect_for_window(&window) else {
return false; return false;
}; };
self.focus_window_at(location, serial, true); self.focus_window_at(location, serial, true);
let start_data = GrabStartData { self.begin_window_interaction(
focus: None, pointer,
button, window,
location, GrabStartData {
}; focus: None,
button,
if button == BTN_LEFT { location,
pointer.set_grab( },
self, serial,
MoveSurfaceGrab { resize.then(|| ResizeEdge::at(location, rect)),
start_data, )
window,
initial_location: rect.loc,
initial_size: rect.size,
finished: false,
snap_x: Default::default(),
snap_y: Default::default(),
},
serial,
Focus::Clear,
);
} else {
let edge = ResizeEdge::at(location, rect);
pointer.set_grab(
self,
ResizeSurfaceGrab::new(start_data, window, edge, rect),
serial,
Focus::Clear,
);
}
true
} }
fn absolute_event_position<I, E>(&self, event: &E) -> Option<Point<f64, Logical>> fn absolute_event_position<I, E>(&self, event: &E) -> Option<Point<f64, Logical>>
@@ -1383,7 +1403,8 @@ impl Ferese {
fn execute_binding(&mut self, action: BindingAction) { fn execute_binding(&mut self, action: BindingAction) {
match action { match action {
BindingAction::None => {} // Interactive pointer actions are dispatched with the originating button.
BindingAction::None | BindingAction::PointerMove | BindingAction::PointerResize => {}
BindingAction::BindingMode(name) => self.set_binding_mode(&name), BindingAction::BindingMode(name) => self.set_binding_mode(&name),
BindingAction::Spawn(argv) => { BindingAction::Spawn(argv) => {
if let Some(child) = spawn_client( if let Some(child) = spawn_client(
+552
View File
@@ -0,0 +1,552 @@
use super::{AxisSource, BindingAction, Ferese, PointerHandle, Serial, with_pointer_constraint};
use crate::config::{PointerTrigger, ScrollDirection};
use std::collections::HashMap;
use std::time::{Duration, Instant};
#[derive(Clone, Debug, PartialEq, Eq)]
struct ScrollContext {
modifiers: u8,
mode: Option<String>,
revision: u64,
}
#[derive(Default)]
struct ScrollAxis {
remainder: f64,
context: Option<ScrollContext>,
finger_owner: Option<bool>,
last_action: Option<Instant>,
}
impl ScrollAxis {
fn feed(
&mut self,
delta: Option<f64>,
finger: bool,
context: &ScrollContext,
bound: bool,
now: Instant,
cooldown: Duration,
) -> (bool, i32) {
let owned = if finger {
*self.finger_owner.get_or_insert(bound)
} else {
bound
};
let Some(delta) = delta else {
*self = Self::default();
return (owned, 0);
};
if self.context.as_ref() != Some(context) {
self.remainder = 0.0;
self.last_action = None;
if finger && self.context.is_some() && owned {
// Finish an intercepted finger gesture without delivering its tail
// or changing actions halfway through it.
return (true, 0);
}
self.context = Some(context.clone());
}
if !owned || !bound || !delta.is_finite() {
self.remainder = 0.0;
return (owned, 0);
}
if self.remainder.signum() != delta.signum() {
self.remainder = 0.0;
}
self.remainder += delta;
let threshold = if finger { 15.0 } else { 120.0 };
let ticks = (self.remainder / threshold).trunc().clamp(-32.0, 32.0) as i32;
self.remainder %= threshold;
if ticks == 0
|| self
.last_action
.is_some_and(|last| now.duration_since(last) < cooldown)
{
return (true, 0);
}
self.last_action = Some(now);
(
true,
if cooldown.is_zero() {
ticks
} else {
ticks.signum()
},
)
}
}
#[derive(Default)]
struct DeviceScroll {
axes: [ScrollAxis; 2],
fraction: [f64; 2],
factor: f64,
}
#[derive(Default)]
pub(crate) struct ScrollBindings {
devices: HashMap<String, DeviceScroll>,
}
impl Ferese {
fn pointer_bindings_allowed(&self, pointer: &PointerHandle<Self>) -> bool {
!self.session_lock.active()
&& !self.input_capture.active()
&& !self.overview.is_active()
&& self.active_shortcuts_inhibitor.is_none()
&& !pointer.is_grabbed()
&& !pointer.current_focus().is_some_and(|surface| {
with_pointer_constraint(&surface, pointer, |constraint| {
constraint.is_some_and(|constraint| constraint.is_active())
})
})
}
pub(super) fn handle_pointer_binding(
&mut self,
pointer: &PointerHandle<Self>,
button: u32,
serial: Serial,
) -> bool {
if !self.pointer_bindings_allowed(pointer) {
return false;
}
let Some(keyboard) = self.seat.get_keyboard() else {
return false;
};
let action = self
.binding_modes
.bindings(&self.bindings)
.binding_for_pointer(PointerTrigger::Button(button), keyboard.modifier_state())
.map(|binding| binding.action.clone());
let Some(action) = action else {
return false;
};
if matches!(
action,
BindingAction::PointerMove | BindingAction::PointerResize
) {
return self.start_pointer_window_grab(
pointer,
button,
serial,
action == BindingAction::PointerResize,
);
}
// Bindings use the current keyboard focus, like ordinary shortcuts.
self.consumed_pointer_buttons.insert(button);
self.execute_binding(action);
true
}
}
impl Ferese {
pub(super) fn filter_pointer_scroll(
&mut self,
device: String,
source: AxisSource,
values: [f64; 2],
v120: [Option<f64>; 2],
stops: [bool; 2],
) -> [bool; 2] {
let Some(pointer) = self.seat.get_pointer() else {
return [false; 2];
};
let Some(keyboard) = self.seat.get_keyboard() else {
return [false; 2];
};
let modifiers = keyboard.modifier_state();
let allowed = self.pointer_bindings_allowed(&pointer);
let finger = source == AxisSource::Finger;
let wheel = matches!(source, AxisSource::Wheel | AxisSource::WheelTilt);
if !finger && !wheel {
return [false; 2];
}
let context = ScrollContext {
modifiers: (modifiers.logo as u8)
| ((modifiers.ctrl as u8) << 1)
| ((modifiers.alt as u8) << 2)
| ((modifiers.shift as u8) << 3),
mode: self.binding_modes.active().map(str::to_owned),
revision: self.ipc_events.config_revision,
};
let bindings = self.binding_modes.bindings(&self.bindings);
let actions = [
ScrollDirection::Left,
ScrollDirection::Right,
ScrollDirection::Up,
ScrollDirection::Down,
]
.map(|direction| {
allowed
.then(|| {
bindings.binding_for_pointer(
if finger {
PointerTrigger::Touchpad(direction)
} else {
PointerTrigger::Wheel(direction)
},
modifiers,
)
})
.flatten()
.map(|binding| binding.action.clone())
});
// Bound storage even for synthetic backends that invent device IDs.
if self.pointer_scroll.devices.len() >= 64
&& !self.pointer_scroll.devices.contains_key(&device)
{
self.pointer_scroll.devices.clear();
}
let axes = &mut self.pointer_scroll.devices.entry(device).or_default().axes;
let mut consumed = [false; 2];
let mut steps = [0; 2];
for axis in 0..2 {
if values[axis] == 0.0 && v120[axis].is_none() && !stops[axis] {
continue;
}
let delta = if finger {
values[axis]
} else {
v120[axis].unwrap_or(values[axis] * 8.0)
};
(consumed[axis], steps[axis]) = axes[axis].feed(
(!stops[axis]).then_some(delta),
finger,
&context,
actions[axis * 2].is_some() || actions[axis * 2 + 1].is_some(),
Instant::now(),
Duration::from_millis(self.input_settings.mouse.scroll_cooldown_ms),
);
}
for (axis, steps) in steps.into_iter().enumerate() {
let index = axis * 2 + usize::from(steps > 0);
if let Some(action) = &actions[index] {
for _ in 0..steps.unsigned_abs() {
if self.session_lock.active() || self.input_capture.active() {
break;
}
self.execute_binding(action.clone());
}
}
}
consumed
}
}
#[cfg(test)]
mod tests {
use super::*;
fn context(modifiers: u8) -> ScrollContext {
ScrollContext {
modifiers,
mode: None,
revision: 0,
}
}
#[test]
fn wheel_accumulates_high_resolution_steps_and_discards_reversed_remainder() {
let mut axis = ScrollAxis::default();
let now = Instant::now();
let feed = |axis: &mut ScrollAxis, amount| {
axis.feed(Some(amount), false, &context(1), true, now, Duration::ZERO)
};
assert_eq!(feed(&mut axis, 60.0), (true, 0));
assert_eq!(feed(&mut axis, 60.0), (true, 1));
assert_eq!(feed(&mut axis, 90.0), (true, 0));
assert_eq!(feed(&mut axis, -60.0), (true, 0));
assert_eq!(feed(&mut axis, -60.0), (true, -1));
assert_eq!(feed(&mut axis, f64::NAN), (true, 0));
}
#[test]
fn wheel_remainder_does_not_cross_modifier_or_config_changes() {
let mut axis = ScrollAxis::default();
let now = Instant::now();
axis.feed(Some(60.0), false, &context(1), true, now, Duration::ZERO);
assert_eq!(
axis.feed(Some(60.0), false, &context(2), true, now, Duration::ZERO),
(true, 0)
);
let mut updated = context(2);
updated.revision = 1;
assert_eq!(
axis.feed(Some(60.0), false, &updated, true, now, Duration::ZERO),
(true, 0)
);
}
#[test]
fn finger_gesture_keeps_ownership_until_stop() {
let now = Instant::now();
let mut axis = ScrollAxis::default();
assert_eq!(
axis.feed(Some(15.0), true, &context(1), true, now, Duration::ZERO),
(true, 1)
);
assert_eq!(
axis.feed(Some(15.0), true, &context(0), false, now, Duration::ZERO),
(true, 0)
);
assert_eq!(
axis.feed(None, true, &context(0), false, now, Duration::ZERO),
(true, 0)
);
assert_eq!(
axis.feed(Some(15.0), true, &context(0), false, now, Duration::ZERO),
(false, 0)
);
assert_eq!(
axis.feed(Some(15.0), true, &context(1), true, now, Duration::ZERO),
(false, 0)
);
assert_eq!(
axis.feed(None, true, &context(1), true, now, Duration::ZERO),
(false, 0)
);
}
#[test]
fn cooldown_consumes_ticks_without_replaying_them_later() {
let mut axis = ScrollAxis::default();
let now = Instant::now();
let cooldown = Duration::from_millis(150);
assert_eq!(
axis.feed(Some(120.0), false, &context(1), true, now, cooldown),
(true, 1)
);
assert_eq!(
axis.feed(Some(240.0), false, &context(1), true, now, cooldown),
(true, 0)
);
assert_eq!(
axis.feed(
Some(120.0),
false,
&context(1),
true,
now + cooldown,
cooldown
),
(true, 1)
);
}
}
impl Ferese {
pub(super) fn scale_mouse_scroll(
&mut self,
device: String,
source: AxisSource,
values: [f64; 2],
v120: [Option<f64>; 2],
) -> ([f64; 2], [Option<i32>; 2]) {
if !matches!(source, AxisSource::Wheel | AxisSource::WheelTilt) {
return (values, v120.map(|value| value.map(|value| value as i32)));
}
if self.pointer_scroll.devices.len() >= 64
&& !self.pointer_scroll.devices.contains_key(&device)
{
self.pointer_scroll.devices.clear();
}
let device = self.pointer_scroll.devices.entry(device).or_default();
let factor = self.input_settings.mouse.scroll_factor;
if device.factor != factor {
device.factor = factor;
device.fraction = [0.0; 2];
}
let scaled = std::array::from_fn(|axis| {
v120[axis].filter(|value| value.is_finite()).map(|value| {
let value = value * factor + device.fraction[axis];
device.fraction[axis] = value.fract();
value as i32
})
});
(values.map(|value| value * factor), scaled)
}
}
#[cfg(test)]
mod routing_tests {
use super::*;
use crate::input::{
ButtonEvent, ButtonState, Event, InputBackend, InputEvent, PointerButtonEvent,
SERIAL_COUNTER,
};
use smithay::backend::input::UnusedEvent;
use smithay::backend::winit::WinitVirtualDevice;
#[derive(Debug)]
struct Input;
impl InputBackend for Input {
type Device = WinitVirtualDevice;
type PointerButtonEvent = Button;
type KeyboardKeyEvent = UnusedEvent;
type PointerAxisEvent = UnusedEvent;
type PointerMotionEvent = UnusedEvent;
type PointerMotionAbsoluteEvent = UnusedEvent;
type GestureSwipeBeginEvent = UnusedEvent;
type GestureSwipeUpdateEvent = UnusedEvent;
type GestureSwipeEndEvent = UnusedEvent;
type GesturePinchBeginEvent = UnusedEvent;
type GesturePinchUpdateEvent = UnusedEvent;
type GesturePinchEndEvent = UnusedEvent;
type GestureHoldBeginEvent = UnusedEvent;
type GestureHoldEndEvent = UnusedEvent;
type TouchDownEvent = UnusedEvent;
type TouchUpEvent = UnusedEvent;
type TouchMotionEvent = UnusedEvent;
type TouchCancelEvent = UnusedEvent;
type TouchFrameEvent = UnusedEvent;
type TabletToolAxisEvent = UnusedEvent;
type TabletToolProximityEvent = UnusedEvent;
type TabletToolTipEvent = UnusedEvent;
type TabletToolButtonEvent = UnusedEvent;
type SwitchToggleEvent = UnusedEvent;
type SpecialEvent = UnusedEvent;
}
#[derive(Debug)]
struct Button(bool);
impl Event<Input> for Button {
fn time(&self) -> smithay::backend::input::InputTime {
smithay::backend::input::InputTime::from_millis(1)
}
fn device(&self) -> WinitVirtualDevice {
WinitVirtualDevice
}
}
impl PointerButtonEvent<Input> for Button {
fn button_code(&self) -> u32 {
275
}
fn state(&self) -> ButtonState {
if self.0 {
ButtonState::Pressed
} else {
ButtonState::Released
}
}
}
#[test]
fn pointer_bindings_use_modes_and_consume_releases_after_modifier_changes() {
if !crate::startup_tests::private_runtime(
"input::pointer::routing_tests::pointer_bindings_use_modes_and_consume_releases_after_modifier_changes",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
state
.reload_config_source(
r#"
binding "Super+MouseBack" action="binding-mode" argument="browse"
binding-mode "browse" { binding "MouseBack" action="binding-mode" argument="default"; }
"#
.into(),
)
.unwrap();
let pointer = state.seat.get_pointer().unwrap();
super::super::binding_mode_tests::key(&mut state, 133, true);
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(true),
});
assert_eq!(state.binding_modes.active(), Some("browse"));
assert!(state.consumed_pointer_buttons.contains(&275));
assert!(!pointer.is_grabbed());
super::super::binding_mode_tests::key(&mut state, 133, false);
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(false),
});
assert!(!state.consumed_pointer_buttons.contains(&275));
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(true),
});
assert_eq!(state.binding_modes.active(), None);
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(false),
});
// A missing release after unplugging must not poison the next click.
state.consumed_pointer_buttons.insert(275);
// With no matching modifiers, the application receives an ordinary click grab.
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(true),
});
assert!(
pointer
.with_grab(|_, grab| grab.is::<smithay::input::pointer::ClickGrab<Ferese>>())
.unwrap()
);
assert!(!state.consumed_pointer_buttons.contains(&275));
state.process_input_event::<Input>(InputEvent::PointerButton {
event: Button(false),
});
assert!(!pointer.is_grabbed());
}
#[test]
fn pointer_scroll_routes_axes_independently_and_preserves_fractional_ticks() {
if !crate::startup_tests::private_runtime(
"input::pointer::routing_tests::pointer_scroll_routes_axes_independently_and_preserves_fractional_ticks",
) {
return;
}
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let mut state = crate::startup_tests::state(&mut events);
state
.reload_config_source(
r#"
binding "WheelUp" action="none"
binding "Super+TouchpadScrollLeft" action="none"
input { mouse { scroll-factor 0.25; } }
"#
.into(),
)
.unwrap();
let filtered = state.filter_pointer_scroll(
"mouse".into(),
AxisSource::Wheel,
[15.0, -15.0],
[Some(120.0), Some(-120.0)],
[false; 2],
);
assert_eq!(filtered, [false, true]);
for index in 1..=4 {
let (values, ticks) = state.scale_mouse_scroll(
"mouse".into(),
AxisSource::Wheel,
[1.0, 0.0],
[Some(1.0), None],
);
assert_eq!(values, [0.25, 0.0]);
assert_eq!(ticks, [Some(i32::from(index == 4)), None]);
}
let (values, ticks) =
state.scale_mouse_scroll("touchpad".into(), AxisSource::Finger, [1.0, 2.0], [None; 2]);
assert_eq!(values, [1.0, 2.0]);
assert_eq!(ticks, [None; 2]);
// Pointer grabs retain their scroll events even when a binding exists.
let pointer = state.seat.get_pointer().unwrap();
pointer.button(
&mut state,
&ButtonEvent {
button: 272,
state: ButtonState::Pressed,
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(1),
},
);
assert_eq!(
state.filter_pointer_scroll(
"mouse".into(),
AxisSource::Wheel,
[0.0, -15.0],
[None, Some(-120.0)],
[false; 2]
),
[false; 2]
);
}
}
+728
View File
@@ -0,0 +1,728 @@
use super::{
ButtonEvent, ButtonState, Direction, Ferese, Focus, GrabStartData, Logical, MotionEvent, Point,
PointerHandle, ResizeEdge, SERIAL_COUNTER, Serial, with_pointer_constraint,
};
use ferese_core::{WindowPlacement, WorkspaceId, WorkspaceLayout};
use ferese_layout::{Axis as LayoutAxis, DropPlacement, Rect, WindowId};
use smithay::desktop::Window;
use smithay::input::pointer::{CursorIcon, CursorImageStatus};
use smithay::output::Output;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
use smithay::wayland::compositor::with_states;
use smithay::wayland::shell::xdg::SurfaceCachedState;
use std::collections::HashSet;
pub(crate) struct WindowInteraction {
pub id: WindowId,
window: Window,
workspace: WorkspaceId,
output: Output,
output_geometry: smithay::utils::Rectangle<i32, Logical>,
start: Point<f64, Logical>,
initial: smithay::utils::Rectangle<i32, Logical>,
placement: WindowPlacement,
applied_placement: WindowPlacement,
edges: Option<ResizeEdge>,
base_layout: WorkspaceLayout,
applied_layout: WorkspaceLayout,
revision: u64,
serial: Serial,
last_tick: std::time::Instant,
scroll: f64,
drop_floating: bool,
pub location: Point<f64, Logical>,
pub pressed: HashSet<u32>,
pub finished: bool,
pub cancelled: bool,
started: bool,
last_location: Point<f64, Logical>,
was_maximized: bool,
pub visual: Option<Rect>,
snap_x: crate::floating::AxisSnap,
snap_y: crate::floating::AxisSnap,
}
impl WindowInteraction {
pub(crate) fn toggle_drop_mode(&mut self) {
if self.edges.is_none() && !self.finished {
self.drop_floating = !self.drop_floating;
}
}
pub(crate) fn scroll(&mut self, amount: f64) {
if self.edges.is_none() && amount.is_finite() && !self.finished {
self.scroll += amount;
}
}
}
impl Ferese {
pub(crate) fn begin_window_interaction(
&mut self,
pointer: &PointerHandle<Self>,
window: Window,
start: GrabStartData<Self>,
serial: Serial,
edges: Option<ResizeEdge>,
) -> bool {
if self.session_lock.active()
|| self.input_capture.active()
|| self.overview.is_active()
|| self.pointer_operation.is_some()
|| self.keyboard_focused_layer().is_some()
|| !self.window_content_ready(&window)
|| pointer
.with_grab(|_, grab| !grab.is::<smithay::input::pointer::ClickGrab<Self>>())
.unwrap_or(false)
|| pointer.current_focus().is_some_and(|surface| {
with_pointer_constraint(&surface, pointer, |constraint| {
constraint.is_some_and(|constraint| constraint.is_active())
})
})
{
return false;
}
let Some(id) = self.windows.ids().get(&window).copied() else {
return false;
};
let Some(workspace) = self.workspaces.workspace_for_window(id) else {
return false;
};
let Some(placement) = self.workspaces.placement(id) else {
return false;
};
if !self.window_interaction_allowed(id, workspace) {
return false;
}
let Some(output) = self
.space
.outputs()
.find(|output| self.window_belongs_to_output(id, output))
.cloned()
else {
return false;
};
let Some(output_geometry) = self.space.output_geometry(&output) else {
return false;
};
let Some(mut initial) = self.visual_rect_for_window(&window) else {
return false;
};
let was_maximized = self
.windows
.record(id)
.is_some_and(|record| record.maximized);
if was_maximized {
self.set_window_maximized(id, false);
if let Some(geometry) = self.windows.geometry(&id) {
let restored = geometry.logical;
let size = (
restored.width.round() as i32,
restored.height.round() as i32,
)
.into();
let x = ((start.location.x - initial.loc.x as f64) / initial.size.w.max(1) as f64)
.clamp(0.0, 1.0);
let y = ((start.location.y - initial.loc.y as f64) / initial.size.h.max(1) as f64)
.clamp(0.0, 1.0);
initial = smithay::utils::Rectangle::new(
(
(start.location.x - x * restored.width).round() as i32,
(start.location.y - y * restored.height).round() as i32,
)
.into(),
size,
);
}
}
let layout = self
.workspaces
.workspace(workspace)
.expect("managed workspace")
.layout
.clone();
self.pointer_operation = Some(WindowInteraction {
id,
window,
workspace,
output,
output_geometry,
initial,
placement,
applied_placement: placement,
edges,
base_layout: layout.clone(),
applied_layout: layout,
revision: self.ipc_events.config_revision,
serial,
last_tick: std::time::Instant::now(),
scroll: 0.0,
drop_floating: matches!(placement, WindowPlacement::Floating { .. }),
start: start.location,
location: start.location,
last_location: start.location,
pressed: HashSet::from([start.button]),
finished: false,
cancelled: false,
started: false,
was_maximized,
visual: None,
snap_x: Default::default(),
snap_y: Default::default(),
});
self.cursor_status =
CursorImageStatus::Named(edges.map_or(CursorIcon::Grabbing, ResizeEdge::cursor));
pointer.set_grab(
self,
crate::grabs::WindowPointerGrab {
start_data: start,
finished: false,
},
serial,
Focus::Clear,
);
use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
let interval = std::time::Duration::from_millis(16);
if let Err(error) =
self.loop_handle
.insert_source(Timer::from_duration(interval), move |_, _, state| {
if state
.pointer_operation
.as_ref()
.is_none_or(|operation| operation.serial != serial)
{
return TimeoutAction::Drop;
}
state.poll_window_interaction();
if state.pointer_operation.is_some() {
TimeoutAction::ToDuration(interval)
} else {
TimeoutAction::Drop
}
})
{
tracing::warn!(%error, "failed to schedule drag edge scrolling");
}
true
}
pub(crate) fn cancel_window_interaction(&mut self) {
if let Some(operation) = self.pointer_operation.as_mut() {
operation.cancelled = true;
}
self.poll_window_interaction();
}
pub(crate) fn poll_window_interaction(&mut self) {
self.update_window_interaction();
if self.window_grab_focus_pending
&& let Some(pointer) = self.seat.get_pointer()
&& !pointer.is_grabbed()
{
self.window_grab_focus_pending = false;
let location = pointer.current_location();
pointer.motion(
self,
self.surface_under(location),
&MotionEvent {
location,
serial: SERIAL_COUNTER.next_serial(),
time: self.last_pointer_time,
},
);
pointer.frame(self);
}
}
fn update_window_interaction(&mut self) {
let Some(mut operation) = self.pointer_operation.take() else {
return;
};
let valid = !self.session_lock.active()
&& !self.input_capture.active()
&& self.keyboard_focused_layer().is_none()
&& self
.windows
.record(operation.id)
.is_some_and(|record| !record.maximized)
&& operation.location.x.is_finite()
&& operation.location.y.is_finite()
&& self
.windows
.window(operation.id)
.is_some_and(|window| self.window_content_ready(window))
&& self.workspaces.workspace_for_window(operation.id) == Some(operation.workspace)
&& self.space.output_geometry(&operation.output) == Some(operation.output_geometry)
&& self
.output_id(&operation.output)
.and_then(|id| self.output_workspaces.active_workspace(id))
== Some(operation.workspace)
&& self.window_interaction_allowed(operation.id, operation.workspace)
&& self
.workspaces
.placement(operation.id)
.is_some_and(|placement| {
matches!(
(placement, operation.placement),
(WindowPlacement::Tiled, WindowPlacement::Tiled)
| (
WindowPlacement::Floating { .. },
WindowPlacement::Floating { .. }
)
)
})
&& self.ipc_events.config_revision == operation.revision;
if !valid {
operation.cancelled = true;
}
if self.overview.is_active() {
operation.cancelled = true;
}
let layout_matches = operation.edges.is_none()
|| operation.placement != WindowPlacement::Tiled
|| self
.workspaces
.workspace(operation.workspace)
.is_some_and(|workspace| same_layout(&workspace.layout, &operation.applied_layout));
if operation.edges.is_some()
&& operation.placement == WindowPlacement::Tiled
&& !layout_matches
{
operation.cancelled = true;
}
let placement_matches =
self.workspaces.placement(operation.id) == Some(operation.applied_placement);
if operation.edges.is_some() && !placement_matches {
operation.cancelled = true;
}
let delta = operation.location - operation.start;
let distance = delta.x.hypot(delta.y);
operation.started |= distance > 0.0 && distance >= self.input_settings.mouse.drag_threshold;
if operation.cancelled {
if operation.started
&& self.workspaces.workspace_for_window(operation.id) == Some(operation.workspace)
{
match operation.placement {
WindowPlacement::Tiled if operation.edges.is_some() && layout_matches => {
if let Some(workspace) = self.workspaces.workspace_mut(operation.workspace)
{
workspace.layout = operation.base_layout.clone();
}
}
WindowPlacement::Floating { rect }
if operation.edges.is_some() && placement_matches =>
{
self.set_floating_window_rect(&operation.window, rect);
}
_ => {}
}
}
if let Some(pointer) = self.seat.get_pointer() {
// Flush only a compositor-owned grab. The physical releases are
// then consumed, preserving button state without sending them to clients.
if pointer
.with_grab(|_, grab| grab.is::<crate::grabs::WindowPointerGrab>())
.unwrap_or(false)
{
for button in &operation.pressed {
pointer.button(
self,
&ButtonEvent {
button: *button,
state: ButtonState::Released,
serial: SERIAL_COUNTER.next_serial(),
time: self.last_pointer_time,
},
);
self.consumed_pointer_buttons.insert(*button);
}
if pointer
.with_grab(|_, grab| grab.is::<crate::grabs::WindowPointerGrab>())
.unwrap_or(false)
{
pointer.unset_grab(
self,
SERIAL_COUNTER.next_serial(),
self.last_pointer_time,
);
}
}
}
self.finish_window_interaction(&operation, true);
return;
}
if operation.started && operation.location != operation.last_location {
let previous_visual = operation.visual;
if let Some(edges) = operation.edges {
self.resize_interaction(&mut operation, edges, delta);
} else {
let mut rect = Rect::new(
operation.initial.loc.x as f64 + delta.x,
operation.initial.loc.y as f64 + delta.y,
operation.initial.size.w as f64,
operation.initial.size.h as f64,
);
if operation.drop_floating {
let (xs, ys) = self.floating_snap_lines(&operation.window, rect);
if self.floating_snap_bypassed() {
operation.snap_x.clear();
operation.snap_y.clear();
} else {
rect.x = operation.snap_x.apply(rect.x, rect.width, &xs);
rect.y = operation.snap_y.apply(rect.y, rect.height, &ys);
}
}
operation.visual = Some(rect);
self.space.raise_element(&operation.window, false);
}
operation.last_location = operation.location;
let outputs = self
.space
.outputs()
.filter(|output| {
*output == &operation.output
|| self.space.output_geometry(output).is_some_and(|geometry| {
let bounds = Rect::new(
geometry.loc.x as f64,
geometry.loc.y as f64,
geometry.size.w as f64,
geometry.size.h as f64,
);
[previous_visual, operation.visual]
.into_iter()
.flatten()
.any(|rect| crate::floating::intersection(rect, bounds).is_some())
})
})
.cloned()
.collect::<Vec<_>>();
for output in outputs {
self.defer_output_redraw(output);
}
self.cursor_redraw_pending = true;
}
if operation.started && operation.edges.is_none() && !operation.finished {
self.scroll_drag_viewport(&mut operation);
}
if operation.finished {
let committed = operation.started
&& (operation.edges.is_some() || self.drop_interaction(&operation));
self.finish_window_interaction(&operation, !committed);
} else {
self.pointer_operation = Some(operation);
}
}
fn scroll_drag_viewport(&mut self, operation: &mut WindowInteraction) {
let now = std::time::Instant::now();
let elapsed = now
.duration_since(operation.last_tick)
.as_secs_f64()
.min(0.05);
operation.last_tick = now;
let wheel = std::mem::take(&mut operation.scroll);
let Some(output) = self.space.output_under(operation.location).next().cloned() else {
return;
};
let Some(bounds) = self.output_bounds_for(&output) else {
return;
};
let Some(workspace) = self
.output_id(&output)
.and_then(|id| self.output_workspaces.active_workspace(id))
else {
return;
};
// The pointer can be held at an edge to reach columns outside the viewport.
let edge = if operation.drop_floating
|| self
.layer_under(operation.location)
.is_some_and(|(layer, ..)| {
matches!(
layer.layer(),
smithay::wayland::shell::wlr_layer::Layer::Top
| smithay::wayland::shell::wlr_layer::Layer::Overlay
)
}) {
0.0
} else if operation.location.x < bounds.x + 32.0 {
-1.0
} else if operation.location.x > bounds.x + bounds.width - 32.0 {
1.0
} else {
0.0
};
if let Some(workspace) = self.workspaces.workspace_mut(workspace)
&& workspace.fullscreen.is_none()
&& let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout
&& layout.scroll_viewport(wheel + edge * elapsed * 800.0)
{
self.relayout();
}
}
fn resize_interaction(
&mut self,
operation: &mut WindowInteraction,
edges: ResizeEdge,
delta: Point<f64, Logical>,
) {
if operation.placement == WindowPlacement::Tiled {
let Some(bounds) = self.output_bounds_for(&operation.output) else {
return;
};
let mut layout = operation.base_layout.clone();
if edges.left() || edges.right() {
let edge = if edges.left() {
Direction::Left
} else {
Direction::Right
};
let _ = layout.resize_edge(operation.id, edge, delta.x, bounds);
}
if edges.top() || edges.bottom() {
let edge = if edges.top() {
Direction::Up
} else {
Direction::Down
};
let _ = layout.resize_edge(operation.id, edge, delta.y, bounds);
}
if let Some(workspace) = self.workspaces.workspace_mut(operation.workspace) {
workspace.layout = layout;
}
self.relayout();
if let Some(workspace) = self.workspaces.workspace(operation.workspace) {
operation.applied_layout = workspace.layout.clone();
}
} else if let Some(surface) = operation.window.toplevel() {
let initial = operation.initial;
let mut delta = delta;
let left = initial.loc.x as f64 + if edges.left() { delta.x } else { 0.0 };
let top = initial.loc.y as f64 + if edges.top() { delta.y } else { 0.0 };
let right = initial.loc.x as f64
+ initial.size.w as f64
+ if edges.right() { delta.x } else { 0.0 };
let bottom = initial.loc.y as f64
+ initial.size.h as f64
+ if edges.bottom() { delta.y } else { 0.0 };
let raw = Rect::new(left, top, (right - left).max(1.0), (bottom - top).max(1.0));
let (xs, ys) = self.floating_snap_lines(&operation.window, raw);
if self.floating_snap_bypassed() {
operation.snap_x.clear();
operation.snap_y.clear();
} else {
if edges.left() || edges.right() {
let moving = if edges.left() { left } else { right };
delta.x += operation.snap_x.apply(moving, 0.0, &xs) - moving;
}
if edges.top() || edges.bottom() {
let moving = if edges.top() { top } else { bottom };
delta.y += operation.snap_y.apply(moving, 0.0, &ys) - moving;
}
}
let (min, max) = with_states(surface.wl_surface(), |states| {
let mut cache = states.cached_state.get::<SurfaceCachedState>();
let cached = cache.current();
(cached.min_size, cached.max_size)
});
let (min, max) =
self.effective_size_constraints(&operation.window, (min.w, min.h), (max.w, max.h));
let size =
crate::grabs::constrained_size(operation.initial.size, delta, edges, min, max);
let rect = crate::grabs::resized_rect(operation.initial, size, edges);
self.set_floating_window_geometry(&operation.window, rect.loc, rect.size);
operation.applied_placement = self
.workspaces
.placement(operation.id)
.unwrap_or(operation.applied_placement);
let initial = operation.initial;
self.windows.update(operation.id, |record| {
record.resize_anchor = Some((
edges.left(),
edges.top(),
Rect::new(
initial.loc.x as f64,
initial.loc.y as f64,
initial.size.w as f64,
initial.size.h as f64,
),
))
});
}
if let Some(surface) = operation.window.toplevel() {
surface.with_pending_state(|state| {
state.states.set(xdg_toplevel::State::Resizing);
});
surface.send_pending_configure();
}
}
fn drop_interaction(&mut self, operation: &WindowInteraction) -> bool {
if self
.layer_under(operation.location)
.is_some_and(|(layer, ..)| {
matches!(
layer.layer(),
smithay::wayland::shell::wlr_layer::Layer::Top
| smithay::wayland::shell::wlr_layer::Layer::Overlay
)
})
{
return false;
}
let Some(output) = self.space.output_under(operation.location).next().cloned() else {
return false;
};
let Some(destination) = self
.output_id(&output)
.and_then(|id| self.output_workspaces.active_workspace(id))
else {
return false;
};
if self
.workspaces
.workspace(destination)
.is_none_or(|workspace| workspace.fullscreen.is_some())
&& !(destination == operation.workspace
&& operation.drop_floating
&& self.window_interaction_allowed(operation.id, destination))
{
return false;
}
let target = self
.space
.elements()
.rev()
.filter_map(|window| {
let id = self.windows.ids().get(window).copied()?;
if id == operation.id
|| self.workspaces.workspace_for_window(id) != Some(destination)
|| self.workspaces.placement(id) != Some(WindowPlacement::Tiled)
{
return None;
}
let rect = self.visual_rect_for_window(window)?;
rect.to_f64()
.contains(operation.location)
.then_some((id, rect))
})
.next();
// Apply a drop transactionally, preserving all changes made while dragging.
let mut next = self.workspaces.clone();
let floating = matches!(operation.placement, WindowPlacement::Floating { .. });
if floating != operation.drop_floating
&& next
.toggle_floating(
operation.id,
operation
.visual
.expect("started move has a visual rectangle"),
LayoutAxis::Horizontal,
0.5,
)
.is_err()
{
return false;
}
if next
.move_window_to_workspace(operation.id, destination, LayoutAxis::Horizontal, 0.5)
.is_err()
{
return false;
}
match next
.placement(operation.id)
.expect("validated dragged window")
{
WindowPlacement::Tiled => {
if let Some((target, rect)) = target {
let placement = drop_placement(operation.location, rect);
if next
.workspace_mut(destination)
.expect("validated destination")
.layout
.place_window(operation.id, target, placement)
.is_err()
{
return false;
}
}
}
WindowPlacement::Floating { .. } => {
if let Some(rect) = operation.visual
&& next.set_floating_rect(operation.id, rect).is_err()
{
return false;
}
}
}
if next.validate().is_err() {
return false;
}
self.workspaces = next;
// Start the landing animation at the position actually being displayed.
if let Some(rect) = operation.visual {
self.windows.clear_transaction(&operation.id);
self.windows.update(operation.id, |record| {
record.world_x = None;
record.coupled_width = None;
if let Some(geometry) = record.geometry.as_mut() {
geometry.visual = ferese_animation::AnimatedRect::new(rect);
geometry.presentation_changed = true;
}
});
}
self.activate_managed_window(operation.id);
true
}
fn finish_window_interaction(&mut self, operation: &WindowInteraction, cancelled: bool) {
if let Some(surface) = operation.window.toplevel() {
surface.with_pending_state(|state| {
state.states.unset(xdg_toplevel::State::Resizing);
});
surface.send_pending_configure();
}
if cancelled && operation.was_maximized && self.windows.window(operation.id).is_some() {
self.set_window_maximized(operation.id, true);
}
if !cancelled {
self.remember_floating(&operation.window);
}
self.cursor_status = CursorImageStatus::default_named();
self.relayout();
self.cursor_redraw_pending = true;
}
}
fn drop_placement(
location: Point<f64, Logical>,
rect: smithay::utils::Rectangle<i32, Logical>,
) -> DropPlacement {
let x = (location.x - rect.loc.x as f64) / rect.size.w.max(1) as f64;
let y = (location.y - rect.loc.y as f64) / rect.size.h.max(1) as f64;
if (0.25..=0.75).contains(&x) && (0.25..=0.75).contains(&y) {
return DropPlacement::Tab;
}
let edges = [
(x, DropPlacement::Left),
(1.0 - x, DropPlacement::Right),
(y, DropPlacement::Above),
(1.0 - y, DropPlacement::Below),
];
edges
.into_iter()
.min_by(|a, b| a.0.total_cmp(&b.0))
.expect("four edges")
.1
}
fn same_layout(a: &WorkspaceLayout, b: &WorkspaceLayout) -> bool {
match (a, b) {
(WorkspaceLayout::Scrolling(a), WorkspaceLayout::Scrolling(b)) => {
a.columns() == b.columns()
}
_ => a == b,
}
}
+1
View File
@@ -212,6 +212,7 @@ fn after_dispatch_with_redraw(
mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>), mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>),
) { ) {
crate::backends::direct::gamma::sync_night_light(state); crate::backends::direct::gamma::sync_night_light(state);
state.poll_window_interaction();
state.process_pending_dmabuf_imports(); state.process_pending_dmabuf_imports();
state.prune_screencopies(); state.prune_screencopies();
state.refresh_capture_sessions(); state.refresh_capture_sessions();
+11 -1
View File
@@ -232,7 +232,7 @@ fn scene_elements(
presentation_alpha, presentation_alpha,
)) ))
}; };
let windows = if workspace.is_none() && state.overview.is_active() { let mut windows = if workspace.is_none() && state.overview.is_active() {
state state
.windows .windows
.overview_windows() .overview_windows()
@@ -258,6 +258,16 @@ fn scene_elements(
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
if let Some(operation) = state
.pointer_operation
.as_ref()
.filter(|operation| operation.visual.is_some())
&& let Some(index) = windows.iter().position(|(_, id, ..)| *id == operation.id)
{
// A dragged tile stays above floating windows, even after a relayout.
windows[..=index].rotate_right(1);
}
let mut closing = if workspace.is_some() { let mut closing = if workspace.is_some() {
Default::default() Default::default()
} else { } else {
+2
View File
@@ -299,6 +299,7 @@ impl Ferese {
} }
pub(crate) fn prepare_lock_input(&mut self) { pub(crate) fn prepare_lock_input(&mut self) {
self.cancel_window_interaction();
// Reject desktop grabs installed after acquisition, but keep the lock // Reject desktop grabs installed after acquisition, but keep the lock
// owner's implicit click grab intact until its button release. // owner's implicit click grab intact until its button release.
if let Some(keyboard) = self.seat.get_keyboard() if let Some(keyboard) = self.seat.get_keyboard()
@@ -482,6 +483,7 @@ impl SessionLockHandler for Ferese {
); );
} }
self.set_overview_active(false); self.set_overview_active(false);
self.cancel_window_interaction();
let serial = SERIAL_COUNTER.next_serial(); let serial = SERIAL_COUNTER.next_serial();
if let Some(pointer) = self.seat.get_pointer() { if let Some(pointer) = self.seat.get_pointer() {
pointer.unset_grab(self, serial, smithay::backend::input::InputTime::now()); pointer.unset_grab(self, serial, smithay::backend::input::InputTime::now());
+12 -2
View File
@@ -11,6 +11,8 @@ mod hit_testing;
mod layout; mod layout;
mod navigation; mod navigation;
mod outputs; mod outputs;
#[cfg(test)]
mod pointer_tests;
mod reconciliation; mod reconciliation;
pub(crate) mod tab_indicator; pub(crate) mod tab_indicator;
mod tabs; mod tabs;
@@ -488,6 +490,9 @@ pub struct Ferese {
pub(crate) tab_indicator_settings: crate::config::TabIndicatorSettings, pub(crate) tab_indicator_settings: crate::config::TabIndicatorSettings,
pub(crate) tab_bars: HashMap<WorkspaceId, Vec<ferese_layout::TabBar>>, pub(crate) tab_bars: HashMap<WorkspaceId, Vec<ferese_layout::TabBar>>,
pub(crate) consumed_pointer_buttons: HashSet<u32>, pub(crate) consumed_pointer_buttons: HashSet<u32>,
pub(crate) pointer_scroll: crate::input::ScrollBindings,
pub(crate) pointer_operation: Option<crate::input::WindowInteraction>,
pub(crate) window_grab_focus_pending: bool,
pub(crate) pending_layer_focus: Option<(WlSurface, Instant)>, pub(crate) pending_layer_focus: Option<(WlSurface, Instant)>,
pub(crate) consumed_touch_slots: HashSet<smithay::backend::input::TouchSlot>, pub(crate) consumed_touch_slots: HashSet<smithay::backend::input::TouchSlot>,
pub(crate) focus_history: ferese_core::FocusHistory, pub(crate) focus_history: ferese_core::FocusHistory,
@@ -812,6 +817,9 @@ impl Ferese {
tab_indicator_settings: config.tab_indicator, tab_indicator_settings: config.tab_indicator,
tab_bars: HashMap::new(), tab_bars: HashMap::new(),
consumed_pointer_buttons: HashSet::new(), consumed_pointer_buttons: HashSet::new(),
pointer_scroll: Default::default(),
pointer_operation: None,
window_grab_focus_pending: false,
pending_layer_focus: None, pending_layer_focus: None,
consumed_touch_slots: HashSet::new(), consumed_touch_slots: HashSet::new(),
focus_history: Default::default(), focus_history: Default::default(),
@@ -1022,7 +1030,9 @@ impl Ferese {
self.advance_animations(Instant::now()); self.advance_animations(Instant::now());
} }
let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad; let pointer_settings_changed = self.input_settings.touchpad
!= config.input_settings.touchpad
|| self.input_settings.mouse != config.input_settings.mouse;
let outputs_changed = self.output_profiles != config.output_profiles; let outputs_changed = self.output_profiles != config.output_profiles;
if let Some(workspaces) = workspaces { if let Some(workspaces) = workspaces {
self.workspaces = workspaces; self.workspaces = workspaces;
@@ -1089,7 +1099,7 @@ impl Ferese {
crate::daemon::Runner::refresh(&runner, self); crate::daemon::Runner::refresh(&runner, self);
} }
if touchpad_changed { if pointer_settings_changed {
crate::backends::direct::reload_input_devices(self); crate::backends::direct::reload_input_devices(self);
} }
+35 -7
View File
@@ -6,6 +6,25 @@ use super::{
}; };
impl Ferese { impl Ferese {
pub(crate) fn window_interaction_allowed(
&self,
id: WindowId,
workspace: ferese_core::WorkspaceId,
) -> bool {
self.workspaces
.workspace(workspace)
.is_some_and(|workspace| {
workspace.fullscreen.is_none_or(|fullscreen| {
id != fullscreen
&& self.floating_above_fullscreen.get(&id) == Some(&fullscreen)
&& matches!(
self.workspaces.placement(id),
Some(WindowPlacement::Floating { .. })
)
})
})
}
pub(super) fn adjust_floating_direction( pub(super) fn adjust_floating_direction(
&mut self, &mut self,
id: WindowId, id: WindowId,
@@ -238,18 +257,24 @@ impl Ferese {
location: Point<i32, Logical>, location: Point<i32, Logical>,
size: Size<i32, Logical>, size: Size<i32, Logical>,
) { ) {
self.set_floating_window_rect(
window,
Rect::new(
location.x as f64,
location.y as f64,
size.w.max(1) as f64,
size.h.max(1) as f64,
),
);
}
pub(crate) fn set_floating_window_rect(&mut self, window: &Window, rect: Rect) {
let Some(id) = self.windows.ids().get(window).copied() else { let Some(id) = self.windows.ids().get(window).copied() else {
return; return;
}; };
self.windows self.windows
.update(id, |w| w.resize_anchor.take()) .update(id, |w| w.resize_anchor.take())
.flatten(); .flatten();
let rect = Rect::new(
location.x as f64,
location.y as f64,
size.w.max(1) as f64,
size.h.max(1) as f64,
);
if let Err(error) = self.workspaces.set_floating_rect(id, rect) { if let Err(error) = self.workspaces.set_floating_rect(id, rect) {
tracing::error!(%error, ?id, "failed to update floating window geometry"); tracing::error!(%error, ?id, "failed to update floating window geometry");
@@ -288,7 +313,10 @@ impl Ferese {
self.windows.update(id, |record| record.world_x = None); self.windows.update(id, |record| record.world_x = None);
self.windows self.windows
.update(id, |record| record.coupled_width = None); .update(id, |record| record.coupled_width = None);
self.map_window_geometry(window.clone(), location); self.map_window_geometry(
window.clone(),
(rect.x.round() as i32, rect.y.round() as i32).into(),
);
self.sync_window_stacking(); self.sync_window_stacking();
// Move/resize/cancel callbacks run with Smithay's pointer mutex held. // Move/resize/cancel callbacks run with Smithay's pointer mutex held.
// Cursor rendering reads current_location(), which would lock it again. // Cursor rendering reads current_location(), which would lock it again.
+13
View File
@@ -214,6 +214,19 @@ impl Ferese {
} }
pub(crate) fn window_belongs_to_output(&self, window: WindowId, output: &Output) -> bool { pub(crate) fn window_belongs_to_output(&self, window: WindowId, output: &Output) -> bool {
if let Some(rect) = self
.pointer_operation
.as_ref()
.filter(|operation| operation.id == window)
.and_then(|operation| operation.visual)
{
return self.space.output_geometry(output).is_some_and(|area| {
rect.x < (area.loc.x + area.size.w) as f64
&& rect.x + rect.width > area.loc.x as f64
&& rect.y < (area.loc.y + area.size.h) as f64
&& rect.y + rect.height > area.loc.y as f64
});
}
let Some(output_id) = self.output_id(output) else { let Some(output_id) = self.output_id(output) else {
return false; return false;
}; };
+539
View File
@@ -0,0 +1,539 @@
use super::capture_privacy::tests::window;
use super::window_lifecycle_tests::fixture;
use super::*;
use smithay::input::pointer::{GrabStartData, MotionEvent};
use smithay::utils::SERIAL_COUNTER;
fn pointer_interaction_begin(
state: &mut Ferese,
window: Window,
resize: bool,
) -> smithay::input::pointer::PointerHandle<Ferese> {
use smithay::backend::input::{ButtonState, InputTime};
let pointer = state.seat.get_pointer().unwrap();
let rect = state.visual_rect_for_window(&window).unwrap();
let location = (
rect.loc.x as f64 + rect.size.w as f64 / 2.0,
rect.loc.y as f64 + rect.size.h as f64 / 2.0,
)
.into();
assert!(state.begin_window_interaction(
&pointer,
window,
GrabStartData {
focus: None,
button: 0x110,
location
},
SERIAL_COUNTER.next_serial(),
resize.then(|| xdg_toplevel::ResizeEdge::BottomRight.into())
));
pointer.button(
state,
&smithay::input::pointer::ButtonEvent {
button: 0x110,
state: ButtonState::Pressed,
serial: SERIAL_COUNTER.next_serial(),
time: InputTime::from_millis(1),
},
);
pointer
}
#[test]
fn pointer_drag_keeps_layout_until_drop_and_escape_preserves_new_windows() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_drag_keeps_layout_until_drop_and_escape_preserves_new_windows",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (first, _wire) = window(&mut state, &mut events, 0xff112233);
let first_id = state.windows.ids()[&first];
let before = state.workspaces.active().layout.clone();
let pointer = pointer_interaction_begin(&mut state, first, false);
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (700.0, 300.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(2),
},
);
state.poll_window_interaction();
assert_eq!(state.workspaces.active().layout, before);
assert!(state.pointer_operation.as_ref().unwrap().visual.is_some());
let (second, _wire2) = window(&mut state, &mut events, 0xff445566);
let second_id = state.windows.ids()[&second];
state.cancel_window_interaction();
assert!(state.pointer_operation.is_none());
assert!(!pointer.is_grabbed());
assert!(state.workspaces.active().layout.contains(first_id));
assert!(state.workspaces.active().layout.contains(second_id));
state.workspaces.validate().unwrap();
}
#[test]
fn pointer_resize_cancels_cleanly_and_finishes_on_its_own_button() {
use smithay::backend::input::{ButtonState, InputTime};
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_resize_cancels_cleanly_and_finishes_on_its_own_button",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (first, _wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&first];
state.focused_window = Some(id);
state.toggle_focused_floating();
let before = state.workspaces.placement(id);
let pointer = pointer_interaction_begin(&mut state, first.clone(), true);
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (700.0, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: InputTime::from_millis(2),
},
);
state.poll_window_interaction();
assert_ne!(state.workspaces.placement(id), before);
state.cancel_window_interaction();
assert_eq!(state.workspaces.placement(id), before);
let pointer = pointer_interaction_begin(&mut state, first, false);
for (button, pressed) in [(0x111, true), (0x110, false)] {
pointer.button(
&mut state,
&smithay::input::pointer::ButtonEvent {
button,
state: if pressed {
ButtonState::Pressed
} else {
ButtonState::Released
},
serial: SERIAL_COUNTER.next_serial(),
time: InputTime::from_millis(3),
},
);
}
state.poll_window_interaction();
assert!(state.pointer_operation.is_none());
assert!(
pointer.is_grabbed(),
"remaining button is drained without moving the window"
);
pointer.button(
&mut state,
&smithay::input::pointer::ButtonEvent {
button: 0x111,
state: ButtonState::Released,
serial: SERIAL_COUNTER.next_serial(),
time: InputTime::from_millis(4),
},
);
assert!(!pointer.is_grabbed());
}
fn motion(state: &mut Ferese, point: (f64, f64)) {
let pointer = state.seat.get_pointer().unwrap();
pointer.motion(
state,
None,
&MotionEvent {
location: point.into(),
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(2),
},
);
state.poll_window_interaction();
}
fn button(state: &mut Ferese, code: u32, pressed: bool) {
let pointer = state.seat.get_pointer().unwrap();
pointer.button(
state,
&smithay::input::pointer::ButtonEvent {
button: code,
state: if pressed {
smithay::backend::input::ButtonState::Pressed
} else {
smithay::backend::input::ButtonState::Released
},
serial: SERIAL_COUNTER.next_serial(),
time: smithay::backend::input::InputTime::from_millis(3),
},
);
state.poll_window_interaction();
}
#[test]
fn pointer_drop_joins_tabs_and_can_float_and_retile_in_both_layouts() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_drop_joins_tabs_and_can_float_and_retile_in_both_layouts",
) {
return;
}
for tree in [false, true] {
let (mut events, mut state, _) = fixture();
let (first, _wire1) = window(&mut state, &mut events, 0xff112233);
let (second, _wire2) = window(&mut state, &mut events, 0xff445566);
let a = state.windows.ids()[&first];
let b = state.windows.ids()[&second];
if tree {
let mut layout = ferese_layout::LayoutTree::default();
layout.insert(a, None, Axis::Horizontal, 0.5).unwrap();
layout.insert(b, Some(a), Axis::Horizontal, 0.5).unwrap();
state.workspaces.active_mut().layout = WorkspaceLayout::Tree(layout);
state.relayout();
}
let target = state.visual_rect_for_window(&second).unwrap();
pointer_interaction_begin(&mut state, first.clone(), false);
motion(
&mut state,
(target.loc.x as f64 + target.size.w as f64 / 2.0, 250.0),
);
button(&mut state, 0x110, false);
let group = state.workspaces.active().layout.tab_group(a).unwrap();
assert!(group.windows.contains(&b));
assert_eq!(group.active, a);
assert!(state.space.element_location(&second).is_none());
pointer_interaction_begin(&mut state, first.clone(), false);
button(&mut state, 0x111, true);
motion(&mut state, (200.0, 180.0));
button(&mut state, 0x111, false);
button(&mut state, 0x110, false);
assert!(matches!(
state.workspaces.placement(a),
Some(WindowPlacement::Floating { .. })
));
assert!(state.space.element_location(&second).is_some());
let target = state.visual_rect_for_window(&second).unwrap();
pointer_interaction_begin(&mut state, first.clone(), false);
button(&mut state, 0x111, true);
motion(&mut state, (target.loc.x as f64 + 10.0, 200.0));
button(&mut state, 0x111, false);
button(&mut state, 0x110, false);
assert_eq!(state.workspaces.placement(a), Some(WindowPlacement::Tiled));
assert!(state.workspaces.active().layout.tab_group(a).is_none());
state.workspaces.validate().unwrap();
}
}
#[test]
fn pointer_drop_crosses_negative_rotated_scaled_output_and_preserves_visual_origin() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_drop_crosses_negative_rotated_scaled_output_and_preserves_visual_origin",
) {
return;
}
let (mut events, mut state, first_output) = fixture();
let second = Output::new("second".into(), first_output.physical_properties());
second.change_current_state(
first_output.current_mode(),
Some(smithay::utils::Transform::_90),
Some(smithay::output::Scale::Fractional(1.5)),
None,
);
state.space.map_output(&second, (-400, -150));
state.register_output(&second, "second".into());
let unaffected = Output::new("unaffected".into(), first_output.physical_properties());
unaffected.change_current_state(first_output.current_mode(), None, None, None);
state.space.map_output(&unaffected, (1600, 0));
state.register_output(&unaffected, "unaffected".into());
let (window, _wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
state.animations_enabled = true;
pointer_interaction_begin(&mut state, window.clone(), false);
state.output_redraw_pending.clear();
motion(&mut state, (-200.0, 100.0));
assert!(
!state.output_redraw_pending.contains(&unaffected),
"dragging must not redraw an unrelated output"
);
let dragged = state.pointer_operation.as_ref().unwrap().visual.unwrap();
let frame = state.sample_frame(&second, Duration::ZERO);
assert!(
frame.windows.contains_key(&id),
"dragged window must be sampled on the destination output"
);
assert_eq!(frame.windows[&id].presentation.bounds.current, dragged);
button(&mut state, 0x110, false);
assert_eq!(
state.workspaces.workspace_for_window(id),
state
.output_id(&second)
.and_then(|id| state.output_workspaces.active_workspace(id))
);
assert_eq!(
state.windows.geometry(&id).unwrap().visual.current,
dragged,
"landing must animate from the pointer, not the old tile"
);
let pointer = state.seat.get_pointer().unwrap();
assert_eq!(
pointer.current_focus(),
state
.surface_under(pointer.current_location())
.map(|(surface, _)| surface)
);
state.workspaces.validate().unwrap();
}
#[test]
fn pointer_grab_cancels_on_reload_output_loss_and_unmap() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_grab_cancels_on_reload_output_loss_and_unmap",
) {
return;
}
for reason in ["reload", "output", "unmap", "overview", "escape"] {
let (mut events, mut state, output) = fixture();
let (window, _wire) = window(&mut state, &mut events, 0xff112233);
let pointer = pointer_interaction_begin(&mut state, window.clone(), false);
motion(&mut state, (700.0, 300.0));
match reason {
"reload" => {
state.reload_config_source("input { touchpad { accel-speed 0.4; disable-while-mouse-connected #true; } }".into()).unwrap();
}
"output" => state.unregister_output(&output),
"unmap" => state.remove_tiled_window(&window),
"overview" => state.set_overview_active(true),
"escape" => {
crate::input::binding_mode_tests::key(&mut state, 9, true);
crate::input::binding_mode_tests::key(&mut state, 9, false);
}
_ => unreachable!(),
}
state.poll_window_interaction();
assert!(state.pointer_operation.is_none(), "{reason}");
assert!(!pointer.is_grabbed(), "{reason}");
state.workspaces.validate().unwrap();
if reason == "reload" {
assert_eq!(state.input_settings.touchpad.accel_speed, 0.4);
assert!(state.input_settings.touchpad.disable_while_mouse_connected);
}
}
}
#[test]
fn pointer_tiled_resize_rolls_back_without_losing_concurrent_layout_changes() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_tiled_resize_rolls_back_without_losing_concurrent_layout_changes",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (first, _wire1) = window(&mut state, &mut events, 0xff112233);
let (second, _wire2) = window(&mut state, &mut events, 0xff445566);
let a = state.windows.ids()[&first];
let before = state.workspaces.active().layout.clone();
pointer_interaction_begin(&mut state, first.clone(), true);
motion(&mut state, (750.0, 500.0));
assert_ne!(state.workspaces.active().layout, before);
state.cancel_window_interaction();
assert_eq!(
state
.workspaces
.active()
.layout
.geometry(Rect::new(0., 0., 800., 600.))
.unwrap(),
before.geometry(Rect::new(0., 0., 800., 600.)).unwrap()
);
pointer_interaction_begin(&mut state, first, true);
motion(&mut state, (750.0, 500.0));
let (third, _wire3) = window(&mut state, &mut events, 0xff778899);
state.poll_window_interaction();
assert!(state.pointer_operation.is_none());
for id in [a, state.windows.ids()[&second], state.windows.ids()[&third]] {
assert!(state.workspaces.active().layout.contains(id));
}
state.workspaces.validate().unwrap();
}
#[test]
fn pointer_fullscreen_and_maximize_preserve_restore_geometry_and_zero_threshold_is_safe() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_fullscreen_and_maximize_preserve_restore_geometry_and_zero_threshold_is_safe",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (window, _wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
state.focused_window = Some(id);
state.toggle_focused_floating();
state.set_floating_window_geometry(&window, (200, 150).into(), (300, 200).into());
let original = state.workspaces.placement(id);
state.set_window_fullscreen(id, true);
let pointer = state.seat.get_pointer().unwrap();
assert!(!state.begin_window_interaction(
&pointer,
window.clone(),
GrabStartData {
focus: None,
button: 0x110,
location: (300.0, 200.0).into()
},
SERIAL_COUNTER.next_serial(),
None
));
state.set_window_fullscreen(id, false);
state.animations_enabled = true;
state.set_window_maximized(id, true);
pointer_interaction_begin(&mut state, window.clone(), false);
motion(&mut state, (500.0, 350.0));
let visual = state.pointer_operation.as_ref().unwrap().visual.unwrap();
assert_eq!((visual.width, visual.height), (300.0, 200.0));
state.cancel_window_interaction();
assert!(state.windows.record(id).unwrap().maximized);
assert_eq!(state.workspaces.placement(id), original);
state.input_settings.mouse.drag_threshold = 0.0;
pointer_interaction_begin(&mut state, window, false);
button(&mut state, 0x111, true);
button(&mut state, 0x111, false);
button(&mut state, 0x110, false);
assert!(state.windows.record(id).unwrap().maximized);
assert_eq!(state.workspaces.placement(id), original);
}
#[test]
fn pointer_scroll_reaches_hidden_columns_without_changing_window_membership() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_scroll_reaches_hidden_columns_without_changing_window_membership",
) {
return;
}
let (mut events, mut state, _) = fixture();
let clients = (0..5)
.map(|_| window(&mut state, &mut events, 0xff112233))
.collect::<Vec<_>>();
let before = state.workspaces.active().layout.viewport_x().unwrap();
assert!(before > 0.0);
let window = clients.last().unwrap().0.clone();
let pointer = pointer_interaction_begin(&mut state, window, false);
motion(&mut state, (400.0, 200.0));
pointer.axis(
&mut state,
smithay::input::pointer::AxisFrame::new(smithay::backend::input::InputTime::from_millis(4))
.value(smithay::backend::input::Axis::Vertical, -200.0),
);
state.poll_window_interaction();
assert!(state.workspaces.active().layout.viewport_x().unwrap() < before);
assert_eq!(state.workspaces.active().layout.window_ids().count(), 5);
state.cancel_window_interaction();
state.workspaces.validate().unwrap();
}
#[test]
fn pointer_lock_cancels_drag_and_releases_buttons_before_lock_surfaces_receive_input() {
use smithay::reexports::wayland_server::Resource;
use smithay::reexports::wayland_protocols::ext::session_lock::v1::server::ext_session_lock_manager_v1::ExtSessionLockManagerV1;
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_lock_cancels_drag_and_releases_buttons_before_lock_surfaces_receive_input",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (window, _window_wire) = window(&mut state, &mut events, 0xff112233);
let (server, mut wire) = std::os::unix::net::UnixStream::pair().unwrap();
let client = state
.display_handle
.insert_client(server, std::sync::Arc::new(ClientState::default()))
.unwrap();
let pointer = pointer_interaction_begin(&mut state, window, false);
motion(&mut state, (700.0, 200.0));
let manager = client
.create_resource::<ExtSessionLockManagerV1, smithay::wayland::GlobalData, Ferese>(
&state.display_handle,
1,
smithay::wayland::GlobalData,
)
.unwrap();
super::capture_privacy::tests::request(&mut wire, manager.id().protocol_id(), 1, &[2], None);
for _ in 0..20 {
super::capture_privacy::tests::dispatch(&mut events, &mut state);
if state.session_lock.active() {
break;
}
}
state.poll_window_interaction();
assert!(state.session_lock.active());
assert!(state.pointer_operation.is_none());
assert!(!pointer.is_grabbed());
assert!(state.consumed_pointer_buttons.contains(&0x110));
assert_eq!(pointer.current_focus(), None);
}
#[test]
fn pointer_floating_resize_keeps_edge_snapping_and_shift_bypass() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_floating_resize_keeps_edge_snapping_and_shift_bypass",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (window, _wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
state.focused_window = Some(id);
state.toggle_focused_floating();
state.set_floating_window_geometry(&window, (100, 100).into(), (200, 200).into());
pointer_interaction_begin(&mut state, window, true);
// The helper starts at (200, 200). A 493px delta puts the right edge 7px
// from the output edge, inside the existing 10px snap distance.
motion(&mut state, (693.0, 210.0));
let Some(WindowPlacement::Floating { rect }) = state.workspaces.placement(id) else {
panic!("floating window");
};
assert_eq!(rect.x + rect.width, 800.0);
crate::input::binding_mode_tests::key(&mut state, 50, true);
motion(&mut state, (694.0, 210.0));
let Some(WindowPlacement::Floating { rect }) = state.workspaces.placement(id) else {
panic!("floating window");
};
assert_eq!(rect.x + rect.width, 794.0);
crate::input::binding_mode_tests::key(&mut state, 50, false);
state.cancel_window_interaction();
}
#[test]
fn pointer_scrolling_left_resize_anchors_the_right_edge_and_cancels() {
if !crate::startup_tests::private_runtime(
"state::pointer_tests::pointer_scrolling_left_resize_anchors_the_right_edge_and_cancels",
) {
return;
}
let (mut events, mut state, _) = fixture();
let (window, _wire) = window(&mut state, &mut events, 0xff112233);
let id = state.windows.ids()[&window];
let before = state.windows.geometry(&id).unwrap().logical;
let pointer = state.seat.get_pointer().unwrap();
let start = Point::from((before.x, before.y + before.height / 2.0));
assert!(state.begin_window_interaction(
&pointer,
window,
GrabStartData {
focus: None,
button: 0x111,
location: start,
},
SERIAL_COUNTER.next_serial(),
Some(xdg_toplevel::ResizeEdge::Left.into())
));
button(&mut state, 0x111, true);
motion(&mut state, (start.x + 100.0, start.y));
let after = state.windows.geometry(&id).unwrap().logical;
assert_eq!(
after.x + after.width,
before.x + before.width,
"dragging the left edge moved the opposite edge: {before:?} -> {after:?}"
);
assert_eq!(after.x, before.x + 100.0);
state.cancel_window_interaction();
assert!(!pointer.is_grabbed());
assert_eq!(state.windows.geometry(&id).unwrap().logical, before);
}
+24
View File
@@ -155,6 +155,15 @@ impl Ferese {
0.03 * opacity.velocity, 0.03 * opacity.velocity,
); );
bounds.current = crate::presentation::scaled_visual_rect(bounds.current, presence_scale); bounds.current = crate::presentation::scaled_visual_rect(bounds.current, presence_scale);
if let Some(rect) = self
.pointer_operation
.as_ref()
.filter(|operation| operation.id == id)
.and_then(|operation| operation.visual)
{
bounds.current = rect;
bounds.velocity = Default::default();
}
bounds.target = bounds.current; bounds.target = bounds.current;
let (focus_alpha, dim) = self.window_focus_effect(id, delta, overview.opacity()); let (focus_alpha, dim) = self.window_focus_effect(id, delta, overview.opacity());
crate::presentation::WindowPresentation { crate::presentation::WindowPresentation {
@@ -195,6 +204,21 @@ impl Ferese {
)) ))
}) })
}) })
.chain(
self.pointer_operation
.as_ref()
.filter(|operation| {
operation.visual.is_some()
&& self.workspaces.workspace_for_window(operation.id).and_then(
|workspace| self.output_workspaces.output_for_workspace(workspace),
) != self.output_id(output)
})
.and_then(|operation| {
self.windows
.record(operation.id)
.map(|record| (operation.id, record, false))
}),
)
} }
pub(crate) fn output_has_pending_visual_changes(&self, output: &Output) -> bool { pub(crate) fn output_has_pending_visual_changes(&self, output: &Output) -> bool {
+13 -16
View File
@@ -1,10 +1,10 @@
use super::capture_privacy::tests::{ack_configure, dispatch, request, window}; use super::capture_privacy::tests::{ack_configure, dispatch, request, window};
use super::*; use super::*;
use smithay::input::pointer::{Focus, GrabStartData, MotionEvent}; use smithay::input::pointer::{GrabStartData, MotionEvent};
use smithay::reexports::wayland_server::Resource; use smithay::reexports::wayland_server::Resource;
use smithay::utils::SERIAL_COUNTER; use smithay::utils::SERIAL_COUNTER;
fn fixture() -> ( pub(super) fn fixture() -> (
smithay::reexports::calloop::EventLoop<'static, Ferese>, smithay::reexports::calloop::EventLoop<'static, Ferese>,
Ferese, Ferese,
Output, Output,
@@ -1228,21 +1228,17 @@ fn malformed_resize_limits_disconnect_only_the_client_and_motion_survives() {
state.focused_window = Some(id); state.focused_window = Some(id);
state.toggle_focused_floating(); state.toggle_focused_floating();
let pointer = state.seat.get_pointer().unwrap(); let pointer = state.seat.get_pointer().unwrap();
pointer.set_grab( assert!(state.begin_window_interaction(
&mut state, &pointer,
crate::grabs::ResizeSurfaceGrab::new( window,
GrabStartData { GrabStartData {
focus: None, focus: None,
button: 0x110, button: 0x110,
location: (100.0, 100.0).into(), location: (100.0, 100.0).into()
}, },
window,
xdg_toplevel::ResizeEdge::BottomRight.into(),
Rectangle::new((100, 100).into(), (64, 48).into()),
),
SERIAL_COUNTER.next_serial(), SERIAL_COUNTER.next_serial(),
Focus::Clear, Some(xdg_toplevel::ResizeEdge::BottomRight.into()),
); ));
request(&mut wire, 4, 8, &[minimum.0 as u32, minimum.1 as u32], None); request(&mut wire, 4, 8, &[minimum.0 as u32, minimum.1 as u32], None);
request(&mut wire, 4, 7, &[maximum.0 as u32, maximum.1 as u32], None); request(&mut wire, 4, 7, &[maximum.0 as u32, maximum.1 as u32], None);
request(&mut wire, 2, 6, &[], None); request(&mut wire, 2, 6, &[], None);
@@ -1261,6 +1257,7 @@ fn malformed_resize_limits_disconnect_only_the_client_and_motion_survives() {
SERIAL_COUNTER.next_serial(), SERIAL_COUNTER.next_serial(),
smithay::backend::input::InputTime::from_millis(2), smithay::backend::input::InputTime::from_millis(2),
); );
state.poll_window_interaction();
wire.set_nonblocking(true).unwrap(); wire.set_nonblocking(true).unwrap();
let mut messages = Vec::new(); let mut messages = Vec::new();
let _ = wire.read_to_end(&mut messages); let _ = wire.read_to_end(&mut messages);
+25
View File
@@ -123,6 +123,31 @@ impl WorkspaceLayout {
} }
} }
pub fn place_window(
&mut self,
window: WindowId,
target: WindowId,
placement: ferese_layout::DropPlacement,
) -> Result<(), LayoutError> {
match self {
Self::Tree(layout) => layout.place_window(window, target, placement),
Self::Scrolling(layout) => layout.place_window(window, target, placement),
}
}
pub fn resize_edge(
&mut self,
window: WindowId,
edge: Direction,
delta: f64,
bounds: Rect,
) -> Result<bool, LayoutError> {
match self {
Self::Tree(layout) => layout.resize_edge(window, edge, delta, bounds),
Self::Scrolling(layout) => layout.resize_edge(window, edge, delta, bounds),
}
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> { pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
match self { match self {
Self::Scrolling(layout) => layout.remove(window), Self::Scrolling(layout) => layout.remove(window),
+128
View File
@@ -279,6 +279,134 @@ impl LayoutTree {
Ok(window_node) Ok(window_node)
} }
/// Reinsert a window beside a target, or into its tab group.
pub fn place_window(
&mut self,
window: WindowId,
target: WindowId,
placement: crate::DropPlacement,
) -> Result<(), LayoutError> {
use crate::DropPlacement::*;
if !self.contains(window) {
return Err(LayoutError::UnknownWindow(window));
}
if !self.contains(target) {
return Err(LayoutError::UnknownWindow(target));
}
if window == target {
return Ok(());
}
if placement == Tab {
return self.stack_window(window, target);
}
let mut next = self.clone();
next.remove(window)?;
let axis = if matches!(placement, Left | Right) {
Axis::Horizontal
} else {
Axis::Vertical
};
let leaf = next.insert(window, Some(target), axis, 0.5)?;
if matches!(placement, Left | Above) {
let parent = next.parents[&leaf];
if let Some(Node::Split { first, second, .. }) = next.nodes.get_mut(&parent) {
std::mem::swap(first, second);
}
}
next.validate()?;
*self = next;
Ok(())
}
/// Move a selected split edge by a signed logical-pixel distance.
pub fn resize_edge(
&mut self,
window: WindowId,
edge: Direction,
delta: f64,
bounds: Rect,
) -> Result<bool, LayoutError> {
let mut child = *self
.windows
.get(&window)
.ok_or(LayoutError::UnknownWindow(window))?;
if !delta.is_finite() {
return Ok(false);
}
while let Some(parent) = self.parents.get(&child).copied() {
let matches = match self.nodes.get(&parent) {
Some(Node::Split {
axis: Axis::Horizontal,
first,
second,
..
}) => {
(edge == Direction::Right && *first == child)
|| (edge == Direction::Left && *second == child)
}
Some(Node::Split {
axis: Axis::Vertical,
first,
second,
..
}) => {
(edge == Direction::Down && *first == child)
|| (edge == Direction::Up && *second == child)
}
_ => false,
};
if matches {
let mut path = Vec::new();
let mut node = parent;
while let Some(ancestor) = self.parents.get(&node).copied() {
path.push((ancestor, node));
node = ancestor;
}
let mut area = bounds;
for (ancestor, descendant) in path.into_iter().rev() {
if let Some(Node::Split {
axis, ratio, first, ..
}) = self.nodes.get(&ancestor)
{
match axis {
Axis::Horizontal => {
let part = area.width * ratio;
if *first == descendant {
area.width = part;
} else {
area.x += part;
area.width -= part;
}
}
Axis::Vertical => {
let part = area.height * ratio;
if *first == descendant {
area.height = part;
} else {
area.y += part;
area.height -= part;
}
}
}
}
}
if let Some(Node::Split { ratio, .. }) = self.nodes.get_mut(&parent) {
let extent = if matches!(edge, Direction::Left | Direction::Right) {
area.width
} else {
area.height
};
let next = normalized_ratio(*ratio + delta / extent.max(1.0));
let changed = next != *ratio;
*ratio = next;
return Ok(changed);
}
}
child = parent;
}
Ok(false)
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> { pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
let leaf = self let leaf = self
.windows .windows
+353
View File
@@ -6,3 +6,356 @@ pub use layout_tree::*;
pub use scrolling::{ pub use scrolling::{
Column, ColumnDisplay, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget, Column, ColumnDisplay, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget,
}; };
/// The destination of an interactive tiled-window drop.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DropPlacement {
Left,
Right,
Above,
Below,
Tab,
}
#[cfg(test)]
mod pointer_tests {
use super::*;
#[test]
fn pointer_tree_drop_preserves_windows_and_is_atomic_for_unknown_targets() {
for placement in [
DropPlacement::Left,
DropPlacement::Right,
DropPlacement::Above,
DropPlacement::Below,
DropPlacement::Tab,
] {
let mut tree = LayoutTree::default();
for id in 1..=4 {
tree.insert(WindowId(id), None, Axis::Horizontal, 0.5)
.unwrap();
}
tree.place_window(WindowId(1), WindowId(3), placement)
.unwrap();
tree.validate().unwrap();
assert_eq!(tree.window_ids().count(), 4);
let before = tree.clone();
assert!(
tree.place_window(WindowId(1), WindowId(99), placement)
.is_err()
);
assert_eq!(tree, before);
tree.place_window(WindowId(1), WindowId(1), placement)
.unwrap();
assert_eq!(tree, before);
}
}
#[test]
fn pointer_scrolling_drop_preserves_window_and_row_invariants() {
for placement in [
DropPlacement::Left,
DropPlacement::Right,
DropPlacement::Above,
DropPlacement::Below,
DropPlacement::Tab,
] {
let mut layout = ScrollingLayout::default();
for id in 1..=4 {
layout.insert(WindowId(id), None).unwrap();
}
layout
.place_window(WindowId(1), WindowId(3), placement)
.unwrap();
layout.validate().unwrap();
assert_eq!(layout.window_ids().count(), 4);
let column = layout
.columns()
.iter()
.find(|column| column.windows.contains(&WindowId(1)))
.unwrap();
if placement == DropPlacement::Tab {
assert_eq!(column.display, ColumnDisplay::Tabbed);
assert_eq!(column.windows.len(), 2);
}
let before = layout.clone();
assert!(
layout
.place_window(WindowId(1), WindowId(99), placement)
.is_err()
);
assert_eq!(layout, before);
}
}
#[test]
fn pointer_tree_resize_moves_the_selected_boundary_in_both_directions() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
let bounds = Rect::new(-1000.0, -500.0, 1000.0, 600.0);
let before = tree.geometry(bounds).unwrap();
assert!(
tree.resize_edge(WindowId(1), Direction::Right, 100.0, bounds)
.unwrap()
);
let after = tree.geometry(bounds).unwrap();
assert_eq!(
after[&WindowId(1)].width,
before[&WindowId(1)].width + 100.0
);
assert_eq!(
after[&WindowId(2)].width,
before[&WindowId(2)].width - 100.0
);
assert!(
tree.resize_edge(WindowId(2), Direction::Left, -100.0, bounds)
.unwrap()
);
assert_eq!(tree.geometry(bounds).unwrap(), before);
assert!(
!tree
.resize_edge(WindowId(1), Direction::Left, 100.0, bounds)
.unwrap()
);
assert!(
!tree
.resize_edge(WindowId(1), Direction::Right, f64::NAN, bounds)
.unwrap()
);
}
}
#[cfg(test)]
mod pointer_sequence_tests {
use super::*;
#[test]
fn repeated_pointer_drops_and_resizes_keep_tabbed_layouts_valid() {
let mut tree = LayoutTree::default();
let mut scrolling = ScrollingLayout::default();
for id in 1..=8 {
tree.insert(WindowId(id), None, Axis::Horizontal, 0.5)
.unwrap();
scrolling.insert(WindowId(id), None).unwrap();
}
let bounds = Rect::new(-1200.0, -300.0, 1200.0, 800.0);
let mut seed = 42_u64;
for _ in 0..1000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let source = WindowId(1 + (seed >> 32) % 8);
let target = WindowId(1 + (seed >> 40) % 8);
let placement = [
DropPlacement::Left,
DropPlacement::Right,
DropPlacement::Above,
DropPlacement::Below,
DropPlacement::Tab,
][(seed >> 48) as usize % 5];
tree.place_window(source, target, placement).unwrap();
scrolling.place_window(source, target, placement).unwrap();
let edge = [
Direction::Left,
Direction::Right,
Direction::Up,
Direction::Down,
][(seed >> 52) as usize % 4];
let delta = (seed >> 16) as i16 as f64;
tree.resize_edge(source, edge, delta, bounds).unwrap();
scrolling.resize_edge(source, edge, delta, bounds).unwrap();
tree.validate().unwrap();
scrolling.validate().unwrap();
assert_eq!(tree.window_ids().count(), 8);
assert_eq!(scrolling.window_ids().count(), 8);
for id in 1..=8 {
assert!(tree.contains(WindowId(id)));
assert!(scrolling.contains(WindowId(id)));
}
}
}
}
#[cfg(test)]
mod pointer_regression_tests {
use super::*;
use std::collections::HashMap;
#[test]
fn edge_drop_on_tabbed_column_shows_rows_and_center_drop_keeps_tabs() {
for placement in [
DropPlacement::Above,
DropPlacement::Below,
DropPlacement::Tab,
] {
let mut layout = ScrollingLayout::default();
for id in 1..=3 {
layout.insert(WindowId(id), None).unwrap();
}
layout
.place_window(WindowId(1), WindowId(2), DropPlacement::Tab)
.unwrap();
layout
.place_window(WindowId(3), WindowId(2), placement)
.unwrap();
let result = layout
.resolve_geometry_with_constraints(
Rect::new(0.0, 0.0, 1200.0, 800.0),
GapConfig::default(),
&HashMap::new(),
Some(WindowId(3)),
)
.unwrap();
layout.validate().unwrap();
assert_eq!(layout.window_ids().count(), 3);
if placement == DropPlacement::Tab {
assert_eq!(result.geometry.len(), 1);
assert!(result.geometry.contains_key(&WindowId(3)));
assert_eq!(layout.tab_group(WindowId(3)).unwrap().windows.len(), 3);
} else {
assert_eq!(result.geometry.len(), 3);
let dropped = result.geometry[&WindowId(3)];
let target = result.geometry[&WindowId(2)];
assert_eq!(dropped.y < target.y, placement == DropPlacement::Above);
assert!(layout.tab_group(WindowId(3)).is_none());
}
}
}
#[test]
fn left_resize_should_move_left_edge_and_preserve_right_edge() {
let mut layout = ScrollingLayout::default();
layout.insert(WindowId(1), None).unwrap();
let bounds = Rect::new(0.0, 0.0, 1200.0, 800.0);
let before = layout
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(1)),
)
.unwrap()
.geometry[&WindowId(1)];
layout
.resize_edge(WindowId(1), Direction::Left, 100.0, bounds)
.unwrap();
let after = layout
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(1)),
)
.unwrap()
.geometry[&WindowId(1)];
assert_eq!(
after.x + after.width,
before.x + before.width,
"left-edge drag changed the right edge: {before:?} -> {after:?}"
);
}
#[test]
fn horizontal_resize_anchors_survive_limits_focus_policies_and_held_buffers() {
let bounds = Rect::new(-1200.0, -300.0, 1200.0, 800.0);
for strategy in [
ViewportFocusStrategy::Minimal,
ViewportFocusStrategy::Center,
ViewportFocusStrategy::Paged,
] {
for tabbed in [false, true] {
for constraint in [
SizeConstraints::default(),
SizeConstraints {
min_width: 700.0,
..Default::default()
},
SizeConstraints {
max_width: Some(250.0),
..Default::default()
},
] {
let mut base = ScrollingLayout::default();
base.set_focus_strategy(strategy);
for id in 1..=3 {
base.insert(WindowId(id), None).unwrap();
}
if tabbed {
base.place_window(WindowId(3), WindowId(2), DropPlacement::Tab)
.unwrap();
}
let window = WindowId(2);
let constraints =
HashMap::from([(if tabbed { WindowId(3) } else { window }, constraint)]);
let before = base
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&constraints,
Some(window),
)
.unwrap()
.geometry[&window];
let initial_viewport = base.viewport_x();
let initial_width = base.allocated_column_width(window).unwrap();
for direction in [Direction::Left, Direction::Right] {
for delta in [-1000.0, -100.0, 100.0, 1000.0] {
let mut layout = base.clone();
layout
.resize_edge(window, direction, delta, bounds)
.unwrap();
let after = layout
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&constraints,
Some(window),
)
.unwrap()
.geometry[&window];
let edge = |rect: Rect| {
if direction == Direction::Left {
rect.x + rect.width
} else {
rect.x
}
};
assert!(
(edge(after) - edge(before)).abs() < 0.001,
"{direction:?} {delta} {strategy:?} {constraint:?}: {before:?} -> {after:?}"
);
let target = layout.viewport_target().unwrap();
let held = target.with_held_widths(
&[(
window,
initial_width,
layout.allocated_column_width(window).unwrap(),
)],
None,
);
assert!(
(held - initial_viewport).abs() < 0.001,
"pending buffers must retain the old viewport"
);
let settled = layout.clone();
layout
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&constraints,
Some(window),
)
.unwrap();
assert_eq!(
layout, settled,
"an idle relayout must not reapply the resize"
);
layout.validate().unwrap();
}
}
}
}
}
}
}
+251 -18
View File
@@ -121,6 +121,12 @@ enum ViewportRequest {
WidthCycle(usize), WidthCycle(usize),
Slide(usize, usize), Slide(usize, usize),
Center(usize, Option<f64>), Center(usize, Option<f64>),
Resize {
column: usize,
anchor: f64,
anchor_right: bool,
maximum: Option<f64>,
},
} }
impl ViewportTarget { impl ViewportTarget {
@@ -162,6 +168,18 @@ impl ViewportTarget {
}); });
layout.viewport_x = positions[active].0 + width / 2.0 - self.viewport_width / 2.0 layout.viewport_x = positions[active].0 + width / 2.0 - self.viewport_width / 2.0
} }
ViewportRequest::Resize {
column,
anchor,
anchor_right,
maximum,
} => {
let width = maximum.map_or(positions[column].1, |maximum| {
positions[column].1.min(maximum)
});
layout.viewport_x =
positions[column].0 + if anchor_right { width } else { 0.0 } - anchor;
}
} }
layout.viewport_x layout.viewport_x
} }
@@ -204,6 +222,14 @@ enum PendingViewportRequest {
// Width cycling takes precedence. Keep the focus request only in case a // Width cycling takes precedence. Keep the focus request only in case a
// subsequent no-op cycle cancels the width request before layout resolves. // subsequent no-op cycle cancels the width request before layout resolves.
WidthCycle(Option<PendingFocusRequest>), WidthCycle(Option<PendingFocusRequest>),
// Resolve the opposite edge from the previous strip position and allocation
// after applying client limits. Negative offsets keep the first column anchored.
Resize {
window: WindowId,
origin: f64,
width: f64,
anchor_right: bool,
},
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -242,11 +268,18 @@ impl ScrollingLayout {
match self.pending_viewport { match self.pending_viewport {
Some(PendingViewportRequest::Focus(request)) => Some(request), Some(PendingViewportRequest::Focus(request)) => Some(request),
Some(PendingViewportRequest::WidthCycle(request)) => request, Some(PendingViewportRequest::WidthCycle(request)) => request,
None => None, None | Some(PendingViewportRequest::Resize { .. }) => None,
} }
} }
fn request_focus(&mut self, request: Option<PendingFocusRequest>) { fn request_focus(&mut self, request: Option<PendingFocusRequest>) {
// Direct manipulation keeps its edge anchor through the next solve.
if matches!(
self.pending_viewport,
Some(PendingViewportRequest::Resize { .. })
) {
return;
}
self.pending_viewport = if matches!( self.pending_viewport = if matches!(
self.pending_viewport, self.pending_viewport,
Some(PendingViewportRequest::WidthCycle(_)) Some(PendingViewportRequest::WidthCycle(_))
@@ -371,6 +404,37 @@ impl ScrollingLayout {
self.viewport_x self.viewport_x
} }
/// Pan without changing focus, keeping the viewport inside the column strip.
pub fn scroll_viewport(&mut self, delta: f64) -> bool {
let Some(sizing) = self.last_column_sizing else {
return false;
};
if !delta.is_finite() || delta == 0.0 {
return false;
}
let width = self
.columns
.iter()
.map(|column| {
column
.windows
.first()
.and_then(|id| self.allocated_widths.get(id))
.copied()
.unwrap_or_else(|| sizing.resolve(column.width))
})
.sum::<f64>()
+ sizing.inner_gap * self.columns.len().saturating_sub(1) as f64;
let next = (self.viewport_x + delta).clamp(0.0, (width - sizing.viewport_width).max(0.0));
if next == self.viewport_x {
return false;
}
self.viewport_x = next;
self.pending_viewport = None;
self.viewport_basis = None;
true
}
#[must_use] #[must_use]
pub fn allocated_column_width(&self, window: WindowId) -> Option<f64> { pub fn allocated_column_width(&self, window: WindowId) -> Option<f64> {
self.allocated_widths.get(&window).copied() self.allocated_widths.get(&window).copied()
@@ -450,6 +514,133 @@ impl ScrollingLayout {
Ok(()) Ok(())
} }
pub fn place_window(
&mut self,
window: WindowId,
target: WindowId,
placement: crate::DropPlacement,
) -> Result<(), LayoutError> {
use crate::DropPlacement::*;
let (source, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
if !self.contains(target) {
return Err(LayoutError::UnknownWindow(target));
}
if window == target {
return Ok(());
}
let width = self.columns[source].width;
self.remove(window)?;
let (column, row) = self
.window_location(target)
.expect("target survives removing another window");
match placement {
Left | Right => {
let index = column + usize::from(placement == Right);
let mut entry = Column::new(window, width);
entry.display = self.default_display;
self.columns.insert(index, entry);
self.active_column = Some(index);
}
Above | Below | Tab => {
let entry = &mut self.columns[column];
let index = row + usize::from(placement != Above);
entry.windows.insert(index, window);
// Split the destination row's weight rather than collapsing it.
let weight = entry.heights[row] / 2.0;
entry.heights[row] = weight;
entry.heights.insert(index, weight);
entry.normalize_heights();
entry.display = if placement == Tab {
ColumnDisplay::Tabbed
} else {
ColumnDisplay::Stacked
};
entry.active = index;
self.active_column = Some(column);
}
}
self.request_reveal(Some(window));
debug_assert!(self.validate().is_ok());
Ok(())
}
pub fn resize_edge(
&mut self,
window: WindowId,
edge: Direction,
delta: f64,
bounds: Rect,
) -> Result<bool, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
if !delta.is_finite() {
return Ok(false);
}
if matches!(edge, Direction::Left | Direction::Right) {
let size = self.last_column_sizing.unwrap_or(ColumnSizing {
viewport_width: bounds.width,
inner_gap: 0.0,
});
let width = self
.allocated_column_width(window)
.unwrap_or_else(|| size.resolve(self.columns[column].width));
let origin = self.columns[..column]
.iter()
.map(|column| {
self.allocated_column_width(column.windows[0])
.unwrap_or_else(|| size.resolve(column.width))
+ size.inner_gap
})
.sum::<f64>()
- self.viewport_x;
let change = if edge == Direction::Left {
-delta
} else {
delta
};
let next = (width + change).clamp(
size.resolve(ColumnWidth::Proportion(MIN_COLUMN_WIDTH))
.max(1.0),
size.resolve(ColumnWidth::Proportion(MAX_COLUMN_WIDTH))
.max(1.0),
);
if next == width {
return Ok(false);
}
self.columns[column].width = ColumnWidth::Fixed(next);
self.pending_viewport = Some(PendingViewportRequest::Resize {
window,
origin,
width,
anchor_right: edge == Direction::Left,
});
return Ok(true);
}
let entry = &mut self.columns[column];
if entry.display == ColumnDisplay::Tabbed {
return Ok(false);
}
let neighbor = match edge {
Direction::Up => row.checked_sub(1),
Direction::Down => (row + 1 < entry.windows.len()).then_some(row + 1),
_ => None,
};
let Some(neighbor) = neighbor else {
return Ok(false);
};
let pair = entry.heights[row] + entry.heights[neighbor];
let change = (if edge == Direction::Up { -delta } else { delta }) / bounds.height.max(1.0);
let minimum = MIN_ROW_HEIGHT.min(pair / 2.0);
let height = (entry.heights[row] + change).clamp(minimum, pair - minimum);
let changed = height != entry.heights[row];
entry.heights[row] = height;
entry.heights[neighbor] = pair - height;
Ok(changed)
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> { pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column_index, window_index) = self let (column_index, window_index) = self
.window_location(window) .window_location(window)
@@ -897,6 +1088,42 @@ impl ScrollingLayout {
self.last_column_sizing.map(|sizing| sizing.viewport_width) != Some(viewport_width); self.last_column_sizing.map(|sizing| sizing.viewport_width) != Some(viewport_width);
self.last_column_sizing = Some(sizing); self.last_column_sizing = Some(sizing);
match self.pending_viewport.take() { match self.pending_viewport.take() {
Some(PendingViewportRequest::Resize {
window,
origin,
width,
anchor_right,
}) => {
if let Some((column, _)) = self.window_location(window) {
let constraint = if self.columns[column].display == ColumnDisplay::Tabbed {
tab_constraints(&self.columns[column], constraints)
} else {
layout_tree::normalized_constraints(
constraints.get(&window).copied().unwrap_or_default(),
)
};
let maximum = constraint.max_width;
let previous_width = maximum.map_or(width, |maximum| width.min(maximum));
let anchor = origin + if anchor_right { previous_width } else { 0.0 };
let next_width = maximum.map_or(column_positions[column].1, |maximum| {
column_positions[column].1.min(maximum)
});
self.record_viewport_basis(
&column_positions,
inner,
viewport_width,
ViewportRequest::Resize {
column,
anchor,
anchor_right,
maximum,
},
);
self.viewport_x = column_positions[column].0
+ if anchor_right { next_width } else { 0.0 }
- anchor;
}
}
Some(PendingViewportRequest::WidthCycle(_)) => { Some(PendingViewportRequest::WidthCycle(_)) => {
if let Some(active) = self.active_column { if let Some(active) = self.active_column {
self.record_viewport_basis( self.record_viewport_basis(
@@ -970,23 +1197,7 @@ impl ScrollingLayout {
); );
// All tabs use one size, even when their client constraints differ. // All tabs use one size, even when their client constraints differ.
// Keep the column allocation stable and honor compatible maximums. // Keep the column allocation stable and honor compatible maximums.
let mut common = SizeConstraints::default(); let common = tab_constraints(column, constraints);
for id in &column.windows {
let c = layout_tree::normalized_constraints(
constraints.get(id).copied().unwrap_or_default(),
);
common.min_width = common.min_width.max(c.min_width);
common.min_height = common.min_height.max(c.min_height);
common.max_width = match (common.max_width, c.max_width) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
common.max_height = match (common.max_height, c.max_height) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
let common = layout_tree::normalized_constraints(common);
layout_tree::record_minimum_warnings(window, rect, common, &mut result.warnings); layout_tree::record_minimum_warnings(window, rect, common, &mut result.warnings);
apply_maximums(window, &mut rect, common, &mut result.warnings); apply_maximums(window, &mut rect, common, &mut result.warnings);
if column.windows.len() > 1 { if column.windows.len() > 1 {
@@ -1331,6 +1542,28 @@ fn finite_nonnegative(value: f64) -> f64 {
} }
} }
fn tab_constraints(
column: &Column,
constraints: &HashMap<WindowId, SizeConstraints>,
) -> SizeConstraints {
let mut common = SizeConstraints::default();
for id in &column.windows {
let c =
layout_tree::normalized_constraints(constraints.get(id).copied().unwrap_or_default());
common.min_width = common.min_width.max(c.min_width);
common.min_height = common.min_height.max(c.min_height);
common.max_width = match (common.max_width, c.max_width) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
common.max_height = match (common.max_height, c.max_height) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
layout_tree::normalized_constraints(common)
}
fn apply_maximums( fn apply_maximums(
window: WindowId, window: WindowId,
rect: &mut Rect, rect: &mut Rect,
+6 -2
View File
@@ -117,19 +117,23 @@ Super+BackSpace last focused window; Alt+Tab/Alt+Shift+Tab MRU switching;
Super+Shift+S area screenshot; Print Screen whole-screen screenshot; Super+Shift+S area screenshot; Print Screen whole-screen screenshot;
Super+Shift+E logout confirmation. Super+Shift+E logout confirmation.
Hold Super and drag a floating window with the left mouse button to move it. Hold Super and drag a floating or tiled window with the left mouse button to move it.
Hold Super and drag with the right mouse button to resize. The starting position Hold Super and drag with the right mouse button to resize. The starting position
selects an edge or corner using a 3×3 grid; the center selects the nearest corner. selects an edge or corner using a 3×3 grid; the center selects the nearest corner.
The selected edges stay fixed for the whole drag. These work even when an app has The selected edges stay fixed for the whole drag. These work even when an app has
no title bar or resize border. no title bar or resize border.
Tiled drops can reorder windows, join tab groups or move between monitors.
Mouse buttons and wheel directions can also use the regular binding actions;
see [mouse bindings](input.md#mouse-bindings) for configuration and drag controls.
For a floating window, Super+Shift+H/J/K/L moves left/down/up/right by 32 logical For a floating window, Super+Shift+H/J/K/L moves left/down/up/right by 32 logical
pixels. Super+Ctrl+H/L shrinks/grows its width; Super+Ctrl+K/J shrinks/grows its pixels. Super+Ctrl+H/L shrinks/grows its width; Super+Ctrl+K/J shrinks/grows its
height by the same step. Keyboard adjustments respect client size limits and height by the same step. Keyboard adjustments respect client size limits and
keep the window reachable in its output's work area. Fullscreen and maximized keep the window reachable in its output's work area. Fullscreen and maximized
floating windows ignore these actions. floating windows ignore these actions.
Mouse moves and resizes snap to work-area edges and nearby visible window edges at Floating moves and resizes snap to work-area edges and nearby visible window edges at
10 logical pixels, then resist movement until 20 pixels from the attached edge. 10 logical pixels, then resist movement until 20 pixels from the attached edge.
Window edges are eligible only when the windows overlap along the other axis, Window edges are eligible only when the windows overlap along the other axis,
with a 10-pixel allowance. Hold Shift during a drag to bypass snapping. with a 10-pixel allowance. Hold Shift during a drag to bypass snapping.
+72 -3
View File
@@ -19,9 +19,43 @@ focus keys move the selection, Enter activates it, and Escape dismisses Overview
Clicking a workspace card's background switches workspaces while keeping Overview Clicking a workspace card's background switches workspaces while keeping Overview
open. Previews follow layout order and use the configured shell font and colors. open. Previews follow layout order and use the configured shell font and colors.
The touchpad device keys are booleans, all defaulting to `true`: `tap`, The touchpad keys `tap`, `natural-scroll` and `disable-while-typing` default to
`natural-scroll` and `disable-while-typing`. These apply to DRM devices; nested `true`. Set `disable-while-mouse-connected #true` to pause a built-in touchpad
previews use the host's physical input settings. while an external mouse is connected. It defaults to `false` and uses libinput's
external-mouse policy on supported touchpads, including when a mouse is plugged
in or removed during the session. The toggle is also in **Settings → Keyboard & mouse**.
These device settings apply to DRM sessions; nested previews use the host's
physical input settings.
Touchpad `accel-speed` is independent of the mouse setting. It accepts −1.0 to
1.0 and defaults to 0.0. Both speed controls are available in Settings and reload
immediately in a hardware session.
```kdl
input {
touchpad {
accel-speed 0.0
disable-while-mouse-connected #true
}
mouse {
natural-scroll #false
accel-speed 0.0
// accel-profile "flat"
left-handed #false
middle-emulation #false
scroll-factor 1.0
drag-threshold 4.0
scroll-cooldown-ms 150
}
}
```
Mouse settings are separate from touchpad settings and reload live. `accel-speed`
accepts -1.0 to 1.0; `accel-profile` accepts `adaptive` or `flat`, and follows the
device default when omitted. `scroll-factor` scales application wheel scrolling
(0.01–100). `drag-threshold` is the distance before a window drag starts (0–100
logical pixels). `scroll-cooldown-ms` limits repeated wheel bindings (0–5000 ms);
zero allows each accumulated tick to run its action.
Workspace swipes and left/right window-focus swipes follow your fingers and settle Workspace swipes and left/right window-focus swipes follow your fingers and settle
when released. Up goes to the next workspace on the when released. Up goes to the next workspace on the
@@ -51,3 +85,38 @@ hardware session.
Adjust recognition distance in **Settings → Keyboard & mouse**, or with Adjust recognition distance in **Settings → Keyboard & mouse**, or with
`swipe-threshold 80` under `input.touchpad` (integer 16–1000 logical pixels). `swipe-threshold 80` under `input.touchpad` (integer 16–1000 logical pixels).
## Mouse bindings
`Super+MouseLeft` drags a window and `Super+MouseRight` resizes it. Both work with
floating windows and tiled windows in scrolling or tree layouts. Drag a tiled
window to another window's edge to place it beside that window, or to its center
to join a tab group. Top and bottom drops into a tabbed column show its windows as
rows. Drag across monitors to move to the workspace shown there.
While dragging, right-click to switch between floating and tiling. Scroll or hold
the pointer at a horizontal screen edge to reach other scrolling columns.
Escape cancels the operation; releasing the initiating button finishes it.
Fullscreen windows keep their current layout until you leave fullscreen.
Mouse bindings use the same actions, overrides and named binding modes as keyboard
bindings. Ordinary actions use the current keyboard focus; `pointer-move` and
`pointer-resize` operate on the window under the pointer. Client popup grabs,
pointer constraints, shortcut inhibition and session locking take precedence.
```kdl
binding "Alt+MouseLeft" action="pointer-move"
binding "Alt+MouseRight" action="pointer-resize"
binding "Super+MouseLeft" disabled=#true
binding "Super+WheelUp" action="workspace-previous"
binding "Super+WheelDown" action="workspace-next"
binding "Super+TouchpadScrollLeft" action="focus" argument="left"
binding "Super+MouseBack" action="focus-last-window"
```
Buttons include `MouseLeft`, `MouseRight`, `MouseMiddle`, `MouseBack` and
`MouseForward`, or `mouse:272` through `mouse:287` for Linux mouse button codes.
Wheel directions are `WheelUp`, `WheelDown`, `WheelLeft` and `WheelRight`.
Touchpad scroll bindings use the `TouchpadScroll` prefix with the same directions.
Only a bound scroll axis is intercepted; the other axis still reaches the client.
High-resolution wheel steps accumulate into ticks. An intercepted touchpad scroll
keeps its event ownership until the gesture stops, even if modifiers change.
+9 -5
View File
@@ -22,15 +22,19 @@ Run `feresectl launcher toggle` to open or close it from a script.
| Action | Result | | Action | Result |
| --- | --- | | --- | --- |
| Super + left-button drag on a floating window | Move the window | | Super + left-button drag | Move a floating or tiled window |
| Super + right-button drag on a floating window | Resize the edge or corner selected by the starting 3×3 region | | Super + right-button drag | Resize the edge or corner selected by the starting 3×3 region |
| Right-click while moving | Switch the drop between floating and tiled |
| Escape during a drag | Cancel the move or resize |
| Hold Shift during a move or resize | Bypass edge snapping | | Hold Shift during a move or resize | Bypass edge snapping |
| Super + Shift + Space | Switch the focused window between tiled and floating | | Super + Shift + Space | Switch the focused window between tiled and floating |
You can drag from inside the window; a title bar or resize border is not required. You can drag from inside the window; a title bar or resize border is not required.
To place an app freely, toggle it to floating, then Super-drag it. Drop a tiled window beside another window to reorder it, or in its center to
The central resize region selects the nearest corner. Moving and resizing snap join a tab group. Drag across monitors to move to the workspace shown there.
to nearby edges; continuing to drag beyond 20 logical pixels releases the snap. Scroll or hold at a horizontal screen edge to reach more scrolling columns.
The central resize region selects the nearest corner. Floating windows snap to
nearby edges; continuing to drag beyond 20 logical pixels releases the snap.
## Focus, move, and resize ## Focus, move, and resize
+11
View File
@@ -61,10 +61,21 @@ input {
touchpad { touchpad {
tap #true tap #true
accel-speed 0.0
natural-scroll #true natural-scroll #true
disable-while-typing #true disable-while-typing #true
disable-while-mouse-connected #false
swipe-threshold 80 swipe-threshold 80
} }
mouse {
natural-scroll #false
accel-speed 0.0
left-handed #false
middle-emulation #false
scroll-factor 1.0
drag-threshold 4.0
scroll-cooldown-ms 150
}
} }
layout { layout {
+1
View File
@@ -74,6 +74,7 @@ static const struct wl_registry_listener registry_listener = {global, removed};
static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) { static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) {
(void)data; (void)data;
xdg_surface_ack_configure(xdg, serial); xdg_surface_ack_configure(xdg, serial);
if (!mapped) return;
xdg_surface_set_window_geometry(xdg, 0, 0, width, height); xdg_surface_set_window_geometry(xdg, 0, 0, width, height);
wl_surface_attach(surface, buffer, 0, 0); wl_surface_attach(surface, buffer, 0, 0);
wl_surface_damage(surface, 0, 0, width, height); wl_surface_damage(surface, 0, 0, width, height);
@@ -117,6 +117,7 @@ class Presentation(unittest.TestCase):
self.assertEqual(count("idle"), idle) self.assertEqual(count("idle"), idle)
target.stdin.write(b"u") target.stdin.write(b"u")
target.stdin.flush() target.stdin.flush()
windows(0)
time.sleep(.6) time.sleep(.6)
self.assertGreater(count("idle"), idle, "unmapped inhibitor must stop inhibiting") self.assertGreater(count("idle"), idle, "unmapped inhibitor must stop inhibiting")
target.stdin.close() target.stdin.close()
+1 -1
View File
@@ -152,7 +152,7 @@ impl App {
default: 1., default: 1.,
min: 0.75, min: 0.75,
max: 3., max: 3.,
step: 0.25, step: 0.05,
suffix: "×", suffix: "×",
integer: false, integer: false,
}, },
+1 -1
View File
@@ -375,7 +375,7 @@ impl App {
); );
} }
Page::Shortcuts => { Page::Shortcuts => {
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, workspace-back-and-forth, and none. Leave Argument empty for actions such as toggle-overview or none.")); body = body.push(self.note("Keyboard, mouse and swipe bindings share the same actions. Mouse shortcuts can use MouseLeft, MouseRight, MouseMiddle, MouseBack, MouseForward or WheelUp/Down/Left/Right with modifiers. Use pointer-move or pointer-resize for dragging. Swipes use Swipe3Up, Swipe3Down, Swipe3Left or Swipe3Right (also supports 4 or 5 fingers). Actions such as toggle-overview and none need no argument."));
let mut gestures = row([]).spacing(6); let mut gestures = row([]).spacing(6);
for (keys, label) in [ for (keys, label) in [
("Swipe3Up", "Customize swipe up"), ("Swipe3Up", "Customize swipe up"),
+63
View File
@@ -827,6 +827,69 @@ pub fn fields(page: Page) -> Vec<Field> {
"Avoid accidental pointer movement.", "Avoid accidental pointer movement.",
true, true,
), ),
toggle(
"input.touchpad.disable_while_mouse_connected",
"Disable touchpad with a mouse",
"Pause the built-in touchpad while an external mouse is connected.",
false,
),
range(
"input.touchpad.accel_speed",
"Touchpad speed",
"Adjust pointer acceleration for the touchpad.",
RangeSpec {
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.05,
suffix: "",
integer: false,
},
),
toggle(
"input.mouse.natural_scroll",
"Mouse natural scrolling",
"Reverse the direction of the mouse wheel.",
false,
),
toggle(
"input.mouse.left_handed",
"Left-handed mouse",
"Swap the primary and secondary mouse buttons.",
false,
),
toggle(
"input.mouse.middle_emulation",
"Emulate middle click",
"Press both mouse buttons together to middle-click.",
false,
),
range(
"input.mouse.accel_speed",
"Mouse speed",
"Adjust pointer acceleration for connected mice.",
RangeSpec {
default: 0.0,
min: -1.0,
max: 1.0,
step: 0.05,
suffix: "",
integer: false,
},
),
range(
"input.mouse.scroll_factor",
"Mouse scroll speed",
"Scale wheel scrolling in applications.",
RangeSpec {
default: 1.0,
min: 0.1,
max: 5.0,
step: 0.1,
suffix: "×",
integer: false,
},
),
range( range(
"input.touchpad.swipe_threshold", "input.touchpad.swipe_threshold",
"Swipe distance", "Swipe distance",