diff --git a/rift.default.toml b/rift.default.toml index c3f1d536..90ff2917 100644 --- a/rift.default.toml +++ b/rift.default.toml @@ -116,6 +116,28 @@ fingers = 3 # If true, scrolling past the end of the strip will trigger a workspace switch propagate_to_workspace_swipe = false +# Use mouse scroll wheel to scroll the column strip (scrolling layout only) +# Requires a modifier to avoid hijacking normal scrolling. +# modifier accepts modifiers ("Alt"), combos ("Ctrl+Alt"), or modifier+key ("Ctrl+Alt+Z") +[settings.layout.scrolling.mouse_scroll] +enabled = false +modifier = "Alt" +invert = false +sensitivity = 1.0 + +# Mouse scroll wheel to resize the focused column width +[settings.layout.mouse_resize] +enabled = false +modifier = "Ctrl+Alt" +invert = false +sensitivity = 1.0 + +# Mouse scroll wheel to move the focused column left/right +[settings.layout.mouse_move_column] +enabled = false +modifier = "Ctrl+Shift" +invert = false + [settings.layout.stack] # How much of each stacked window sticks out (in pixels) # Set to 0.0 to have them directly on top of each other. diff --git a/src/actor/event_tap.rs b/src/actor/event_tap.rs index 65a61bcb..3f9e59f4 100644 --- a/src/actor/event_tap.rs +++ b/src/actor/event_tap.rs @@ -26,8 +26,8 @@ use crate::layout_engine::LayoutCommand as LC; use crate::sys::event::{self, Hotkey, KeyCode, MouseState, set_mouse_state}; use crate::sys::geometry::CGRectExt; use crate::sys::hotkey::{ - Modifiers, is_modifier_key, key_code_from_event, modifier_flag_for_key, - modifiers_from_flags_with_keys, + HotkeySpec, Modifiers, is_modifier_key, key_code_from_event, modifier_flag_for_key, + modifiers_from_flags, modifiers_from_flags_with_keys, }; use crate::sys::screen::{CoordinateConverter, SpaceId}; use crate::sys::window_server::{self, WindowServerId, window_level}; @@ -67,6 +67,7 @@ pub struct EventTap { disable_hotkey: RefCell>, swipe: RefCell>, scroll: RefCell>, + mouse_wheel_handlers: RefCell>, hotkeys: RefCell>>, wm_sender: Option, stack_line_tx: Option, @@ -253,6 +254,22 @@ struct ScrollHandler { state: RefCell, } +#[derive(Debug, Clone, Copy, PartialEq)] +enum MouseWheelAction { + ScrollStrip, + Resize, + MoveColumn, +} + +struct MouseWheelHandler { + action: MouseWheelAction, + spec: HotkeySpec, + invert: bool, + sensitivity: f64, + threshold: f64, + accum: RefCell, +} + unsafe fn drop_mouse_ctx(ptr: *mut std::ffi::c_void) { unsafe { drop(Box::from_raw(ptr as *mut CallbackCtx)) }; } @@ -268,10 +285,15 @@ impl EventTap { state.focus_follows_mouse_config_enabled && state.focus_follows_mouse_enabled } + // Also builds mouse wheel handlers since they share the same config reload lifecycle. fn build_gesture_handlers( config: &Config, has_wm: bool, - ) -> (Option, Option) { + ) -> ( + Option, + Option, + Vec, + ) { let swipe_cfg = SwipeConfig::from_config(config); let swipe = if swipe_cfg.enabled && has_wm { Some(SwipeHandler { @@ -292,20 +314,67 @@ impl EventTap { None }; - (swipe, scroll) + let mut mouse_wheel = Vec::new(); + if has_wm { + let ms = &config.settings.layout.scrolling.mouse_scroll; + if ms.enabled { + if let Some(spec) = &ms.modifier { + mouse_wheel.push(MouseWheelHandler { + action: MouseWheelAction::ScrollStrip, + spec: spec.clone(), + invert: ms.invert, + sensitivity: ms.sensitivity.max(0.0), + threshold: 4.0, + accum: RefCell::new(0.0), + }); + } + } + let mr = &config.settings.layout.mouse_resize; + if mr.enabled { + if let Some(spec) = &mr.modifier { + mouse_wheel.push(MouseWheelHandler { + action: MouseWheelAction::Resize, + spec: spec.clone(), + invert: mr.invert, + sensitivity: mr.sensitivity.max(0.0), + threshold: 4.0, + accum: RefCell::new(0.0), + }); + } + } + let mc = &config.settings.layout.mouse_move_column; + if mc.enabled { + if let Some(spec) = &mc.modifier { + mouse_wheel.push(MouseWheelHandler { + action: MouseWheelAction::MoveColumn, + spec: spec.clone(), + invert: mc.invert, + sensitivity: 1.0, + threshold: 5.0, + accum: RefCell::new(0.0), + }); + } + } + } + + (swipe, scroll, mouse_wheel) } fn update_gesture_handlers(&self) { let config = self.config.borrow(); - let (swipe, scroll) = Self::build_gesture_handlers(&config, self.wm_sender.is_some()); + let (swipe, scroll, mouse_wheel) = + Self::build_gesture_handlers(&config, self.wm_sender.is_some()); *self.swipe.borrow_mut() = swipe; *self.scroll.borrow_mut() = scroll; + *self.mouse_wheel_handlers.borrow_mut() = mouse_wheel; } fn gesture_handlers_enabled(&self) -> bool { self.swipe.borrow().is_some() || self.scroll.borrow().is_some() } + fn scroll_wheel_needed(&self) -> bool { !self.mouse_wheel_handlers.borrow().is_empty() } + fn keyboard_handlers_enabled(&self) -> bool { self.disable_hotkey.borrow().is_some() || !self.hotkeys.borrow().is_empty() } @@ -322,6 +391,7 @@ impl EventTap { self.gesture_handlers_enabled(), self.keyboard_handlers_enabled(), self.mouse_move_handlers_enabled(), + self.scroll_wheel_needed(), ) } @@ -378,7 +448,8 @@ impl EventTap { .focus_follows_mouse_disable_hotkey .clone() .and_then(|spec| spec.to_hotkey()); - let (swipe, scroll) = Self::build_gesture_handlers(&config, wm_sender.is_some()); + let (swipe, scroll, mouse_wheel) = + Self::build_gesture_handlers(&config, wm_sender.is_some()); let mut state = State::default(); state.mouse_hides_on_focus = config.settings.mouse_hides_on_focus; state.focus_follows_mouse_config_enabled = config.settings.focus_follows_mouse; @@ -394,6 +465,7 @@ impl EventTap { state.event_processing_enabled && ((state.stack_line_enabled && stack_line_tx.is_some()) || Self::focus_follows_mouse_handler_enabled(&state)), + !mouse_wheel.is_empty(), ); EventTap { config: RefCell::new(config), @@ -405,6 +477,7 @@ impl EventTap { disable_hotkey: RefCell::new(disable_hotkey), swipe: RefCell::new(swipe), scroll: RefCell::new(scroll), + mouse_wheel_handlers: RefCell::new(mouse_wheel), hotkeys: RefCell::new(HashMap::default()), wm_sender, stack_line_tx, @@ -702,6 +775,51 @@ impl EventTap { } } } + CGEventType::ScrollWheel => { + // Read flags from the event directly to avoid stale state.current_flags. + let event_flags = CGEvent::flags(Some(event)); + let event_mods = modifiers_from_flags(event_flags); + + // Pick the modifier with the most bits. + let handlers = self.mouse_wheel_handlers.borrow(); + let mut best: Option = None; + let mut best_bits: u32 = 0; + + for (i, h) in handlers.iter().enumerate() { + let (required_mods, required_key) = match &h.spec { + HotkeySpec::Hotkey(hk) => (hk.modifiers, Some(hk.key_code)), + HotkeySpec::ModifiersOnly { modifiers } => (*modifiers, None), + }; + if !event_mods.contains(required_mods) { + continue; + } + if let Some(key) = required_key { + if !state.pressed_keys.contains(&key) { + continue; + } + } + // ScrollStrip only activates in scrolling layout. + if h.action == MouseWheelAction::ScrollStrip { + let cursor = CGEvent::location(Some(event)); + let mode = + state.layout_mode_at_point(cursor).unwrap_or(state.default_layout_mode); + if !matches!(mode, LayoutMode::Scrolling) { + continue; + } + } + let bits = required_mods.bit_count(); + if best.is_none() || bits > best_bits { + best = Some(i); + best_bits = bits; + } + } + + if let Some(idx) = best { + drop(state); + self.handle_mouse_wheel_event(idx, event); + return false; + } + } _ => (), } @@ -967,6 +1085,71 @@ impl EventTap { } } + fn handle_mouse_wheel_event(&self, handler_idx: usize, event: &CGEvent) { + let Some(wm_sender) = self.wm_sender.as_ref() else { + return; + }; + + let handlers = self.mouse_wheel_handlers.borrow(); + let handler = &handlers[handler_idx]; + + // Prefer horizontal axis, fall back to vertical. + let axis2 = CGEvent::integer_value_field( + Some(event), + CGEventField::ScrollWheelEventPointDeltaAxis2, + ); + let raw = if axis2 != 0 { + axis2 + } else { + CGEvent::integer_value_field(Some(event), CGEventField::ScrollWheelEventPointDeltaAxis1) + }; + if raw == 0 { + return; + } + + // Accumulate delta to batch high-frequency continuous input (trackpad, magic mouse). + // Discrete scroll produces large ticks that exceed the threshold immediately. + let mut accum = handler.accum.borrow_mut(); + *accum += raw as f64 * handler.sensitivity; + if accum.abs() < handler.threshold { + return; + } + + let cmd = match handler.action { + MouseWheelAction::ScrollStrip => { + let mut delta = *accum; + *accum = 0.0; + if handler.invert { + delta = -delta; + } + LC::ScrollStrip { delta: delta * 0.05 } + } + MouseWheelAction::Resize => { + let mut amount = *accum; + *accum = 0.0; + if handler.invert { + amount = -amount; + } + LC::ResizeWindowBy { amount: amount * 0.01 } + } + MouseWheelAction::MoveColumn => { + let direction = if (*accum > 0.0) ^ handler.invert { + crate::layout_engine::Direction::Left + } else { + crate::layout_engine::Direction::Right + }; + *accum = 0.0; + LC::MoveNode(direction) + } + }; + drop(accum); + drop(handlers); + + wm_sender.send(WmEvent::Command(WmCommand::ReactorCommand( + reactor::Command::Layout(cmd), + ))); + } + fn handle_keyboard_event( &self, event_type: CGEventType, @@ -1261,6 +1444,7 @@ fn build_event_mask( gestures_enabled: bool, keyboard_enabled: bool, mouse_move_enabled: bool, + scroll_wheel_enabled: bool, ) -> CGEventMask { let mut m: u64 = 0; let add = |m: &mut u64, ty: CGEventType| *m |= 1u64 << (ty.0 as u64); @@ -1291,6 +1475,9 @@ fn build_event_mask( // NSEventType::Gesture is an NSEventType — it maps via .0 *&mut m |= 1u64 << (NSEventType::Gesture.0 as u64); } + if scroll_wheel_enabled { + add(&mut m, CGEventType::ScrollWheel); + } m } diff --git a/src/actor/reactor/events/command.rs b/src/actor/reactor/events/command.rs index 57e3c953..b5ae0340 100644 --- a/src/actor/reactor/events/command.rs +++ b/src/actor/reactor/events/command.rs @@ -126,6 +126,9 @@ impl CommandEventHandler { pub fn handle_config_updated(reactor: &mut Reactor, new_cfg: Config) { let old_keys = reactor.config.keys.clone(); + let old_mouse_scroll = reactor.config.settings.layout.scrolling.mouse_scroll.clone(); + let old_mouse_resize = reactor.config.settings.layout.mouse_resize.clone(); + let old_mouse_move_column = reactor.config.settings.layout.mouse_move_column.clone(); reactor.config = new_cfg; reactor @@ -154,7 +157,11 @@ impl CommandEventHandler { let _ = reactor.update_layout_or_warn(false, true); - if old_keys != reactor.config.keys { + if old_keys != reactor.config.keys + || old_mouse_scroll != reactor.config.settings.layout.scrolling.mouse_scroll + || old_mouse_resize != reactor.config.settings.layout.mouse_resize + || old_mouse_move_column != reactor.config.settings.layout.mouse_move_column + { if let Some(wm) = &reactor.communication_manager.wm_sender { wm.send(WmEvent::ConfigUpdated(reactor.config.clone())); } diff --git a/src/common/config.rs b/src/common/config.rs index 4ea7e25a..bde06b3f 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -566,6 +566,12 @@ pub struct LayoutSettings { /// Scrolling layout configuration (niri-style columns) #[serde(default)] pub scrolling: ScrollingLayoutSettings, + /// Mouse scroll wheel to resize focused column + #[serde(default)] + pub mouse_resize: MouseResizeSettings, + /// Mouse scroll wheel to move focused column left/right + #[serde(default)] + pub mouse_move_column: MouseMoveColumnSettings, } /// Layout mode enum @@ -623,6 +629,9 @@ pub struct ScrollingLayoutSettings { /// Trackpad gestures for scrolling layout #[serde(default)] pub gestures: ScrollingGestureSettings, + /// Mouse scroll wheel support for scrolling layout + #[serde(default)] + pub mouse_scroll: MouseScrollSettings, } impl Default for ScrollingLayoutSettings { @@ -635,6 +644,7 @@ impl Default for ScrollingLayoutSettings { alignment: ScrollingAlignment::default(), focus_navigation_style: ScrollingFocusNavigationStyle::default(), gestures: ScrollingGestureSettings::default(), + mouse_scroll: MouseScrollSettings::default(), } } } @@ -731,6 +741,90 @@ impl Default for ScrollingGestureSettings { } } +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(deny_unknown_fields)] +pub struct MouseScrollSettings { + /// Enable mouse scroll wheel to scroll the column strip + #[serde(default = "no")] + pub enabled: bool, + /// Modifier key(s) that must be held (e.g. "Alt", "Ctrl+Alt", "Ctrl+Alt+Z") + #[serde(default)] + pub modifier: Option, + /// Invert scroll direction + #[serde(default)] + pub invert: bool, + /// Scroll sensitivity + #[serde(default = "default_mouse_scroll_sensitivity")] + pub sensitivity: f64, +} + +fn default_mouse_scroll_sensitivity() -> f64 { 1.0 } + +impl Default for MouseScrollSettings { + fn default() -> Self { + Self { + enabled: false, + modifier: None, + invert: false, + sensitivity: default_mouse_scroll_sensitivity(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(deny_unknown_fields)] +pub struct MouseResizeSettings { + /// Enable mouse scroll wheel to resize the focused column + #[serde(default = "no")] + pub enabled: bool, + /// Modifier key(s) that must be held (e.g. "Ctrl+Alt", "Ctrl+Alt+Z") + #[serde(default)] + pub modifier: Option, + /// Invert resize direction + #[serde(default)] + pub invert: bool, + /// Resize sensitivity + #[serde(default = "default_mouse_resize_sensitivity")] + pub sensitivity: f64, +} + +fn default_mouse_resize_sensitivity() -> f64 { 1.0 } + +impl Default for MouseResizeSettings { + fn default() -> Self { + Self { + enabled: false, + modifier: None, + invert: false, + sensitivity: default_mouse_resize_sensitivity(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(deny_unknown_fields)] +pub struct MouseMoveColumnSettings { + /// Enable mouse scroll wheel to move the focused column left/right + #[serde(default = "no")] + pub enabled: bool, + /// Modifier key(s) that must be held (e.g. "Ctrl+Shift", "Ctrl+Shift+Z") + #[serde(default)] + pub modifier: Option, + /// Invert move direction + #[serde(default)] + pub invert: bool, +} + +impl Default for MouseMoveColumnSettings { + fn default() -> Self { + Self { + enabled: false, + modifier: None, + invert: false, + } + } +} + #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)] #[serde(rename_all = "snake_case")] pub enum StackDefaultOrientation { diff --git a/src/sys/hotkey.rs b/src/sys/hotkey.rs index a4b37691..bbfc874d 100644 --- a/src/sys/hotkey.rs +++ b/src/sys/hotkey.rs @@ -39,6 +39,8 @@ impl Modifiers { pub fn remove(&mut self, other: Modifiers) { self.0 &= !other.0; } + pub fn bit_count(&self) -> u32 { self.0.count_ones() } + pub fn has_generic_modifiers(&self) -> bool { MOD_FAMILIES.iter().any(|m| self.contains(m.generic)) } @@ -516,7 +518,7 @@ impl fmt::Display for Hotkey { } } -fn parse_mods_and_optional_key(s: &str) -> Result<(Modifiers, Option), anyhow::Error> { +pub fn parse_mods_and_optional_key(s: &str) -> Result<(Modifiers, Option), anyhow::Error> { let parts: Vec<&str> = s.split('+').map(|p| p.trim()).filter(|p| !p.is_empty()).collect(); let mut mods = Modifiers::empty();