#4278

Activation events do not respect warp-mouse-to-focus across outputs

Openatimofeevopened 3mo ago

Config

input {
    warp-mouse-to-focus mode="center-xy-always"
}

What did you do?

  • Multi-monitor setup with warp-mouse-to-focus mode="center-xy-always" enabled
  • Click a link, notification, or taskbar item on output A that activates an already-open window on output B via xdg-activation or foreign-toplevel
  • Example: Firefox on output A activates a Firefox window/tab on output B

What happened?

Focus moves to the window on output B, but the cursor stays on output A.

warp-mouse-to-focus mode="center-xy-always" works for newly opened windows (compositor map path), but activation events (xdg-activation, foreign-toplevel) skip cursor relocation entirely.

What did you expect to happen?

Activation-triggered focus changes should behave like normal focus changes:

  • If warp-mouse-to-focus is set, cursor should warp to the focused window
  • Otherwise, if the active output changed, cursor should at minimum move to the focused output

Likely cause

State::focus_window() handles active-output change and cursor warp/move:

https://github.com/niri-wm/niri/blob/main/src/niri.rs#L976-L993

pub fn focus_window(&mut self, window: &Window) {
    let active_output = self.niri.layout.active_output().cloned();
    self.niri.layout.activate_window(window);
    let new_active = self.niri.layout.active_output().cloned();
    if new_active != active_output {
        if !self.maybe_warp_cursor_to_focus_centered() {
            self.move_cursor_to_output(&new_active.unwrap());
        }
    } else {
        self.maybe_warp_cursor_to_focus();
    }
    self.niri.queue_redraw_all();
}

But xdg_activation::request_activation() calls layout.activate_window() directly, skipping all the cursor-warp / output-move logic:

https://github.com/niri-wm/niri/blob/main/src/handlers/mod.rs

// request_activation()
self.niri.layout.activate_window(&window);
self.niri.layer_shell_on_demand_focus = None;
self.niri.queue_redraw_all();

Same for ForeignToplevelHandler::activate():

self.niri.layout.activate_window(&window);
self.niri.layer_shell_on_demand_focus = None;
self.niri.queue_redraw_all();

Suggested fix

Use self.focus_window(&window) in both activation handlers instead of calling self.niri.layout.activate_window(&window) directly — unless there are borrow-checker or protocol-specific reasons not to. focus_window already handles cursor warp, output change, and queue_redraw_all internally.

System

  • niri version: 26.04 (Nixpkgs)
  • outputs: DP-1 (2560×1440) + DP-2 (2560×1440)

Activity

1
maelisambert28d ago

Workaround until this is fixed

Sharing this for anyone else hitting this — note it also affects same-output cases (clicking a link in app A that activates the browser, clicking a taskbar/dock item…), and plain click-to-focus too, since both paths call layout.activate_window() directly instead of State::focus_window().

You can get the warp back with a tiny IPC daemon: listen to the event stream and re-issue focus-window for whichever window just gained focus. The FocusWindow action goes through State::focus_window(), which applies warp-mouse-to-focus with your configured mode — so the cursor warps exactly the way niri does it for keyboard-initiated focus changes.

Save as ~/.local/bin/warp-on-focus (make it executable; keep it anywhere in your PATH):

#!/usr/bin/env bash
# Cursor warp on every focus change — workaround for niri#4278
# (activation events and click-to-focus don't warp the cursor)

PATH="/usr/bin:/usr/local/bin:$HOME/.local/bin:$PATH"

# Single instance
exec 9>"${XDG_RUNTIME_DIR:-/tmp}/niri-warp-on-focus.lock"
flock -n 9 || exit 0

# 9>&-: don't leak the lock fd into children, otherwise an orphaned child
# could keep the lock alive after the daemon dies
while read -r line; do
    id=$(jq -r '.WindowFocusChanged.id // empty' <<<"$line" 2>/dev/null) || continue
    [ -n "$id" ] && niri msg action focus-window --id "$id" 9>&-
done < <(niri msg -j event-stream 9>&-)

config.kdl:

spawn-at-startup "warp-on-focus"

(If the script isn't in your PATH, use its absolute path in spawn-at-startup — no ~ expansion there since niri doesn't spawn through a shell.)

Why it's loop-safe: the WindowFocusChanged event is only emitted when the focused window actually changes (ipc_refresh_windows() compares against the event-stream state), and re-focusing the already-focused window emits nothing — so it settles after a single iteration. Verified: exactly one event per focus change, ~0% CPU at idle.

Notes:

  • Requires jq (rewrite in python/whatever if you prefer).

  • Focus moving to a layer-shell surface emits WindowFocusChanged { id: null } and is ignored, so launchers/overlays don't grab your cursor.

  • With warp-mouse-to-focus mode="center-xy-always" this also warps on plain click-to-focus (cursor jumps to the window center on every focus-changing click). If that's too aggressive, use warp-mouse-to-focus mode="center-xy". the warp then only happens when the cursor is outside the newly focused window, and plain clicks don't move it at all.

  • If you only want an on-demand warp, a keybind is enough:

    Mod+W { spawn "bash" "-c" "niri msg action focus-window --id $(niri msg -j focused-window | jq -r '.id // empty')"; }
  • Tested on niri 26.04. Delete the script + spawn-at-startup line once this issue is fixed.

+1 on the suggested fix, by the way — routing the activation handlers through focus_window() makes the daemon unnecessary.