diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index aaf81bc..2946b8c 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -75,7 +75,7 @@ pub struct Input { #[knuffel(child)] pub warp_mouse_to_focus: bool, #[knuffel(child)] - pub focus_follows_mouse: bool, + pub focus_follows_mouse: Option, #[knuffel(child)] pub workspace_auto_back_and_forth: bool, } @@ -289,6 +289,15 @@ pub struct Touch { pub map_to_output: Option, } +#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)] +pub struct FocusFollowsMouse { + #[knuffel(property, str)] + pub max_scroll_amount: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Percent(pub f64); + #[derive(Debug, Default, Clone, PartialEq)] pub struct Outputs(pub Vec); @@ -1794,6 +1803,16 @@ where } impl Animation { + pub fn new_off() -> Self { + Self { + off: true, + kind: AnimationKind::Easing(EasingParams { + duration_ms: 0, + curve: AnimationCurve::Linear, + }), + } + } + fn decode_node( node: &knuffel::ast::SpannedNode, ctx: &mut knuffel::decode::Context, @@ -2418,6 +2437,23 @@ impl FromStr for TapButtonMap { } } +impl FromStr for Percent { + type Err = miette::Error; + + fn from_str(s: &str) -> Result { + let Some((value, empty)) = s.split_once('%') else { + return Err(miette!("value must end with '%'")); + }; + + if !empty.is_empty() { + return Err(miette!("trailing characters after '%' are not allowed")); + } + + let value: f64 = value.parse().map_err(|_| miette!("error parsing value"))?; + Ok(Percent(value / 100.)) + } +} + pub fn set_miette_hook() -> Result<(), miette::InstallError> { miette::set_hook(Box::new(|_| Box::new(NarratableReportHandler::new()))) } @@ -2664,7 +2700,9 @@ mod tests { }, disable_power_key_handling: true, warp_mouse_to_focus: true, - focus_follows_mouse: true, + focus_follows_mouse: Some(FocusFollowsMouse { + max_scroll_amount: None, + }), workspace_auto_back_and_forth: true, }, outputs: Outputs(vec![Output { diff --git a/resources/default-config.kdl b/resources/default-config.kdl index 469517d..1d8ebb6 100644 --- a/resources/default-config.kdl +++ b/resources/default-config.kdl @@ -44,7 +44,8 @@ input { // warp-mouse-to-focus // Focus windows and outputs automatically when moving the mouse into them. - // focus-follows-mouse + // Setting max-scroll-amount="0%" makes it work only on windows already fully on screen. + // focus-follows-mouse max-scroll-amount="0%" } // You can configure outputs by their name, which you can find diff --git a/src/layout/mod.rs b/src/layout/mod.rs index ab7d7ec..fadde1f 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -947,6 +947,22 @@ impl Layout { } } + pub fn scroll_amount_to_activate(&self, window: &W::Id) -> f64 { + let MonitorSet::Normal { monitors, .. } = &self.monitor_set else { + return 0.; + }; + + for mon in monitors { + for ws in &mon.workspaces { + if ws.has_window(window) { + return ws.scroll_amount_to_activate(window); + } + } + } + + 0. + } + pub fn activate_window(&mut self, window: &W::Id) { let MonitorSet::Normal { monitors, diff --git a/src/layout/workspace.rs b/src/layout/workspace.rs index c5ddfd2..625f4d5 100644 --- a/src/layout/workspace.rs +++ b/src/layout/workspace.rs @@ -1391,6 +1391,37 @@ impl Workspace { } } + pub fn scroll_amount_to_activate(&self, window: &W::Id) -> f64 { + let column_idx = self + .columns + .iter() + .position(|col| col.contains(window)) + .unwrap(); + + if self.active_column_idx == column_idx { + return 0.; + } + + let current_x = self.view_pos(); + let new_view_offset = self.compute_new_view_offset_for_column( + current_x, + column_idx, + Some(self.active_column_idx), + ); + + // Consider the end of an ongoing animation because that's what compute to fit does too. + let final_x = if let Some(ViewOffsetAdjustment::Animation(anim)) = &self.view_offset_adj { + current_x - self.view_offset + anim.to() + } else { + current_x + }; + + let new_col_x = self.column_x(column_idx); + let from_view_offset = final_x - new_col_x; + + (from_view_offset - new_view_offset).abs() / self.working_area.size.w + } + pub fn activate_window(&mut self, window: &W::Id) { let column_idx = self .columns diff --git a/src/niri.rs b/src/niri.rs index 08331be..2ff2f01 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -4140,9 +4140,9 @@ impl Niri { } pub fn handle_focus_follows_mouse(&mut self, new_focus: &PointerFocus) { - if !self.config.borrow().input.focus_follows_mouse { + let Some(ffm) = self.config.borrow().input.focus_follows_mouse else { return; - } + }; let pointer = &self.seat.get_pointer().unwrap(); if pointer.is_grabbed() { @@ -4160,6 +4160,12 @@ impl Niri { if let Some(window) = &new_focus.window { if current_focus.window.as_ref() != Some(window) { + if let Some(threshold) = ffm.max_scroll_amount { + if self.layout.scroll_amount_to_activate(window) > threshold.0 { + return; + } + } + self.layout.activate_window(window); } } diff --git a/wiki/Configuration:-Input.md b/wiki/Configuration:-Input.md index 05c6ce9..fd4d0d6 100644 --- a/wiki/Configuration:-Input.md +++ b/wiki/Configuration:-Input.md @@ -68,7 +68,7 @@ input { // disable-power-key-handling // warp-mouse-to-focus - // focus-follows-mouse + // focus-follows-mouse max-scroll-amount="0%" // workspace-auto-back-and-forth } ``` @@ -207,6 +207,24 @@ input { } ``` +Since: 0.1.8 You can optionally set `max-scroll-amount`. +Then, focus-follows-mouse won't focus a window if it will result in the view scrolling more than the set amount. +The value is a percentage of the working area width. + +``` +input { + // Allow focus-follows-mouse when it results in scrolling at most 10% of the screen. + focus-follows-mouse max-scroll-amount="10%" +} +``` + +``` +input { + // Allow focus-follows-mouse only when it will not scroll the view. + focus-follows-mouse max-scroll-amount="0%" +} +``` + #### `workspace-auto-back-and-forth` Normally, switching to the same workspace by index twice will do nothing (since you're already on that workspace).