Skip to content

Instantly share code, notes, and snippets.

@nonetrix
Created February 12, 2026 21:15
Show Gist options
  • Select an option

  • Save nonetrix/f8858082045ed841181c4c6926e92235 to your computer and use it in GitHub Desktop.

Select an option

Save nonetrix/f8858082045ed841181c4c6926e92235 to your computer and use it in GitHub Desktop.
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