Created
February 12, 2026 21:15
-
-
Save nonetrix/f8858082045ed841181c4c6926e92235 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/docs/wiki/Configuration:-Input.md b/docs/wiki/Configuration:-Input.md | |
| index 60a3808d..3aed18c5 100644 | |
| --- a/docs/wiki/Configuration:-Input.md | |
| +++ b/docs/wiki/Configuration:-Input.md | |
| @@ -351,6 +351,16 @@ input { | |
| } | |
| ``` | |
| +You can also set a delay in milliseconds before the focus change occurs. | |
| +This can help prevent accidental focus changes when moving the mouse across multiple windows or monitors. | |
| + | |
| +```kdl | |
| +input { | |
| + // Wait for 200ms before changing focus. | |
| + focus-follows-mouse delay-ms=200 | |
| +} | |
| +``` | |
| + | |
| #### `workspace-auto-back-and-forth` | |
| Normally, switching to the same workspace by index twice will do nothing (since you're already on that workspace). | |
| diff --git a/niri-config/src/input.rs b/niri-config/src/input.rs | |
| index 12af80ae..1d56c8f8 100644 | |
| --- a/niri-config/src/input.rs | |
| +++ b/niri-config/src/input.rs | |
| @@ -381,6 +381,8 @@ pub struct Touch { | |
| pub struct FocusFollowsMouse { | |
| #[knuffel(property, str)] | |
| pub max_scroll_amount: Option<Percent>, | |
| + #[knuffel(property, default)] | |
| + pub delay_ms: u32, | |
| } | |
| #[derive(knuffel::Decode, Debug, PartialEq, Eq, Clone, Copy)] | |
| diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs | |
| index b61fe1c1..7fd36211 100644 | |
| --- a/niri-config/src/lib.rs | |
| +++ b/niri-config/src/lib.rs | |
| @@ -1111,6 +1111,7 @@ mod tests { | |
| focus_follows_mouse: Some( | |
| FocusFollowsMouse { | |
| max_scroll_amount: None, | |
| + delay_ms: 0, | |
| }, | |
| ), | |
| workspace_auto_back_and_forth: true, | |
| diff --git a/resources/default-config.kdl b/resources/default-config.kdl | |
| index 3fa09d56..40da13e0 100644 | |
| --- a/resources/default-config.kdl | |
| +++ b/resources/default-config.kdl | |
| @@ -67,6 +67,8 @@ input { | |
| // Focus windows and outputs automatically when moving the mouse into them. | |
| // Setting max-scroll-amount="0%" makes it work only on windows already fully on screen. | |
| // focus-follows-mouse max-scroll-amount="0%" | |
| + // You can also set a delay in milliseconds. | |
| + // focus-follows-mouse delay-ms=200 | |
| } | |
| // You can configure outputs by their name, which you can find | |
| diff --git a/src/input/mod.rs b/src/input/mod.rs | |
| index ab31df94..d61c4eb5 100644 | |
| --- a/src/input/mod.rs | |
| +++ b/src/input/mod.rs | |
| @@ -2759,6 +2759,8 @@ impl State { | |
| } | |
| fn on_pointer_button<I: InputBackend>(&mut self, event: I::PointerButtonEvent) { | |
| + self.niri.cancel_focus_follows_mouse_timer(); | |
| + | |
| let pointer = self.niri.seat.get_pointer().unwrap(); | |
| let serial = SERIAL_COUNTER.next_serial(); | |
| diff --git a/src/niri.rs b/src/niri.rs | |
| index 414f702d..463497ff 100644 | |
| --- a/src/niri.rs | |
| +++ b/src/niri.rs | |
| @@ -322,6 +322,10 @@ pub struct Niri { | |
| pub suppressed_buttons: HashSet<u32>, | |
| pub bind_cooldown_timers: HashMap<Key, RegistrationToken>, | |
| pub bind_repeat_timer: Option<RegistrationToken>, | |
| + | |
| + pub focus_follows_mouse_timer: Option<RegistrationToken>, | |
| + pub focus_follows_mouse_pending: Option<PointContents>, | |
| + | |
| pub keyboard_focus: KeyboardFocus, | |
| pub layer_shell_on_demand_focus: Option<LayerSurface>, | |
| pub idle_inhibiting_surfaces: HashSet<WlSurface>, | |
| @@ -2495,6 +2499,8 @@ impl Niri { | |
| suppressed_buttons: HashSet::new(), | |
| bind_cooldown_timers: HashMap::new(), | |
| bind_repeat_timer: Option::default(), | |
| + focus_follows_mouse_timer: None, | |
| + focus_follows_mouse_pending: None, | |
| presentation_state, | |
| security_context_state, | |
| gamma_control_manager_state, | |
| @@ -5818,11 +5824,24 @@ impl Niri { | |
| } | |
| } | |
| + pub fn cancel_focus_follows_mouse_timer(&mut self) { | |
| + if let Some(token) = self.focus_follows_mouse_timer.take() { | |
| + self.event_loop.remove(token); | |
| + } | |
| + self.focus_follows_mouse_pending = None; | |
| + } | |
| + | |
| pub fn handle_focus_follows_mouse(&mut self, new_focus: &PointContents) { | |
| let Some(ffm) = self.config.borrow().input.focus_follows_mouse else { | |
| return; | |
| }; | |
| + if ffm.delay_ms == 0 { | |
| + self.cancel_focus_follows_mouse_timer(); | |
| + self.handle_focus_follows_mouse_actual(new_focus); | |
| + return; | |
| + } | |
| + | |
| let pointer = &self.seat.get_pointer().unwrap(); | |
| if pointer.is_grabbed() { | |
| return; | |
| @@ -5832,17 +5851,98 @@ impl Niri { | |
| return; | |
| } | |
| - // Recompute the current pointer focus because we don't update it during animations. | |
| - let current_focus = self.contents_under(pointer.current_location()); | |
| + // Check if the focus would actually change from the CURRENT focus. | |
| + let mut would_change = false; | |
| + if let Some(output) = &new_focus.output { | |
| + if self.layout.active_output() != Some(output) { | |
| + would_change = true; | |
| + } | |
| + } | |
| + | |
| + if let Some((window, hit)) = &new_focus.window { | |
| + let active_window = self.layout.active_workspace().and_then(|ws| ws.active_window()); | |
| + if !self.layout.is_overview_open() && active_window.map(|m| &m.window) != Some(window) { | |
| + if !matches!(hit, HitType::Activate { is_tab_indicator: true }) | |
| + && self.layout.should_trigger_focus_follows_mouse_on(window) | |
| + { | |
| + let mut scroll_too_much = false; | |
| + if let Some(threshold) = ffm.max_scroll_amount { | |
| + if self.layout.scroll_amount_to_activate(window) > threshold.0 { | |
| + scroll_too_much = true; | |
| + } | |
| + } | |
| + | |
| + if !scroll_too_much { | |
| + would_change = true; | |
| + } | |
| + } | |
| + } | |
| + } | |
| + | |
| + if let Some(layer) = &new_focus.layer { | |
| + if self.layer_shell_on_demand_focus.as_ref() != Some(layer) { | |
| + would_change = true; | |
| + } | |
| + } | |
| + | |
| + if !would_change { | |
| + self.cancel_focus_follows_mouse_timer(); | |
| + return; | |
| + } | |
| + | |
| + // If we are already pending this same focus, do nothing. | |
| + if let Some(pending) = &self.focus_follows_mouse_pending { | |
| + if pending.output == new_focus.output | |
| + && pending.window == new_focus.window | |
| + && pending.layer == new_focus.layer | |
| + { | |
| + return; | |
| + } | |
| + } | |
| + | |
| + // Otherwise, (re)start the timer. | |
| + if let Some(token) = self.focus_follows_mouse_timer.take() { | |
| + self.event_loop.remove(token); | |
| + } | |
| + | |
| + self.focus_follows_mouse_pending = Some(new_focus.clone()); | |
| + let timer = Timer::from_duration(Duration::from_millis(ffm.delay_ms as u64)); | |
| + let token = self | |
| + .event_loop | |
| + .insert_source(timer, move |_, _, state| { | |
| + state.niri.focus_follows_mouse_timer = None; | |
| + if let Some(pending) = state.niri.focus_follows_mouse_pending.take() { | |
| + state.niri.handle_focus_follows_mouse_actual(&pending); | |
| + } | |
| + TimeoutAction::Drop | |
| + }) | |
| + .unwrap(); | |
| + self.focus_follows_mouse_timer = Some(token); | |
| + } | |
| + | |
| + pub fn handle_focus_follows_mouse_actual(&mut self, new_focus: &PointContents) { | |
| + let Some(ffm) = self.config.borrow().input.focus_follows_mouse else { | |
| + return; | |
| + }; | |
| + | |
| + let pointer = &self.seat.get_pointer().unwrap(); | |
| + if pointer.is_grabbed() { | |
| + return; | |
| + } | |
| + | |
| + if self.window_mru_ui.is_open() { | |
| + return; | |
| + } | |
| if let Some(output) = &new_focus.output { | |
| - if current_focus.output.as_ref() != Some(output) { | |
| + if self.layout.active_output() != Some(output) { | |
| self.layout.focus_output(output); | |
| } | |
| } | |
| if let Some(window) = &new_focus.window { | |
| - if !self.layout.is_overview_open() && current_focus.window.as_ref() != Some(window) { | |
| + let active_window = self.layout.active_workspace().and_then(|ws| ws.active_window()); | |
| + if !self.layout.is_overview_open() && active_window.map(|m| &m.window) != Some(&window.0) { | |
| let (window, hit) = window; | |
| // Don't trigger focus-follows-mouse over the tab indicator. | |
| @@ -5871,7 +5971,7 @@ impl Niri { | |
| } | |
| if let Some(layer) = &new_focus.layer { | |
| - if current_focus.layer.as_ref() != Some(layer) { | |
| + if self.layer_shell_on_demand_focus.as_ref() != Some(layer) { | |
| self.layer_shell_on_demand_focus = Some(layer.clone()); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment