From 5ea9092a4954f8c5cd795f0ebec910666b367d5f Mon Sep 17 00:00:00 2001 From: Bernardo Kuri Date: Mon, 18 Aug 2025 23:51:32 -0600 Subject: Add per-axis scroll speed config for input devices (#2109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add per-axis scroll speed config for input devices. Accepts negative values to inverse scroll direction. Properly complements/overrides global `scroll-direction` setting. Includes docs and tests. * Update per-axis scroll factor implementation after testing - Refined configuration structure in niri-config - Updated input handling to use per-axis scroll factors - Added comprehensive test snapshots - Updated documentation with per-axis examples 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Simplify per-axis scroll factor implementation per review feedback - Make documentation concise and clear - Remove unnecessary comments and test helper functions - Use inline snapshots for tests - Rename get_factors() to h_v_factors() for clarity - Remove unnecessary .clone() calls (ScrollFactor is Copy) - Reduce test count to essential cases only - Fix comment about window factor override behavior 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Remove unnecessary ScrollFactor::new() helper function The maintainer prefers minimal code, so removing this helper and constructing ScrollFactor directly in tests. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Fix scroll factor behavior - all settings now multiply with window factor Per maintainer feedback, both combined and per-axis scroll settings should multiply with the window-specific scroll factor, not override it. This ensures consistent behavior regardless of configuration method. Also removed the now-unused has_per_axis_override() method. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Final cleanup: remove redundant comments and unused snapshot files - Removed unused snapshot files (now using inline snapshots) - Removed redundant inline comments in tests - Simplified test descriptions to be more concise 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Convert scroll factor parsing tests to use assert_debug_snapshot Updates parse_scroll_factor_combined, parse_scroll_factor_split, and parse_scroll_factor_partial tests to use assert_debug_snapshot instead of manual assert_eq comparisons, as requested in PR review. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Convert to inline snapshots as requested - Convert all scroll factor parsing tests to use inline snapshots instead of external files - Remove external snapshot files to keep test directory clean - All tests still pass with inline snapshot assertions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Fix missed assert_eq in parse_scroll_factor_mixed test Converts the remaining assert_eq calls to assert_debug_snapshot with inline snapshots in the mixed syntax test function. Also fixes raw string delimiters from ### to #. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * Convert scroll_factor_h_v_factors test to use assert_debug_snapshot Makes all scroll factor tests consistent by using snapshots instead of assert_eq for better maintainability. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fixes --------- Co-authored-by: Bernardo Kuri Co-authored-by: Claude Co-authored-by: Ivan Molodetskikh --- docs/wiki/Configuration:-Input.md | 4 + niri-config/src/lib.rs | 282 ++++++++++++++++++++++++++++++++++++-- src/input/mod.rs | 35 +++-- 3 files changed, 300 insertions(+), 21 deletions(-) diff --git a/docs/wiki/Configuration:-Input.md b/docs/wiki/Configuration:-Input.md index c9ca0ba2..a03dd67b 100644 --- a/docs/wiki/Configuration:-Input.md +++ b/docs/wiki/Configuration:-Input.md @@ -37,6 +37,7 @@ input { // accel-speed 0.2 // accel-profile "flat" // scroll-factor 1.0 + // scroll-factor vertical=1.0 horizontal=-2.0 // scroll-method "two-finger" // scroll-button 273 // scroll-button-lock @@ -53,6 +54,7 @@ input { // accel-speed 0.2 // accel-profile "flat" // scroll-factor 1.0 + // scroll-factor vertical=1.0 horizontal=-2.0 // scroll-method "no-scroll" // scroll-button 273 // scroll-button-lock @@ -252,6 +254,8 @@ Settings specific to `touchpad` and `mouse`: - `scroll-factor`: Since: 0.1.10 scales the scrolling speed by this value. + Since: next release You can also override horizontal and vertical scroll factor separately like so: `scroll-factor horizontal=2.0 vertical=-1.0` + Settings specific to `tablet`s: - `calibration-matrix`: Since: 25.02 set to six floating point numbers to change the calibration matrix. See the [`LIBINPUT_CALIBRATION_MATRIX` documentation](https://wayland.freedesktop.org/libinput/doc/latest/device-configuration-via-udev.html) for examples. diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 16ec6dca..dbd6b5ea 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -191,6 +191,25 @@ pub enum TrackLayout { Window, } +#[derive(knuffel::Decode, Debug, Default, Clone, Copy, PartialEq)] +pub struct ScrollFactor { + #[knuffel(argument)] + pub base: Option>, + #[knuffel(property)] + pub horizontal: Option>, + #[knuffel(property)] + pub vertical: Option>, +} + +impl ScrollFactor { + pub fn h_v_factors(&self) -> (f64, f64) { + let base_value = self.base.map(|f| f.0).unwrap_or(1.0); + let h = self.horizontal.map(|f| f.0).unwrap_or(base_value); + let v = self.vertical.map(|f| f.0).unwrap_or(base_value); + (h, v) + } +} + #[derive(knuffel::Decode, Debug, Default, PartialEq)] pub struct Touchpad { #[knuffel(child)] @@ -227,8 +246,8 @@ pub struct Touchpad { pub disabled_on_external_mouse: bool, #[knuffel(child)] pub middle_emulation: bool, - #[knuffel(child, unwrap(argument))] - pub scroll_factor: Option>, + #[knuffel(child)] + pub scroll_factor: Option, } #[derive(knuffel::Decode, Debug, Default, PartialEq)] @@ -251,8 +270,8 @@ pub struct Mouse { pub left_handed: bool, #[knuffel(child)] pub middle_emulation: bool, - #[knuffel(child, unwrap(argument))] - pub scroll_factor: Option>, + #[knuffel(child)] + pub scroll_factor: Option, } #[derive(knuffel::Decode, Debug, Default, PartialEq)] @@ -4054,6 +4073,237 @@ mod tests { .unwrap() } + #[test] + fn parse_scroll_factor_combined() { + // Test combined scroll-factor syntax + let parsed = do_parse( + r#" + input { + mouse { + scroll-factor 2.0 + } + touchpad { + scroll-factor 1.5 + } + } + "#, + ); + + assert_debug_snapshot!(parsed.input.mouse.scroll_factor, @r#" + Some( + ScrollFactor { + base: Some( + FloatOrInt( + 2.0, + ), + ), + horizontal: None, + vertical: None, + }, + ) + "#); + assert_debug_snapshot!(parsed.input.touchpad.scroll_factor, @r#" + Some( + ScrollFactor { + base: Some( + FloatOrInt( + 1.5, + ), + ), + horizontal: None, + vertical: None, + }, + ) + "#); + } + + #[test] + fn parse_scroll_factor_split() { + // Test split horizontal/vertical syntax + let parsed = do_parse( + r#" + input { + mouse { + scroll-factor horizontal=2.0 vertical=-1.0 + } + touchpad { + scroll-factor horizontal=-1.5 vertical=0.5 + } + } + "#, + ); + + assert_debug_snapshot!(parsed.input.mouse.scroll_factor, @r#" + Some( + ScrollFactor { + base: None, + horizontal: Some( + FloatOrInt( + 2.0, + ), + ), + vertical: Some( + FloatOrInt( + -1.0, + ), + ), + }, + ) + "#); + assert_debug_snapshot!(parsed.input.touchpad.scroll_factor, @r#" + Some( + ScrollFactor { + base: None, + horizontal: Some( + FloatOrInt( + -1.5, + ), + ), + vertical: Some( + FloatOrInt( + 0.5, + ), + ), + }, + ) + "#); + } + + #[test] + fn parse_scroll_factor_partial() { + // Test partial specification (only one axis) + let parsed = do_parse( + r#" + input { + mouse { + scroll-factor horizontal=2.0 + } + touchpad { + scroll-factor vertical=-1.5 + } + } + "#, + ); + + assert_debug_snapshot!(parsed.input.mouse.scroll_factor, @r#" + Some( + ScrollFactor { + base: None, + horizontal: Some( + FloatOrInt( + 2.0, + ), + ), + vertical: None, + }, + ) + "#); + assert_debug_snapshot!(parsed.input.touchpad.scroll_factor, @r#" + Some( + ScrollFactor { + base: None, + horizontal: None, + vertical: Some( + FloatOrInt( + -1.5, + ), + ), + }, + ) + "#); + } + + #[test] + fn parse_scroll_factor_mixed() { + // Test mixed base + override syntax + let parsed = do_parse( + r#" + input { + mouse { + scroll-factor 2 vertical=-1 + } + touchpad { + scroll-factor 1.5 horizontal=3 + } + } + "#, + ); + + assert_debug_snapshot!(parsed.input.mouse.scroll_factor, @r#" + Some( + ScrollFactor { + base: Some( + FloatOrInt( + 2.0, + ), + ), + horizontal: None, + vertical: Some( + FloatOrInt( + -1.0, + ), + ), + }, + ) + "#); + assert_debug_snapshot!(parsed.input.touchpad.scroll_factor, @r#" + Some( + ScrollFactor { + base: Some( + FloatOrInt( + 1.5, + ), + ), + horizontal: Some( + FloatOrInt( + 3.0, + ), + ), + vertical: None, + }, + ) + "#); + } + + #[test] + fn scroll_factor_h_v_factors() { + let sf = ScrollFactor { + base: Some(FloatOrInt(2.0)), + horizontal: None, + vertical: None, + }; + assert_debug_snapshot!(sf.h_v_factors(), @r#" + ( + 2.0, + 2.0, + ) + "#); + + let sf = ScrollFactor { + base: None, + horizontal: Some(FloatOrInt(3.0)), + vertical: Some(FloatOrInt(-1.0)), + }; + assert_debug_snapshot!(sf.h_v_factors(), @r#" + ( + 3.0, + -1.0, + ) + "#); + + let sf = ScrollFactor { + base: Some(FloatOrInt(2.0)), + horizontal: Some(FloatOrInt(1.0)), + vertical: None, + }; + assert_debug_snapshot!(sf.h_v_factors(), @r" + ( + 1.0, + 2.0, + ) + "); + } + #[test] fn parse() { let parsed = do_parse( @@ -4370,9 +4620,15 @@ mod tests { disabled_on_external_mouse: true, middle_emulation: false, scroll_factor: Some( - FloatOrInt( - 0.9, - ), + ScrollFactor { + base: Some( + FloatOrInt( + 0.9, + ), + ), + horizontal: None, + vertical: None, + }, ), }, mouse: Mouse { @@ -4394,9 +4650,15 @@ mod tests { left_handed: false, middle_emulation: true, scroll_factor: Some( - FloatOrInt( - 0.2, - ), + ScrollFactor { + base: Some( + FloatOrInt( + 0.2, + ), + ), + horizontal: None, + vertical: None, + }, ), }, trackpoint: Trackpoint { diff --git a/src/input/mod.rs b/src/input/mod.rs index c2667dfc..cc07aed3 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -3086,31 +3086,44 @@ impl State { self.update_pointer_contents(); - let scroll_factor = match source { - AxisSource::Wheel => self.niri.config.borrow().input.mouse.scroll_factor, - AxisSource::Finger => self.niri.config.borrow().input.touchpad.scroll_factor, - _ => None, + let device_scroll_factor = { + let config = self.niri.config.borrow(); + match source { + AxisSource::Wheel => config.input.mouse.scroll_factor, + AxisSource::Finger => config.input.touchpad.scroll_factor, + _ => None, + } }; - let scroll_factor = scroll_factor.map(|x| x.0).unwrap_or(1.); + // Get window-specific scroll factor let window_scroll_factor = pointer .current_focus() .map(|focused| self.niri.find_root_shell_surface(&focused)) .and_then(|root| self.niri.layout.find_window_and_output(&root).unzip().0) - .and_then(|window| window.rules().scroll_factor); - let scroll_factor = scroll_factor * window_scroll_factor.unwrap_or(1.); + .and_then(|window| window.rules().scroll_factor) + .unwrap_or(1.); + + // Determine final scroll factors based on configuration + let (horizontal_factor, vertical_factor) = device_scroll_factor + .map(|x| x.h_v_factors()) + .unwrap_or((1.0, 1.0)); + let (horizontal_factor, vertical_factor) = ( + horizontal_factor * window_scroll_factor, + vertical_factor * window_scroll_factor, + ); let horizontal_amount = horizontal_amount.unwrap_or_else(|| { // Winit backend, discrete scrolling. horizontal_amount_v120.unwrap_or(0.0) / 120. * 15. - }) * scroll_factor; + }) * horizontal_factor; + let vertical_amount = vertical_amount.unwrap_or_else(|| { // Winit backend, discrete scrolling. vertical_amount_v120.unwrap_or(0.0) / 120. * 15. - }) * scroll_factor; + }) * vertical_factor; - let horizontal_amount_v120 = horizontal_amount_v120.map(|x| x * scroll_factor); - let vertical_amount_v120 = vertical_amount_v120.map(|x| x * scroll_factor); + let horizontal_amount_v120 = horizontal_amount_v120.map(|x| x * horizontal_factor); + let vertical_amount_v120 = vertical_amount_v120.map(|x| x * vertical_factor); let mut frame = AxisFrame::new(event.time_msec()).source(source); if horizontal_amount != 0.0 { -- cgit