Add per-axis scroll speed config for input devices (#2109)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fixes

---------

Co-authored-by: Bernardo Kuri <github@bkuri.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
This commit is contained in:
Bernardo Kuri
2025-08-18 23:51:32 -06:00
committed by GitHub
parent 43a2648e57
commit 5ea9092a49
3 changed files with 300 additions and 21 deletions
+4
View File
@@ -37,6 +37,7 @@ input {
// accel-speed 0.2 // accel-speed 0.2
// accel-profile "flat" // accel-profile "flat"
// scroll-factor 1.0 // scroll-factor 1.0
// scroll-factor vertical=1.0 horizontal=-2.0
// scroll-method "two-finger" // scroll-method "two-finger"
// scroll-button 273 // scroll-button 273
// scroll-button-lock // scroll-button-lock
@@ -53,6 +54,7 @@ input {
// accel-speed 0.2 // accel-speed 0.2
// accel-profile "flat" // accel-profile "flat"
// scroll-factor 1.0 // scroll-factor 1.0
// scroll-factor vertical=1.0 horizontal=-2.0
// scroll-method "no-scroll" // scroll-method "no-scroll"
// scroll-button 273 // scroll-button 273
// scroll-button-lock // scroll-button-lock
@@ -252,6 +254,8 @@ Settings specific to `touchpad` and `mouse`:
- `scroll-factor`: <sup>Since: 0.1.10</sup> scales the scrolling speed by this value. - `scroll-factor`: <sup>Since: 0.1.10</sup> scales the scrolling speed by this value.
<sup>Since: next release</sup> 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: Settings specific to `tablet`s:
- `calibration-matrix`: <sup>Since: 25.02</sup> 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. - `calibration-matrix`: <sup>Since: 25.02</sup> 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.
+266 -4
View File
@@ -191,6 +191,25 @@ pub enum TrackLayout {
Window, Window,
} }
#[derive(knuffel::Decode, Debug, Default, Clone, Copy, PartialEq)]
pub struct ScrollFactor {
#[knuffel(argument)]
pub base: Option<FloatOrInt<0, 100>>,
#[knuffel(property)]
pub horizontal: Option<FloatOrInt<-100, 100>>,
#[knuffel(property)]
pub vertical: Option<FloatOrInt<-100, 100>>,
}
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)] #[derive(knuffel::Decode, Debug, Default, PartialEq)]
pub struct Touchpad { pub struct Touchpad {
#[knuffel(child)] #[knuffel(child)]
@@ -227,8 +246,8 @@ pub struct Touchpad {
pub disabled_on_external_mouse: bool, pub disabled_on_external_mouse: bool,
#[knuffel(child)] #[knuffel(child)]
pub middle_emulation: bool, pub middle_emulation: bool,
#[knuffel(child, unwrap(argument))] #[knuffel(child)]
pub scroll_factor: Option<FloatOrInt<0, 100>>, pub scroll_factor: Option<ScrollFactor>,
} }
#[derive(knuffel::Decode, Debug, Default, PartialEq)] #[derive(knuffel::Decode, Debug, Default, PartialEq)]
@@ -251,8 +270,8 @@ pub struct Mouse {
pub left_handed: bool, pub left_handed: bool,
#[knuffel(child)] #[knuffel(child)]
pub middle_emulation: bool, pub middle_emulation: bool,
#[knuffel(child, unwrap(argument))] #[knuffel(child)]
pub scroll_factor: Option<FloatOrInt<0, 100>>, pub scroll_factor: Option<ScrollFactor>,
} }
#[derive(knuffel::Decode, Debug, Default, PartialEq)] #[derive(knuffel::Decode, Debug, Default, PartialEq)]
@@ -4054,6 +4073,237 @@ mod tests {
.unwrap() .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] #[test]
fn parse() { fn parse() {
let parsed = do_parse( let parsed = do_parse(
@@ -4370,10 +4620,16 @@ mod tests {
disabled_on_external_mouse: true, disabled_on_external_mouse: true,
middle_emulation: false, middle_emulation: false,
scroll_factor: Some( scroll_factor: Some(
ScrollFactor {
base: Some(
FloatOrInt( FloatOrInt(
0.9, 0.9,
), ),
), ),
horizontal: None,
vertical: None,
},
),
}, },
mouse: Mouse { mouse: Mouse {
off: false, off: false,
@@ -4394,10 +4650,16 @@ mod tests {
left_handed: false, left_handed: false,
middle_emulation: true, middle_emulation: true,
scroll_factor: Some( scroll_factor: Some(
ScrollFactor {
base: Some(
FloatOrInt( FloatOrInt(
0.2, 0.2,
), ),
), ),
horizontal: None,
vertical: None,
},
),
}, },
trackpoint: Trackpoint { trackpoint: Trackpoint {
off: true, off: true,
+23 -10
View File
@@ -3086,31 +3086,44 @@ impl State {
self.update_pointer_contents(); self.update_pointer_contents();
let scroll_factor = match source { let device_scroll_factor = {
AxisSource::Wheel => self.niri.config.borrow().input.mouse.scroll_factor, let config = self.niri.config.borrow();
AxisSource::Finger => self.niri.config.borrow().input.touchpad.scroll_factor, match source {
AxisSource::Wheel => config.input.mouse.scroll_factor,
AxisSource::Finger => config.input.touchpad.scroll_factor,
_ => None, _ => None,
}
}; };
let scroll_factor = scroll_factor.map(|x| x.0).unwrap_or(1.);
// Get window-specific scroll factor
let window_scroll_factor = pointer let window_scroll_factor = pointer
.current_focus() .current_focus()
.map(|focused| self.niri.find_root_shell_surface(&focused)) .map(|focused| self.niri.find_root_shell_surface(&focused))
.and_then(|root| self.niri.layout.find_window_and_output(&root).unzip().0) .and_then(|root| self.niri.layout.find_window_and_output(&root).unzip().0)
.and_then(|window| window.rules().scroll_factor); .and_then(|window| window.rules().scroll_factor)
let scroll_factor = scroll_factor * window_scroll_factor.unwrap_or(1.); .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(|| { let horizontal_amount = horizontal_amount.unwrap_or_else(|| {
// Winit backend, discrete scrolling. // Winit backend, discrete scrolling.
horizontal_amount_v120.unwrap_or(0.0) / 120. * 15. horizontal_amount_v120.unwrap_or(0.0) / 120. * 15.
}) * scroll_factor; }) * horizontal_factor;
let vertical_amount = vertical_amount.unwrap_or_else(|| { let vertical_amount = vertical_amount.unwrap_or_else(|| {
// Winit backend, discrete scrolling. // Winit backend, discrete scrolling.
vertical_amount_v120.unwrap_or(0.0) / 120. * 15. 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 horizontal_amount_v120 = horizontal_amount_v120.map(|x| x * horizontal_factor);
let vertical_amount_v120 = vertical_amount_v120.map(|x| x * scroll_factor); let vertical_amount_v120 = vertical_amount_v120.map(|x| x * vertical_factor);
let mut frame = AxisFrame::new(event.time_msec()).source(source); let mut frame = AxisFrame::new(event.time_msec()).source(source);
if horizontal_amount != 0.0 { if horizontal_amount != 0.0 {