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();