From c75cf6c29c1b80fc46580f5ae9f1ccec822d0e7c Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 10 Jun 2026 18:22:08 -0700 Subject: [PATCH] hypr: fix AI desktop apps stealing focus via xdg-activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-desktop and codex-desktop fire xdg-activation requests while streaming responses. With misc:focus_on_activate=true this yanked focus (and warped the cursor) to those windows mid-use — most visibly causing Super+Ctrl+Space to always end up focused on claude-desktop regardless of which window was active. Fix in settings.lua: window rules for claude-desktop and codex-desktop with focus_on_activate=false (dynamic — applies to already-mapped windows on reload) and suppress_event="activatefocus" (static — applies at map time). Forced activations from the taskbar/foreign-toplevel-wlr protocol still focus the window normally. Secondary fixes in layouts.lua: - find_tabbed_group_anchor: constrain search to current workspace so the binding can't jump across workspaces when a stale anchor exists elsewhere - restore_workspace_tabbed_group / gather_workspace_into_tabbed_group: use the window that was focused at binding invocation time rather than the group anchor, so focus is returned to where the user actually was - Add focus_window_with_cursor helper: focuses the target window and warps the cursor to its center when the cursor is outside it, preventing follow_mouse=1 from fighting the explicit focus dispatch Co-Authored-By: Claude Fable 5 --- dotfiles/config/hypr/hyprland/layouts.lua | 66 +++++++++++++++++++++- dotfiles/config/hypr/hyprland/settings.lua | 14 +++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/dotfiles/config/hypr/hyprland/layouts.lua b/dotfiles/config/hypr/hyprland/layouts.lua index bfffa6ee..d0c8b737 100644 --- a/dotfiles/config/hypr/hyprland/layouts.lua +++ b/dotfiles/config/hypr/hyprland/layouts.lua @@ -312,6 +312,50 @@ function M.setup(ctx) return false end + local function window_contains_point(window, x, y) + local at = window and window.at + local size = window and window.size + if not at or not size then + return false + end + + local left = tonumber(at.x or at[1]) + local top = tonumber(at.y or at[2]) + local width = tonumber(size.x or size[1]) + local height = tonumber(size.y or size[2]) + if not left or not top or not width or not height then + return false + end + + return x >= left and x < left + width and y >= top and y < top + height + end + + -- With follow_mouse=1, a bare focus dispatch does not survive the next + -- pointer motion unless the cursor already sits inside the target window, + -- so warp the cursor into the window when needed. + local function focus_window_with_cursor(window) + local selector = window_selector(window) + if not selector then + return false + end + + local live = type(hl.get_window) == "function" and hl.get_window(selector) or window + if not live then + return false + end + + dispatch(hl.dsp.focus({ window = selector })) + + local cursor = hl.get_cursor_pos and hl.get_cursor_pos() + if cursor and window_contains_point(live, cursor.x, cursor.y) then + return true + end + + local center_x, center_y = window_center(live) + dispatch(hl.dsp.cursor.move({ x = math.floor(center_x), y = math.floor(center_y) })) + return true + end + local function find_tabbed_group_anchor(state) local active = hl.get_active_window() if active and active.group and active.group.size and active.group.size > 1 then @@ -322,8 +366,16 @@ function M.setup(ctx) return nil end + local workspace = active_workspace() for _, window in ipairs(hl.get_windows()) do - if window and window.address == state.anchor and window.group and window.group.size and window.group.size > 1 then + if + window + and window.address == state.anchor + and same_workspace(window.workspace, workspace) + and window.group + and window.group.size + and window.group.size > 1 + then return window end end @@ -378,6 +430,7 @@ function M.setup(ctx) local function restore_workspace_tabbed_group() local key = workspace_key() local state = tabbed_workspace_groups[key] + local entry_focused = hl.get_active_window() local anchor = find_tabbed_group_anchor(state) local anchor_selector = window_selector(anchor) local target_workspace_id = anchor and anchor.workspace and anchor.workspace.id @@ -394,7 +447,9 @@ function M.setup(ctx) tabbed_workspace_groups[key] = nil set_layout(columns_layout) restore_tabbed_group_window_order(state, target_workspace_id) - dispatch(hl.dsp.focus({ window = anchor_selector })) + if not focus_window_with_cursor(entry_focused) then + focus_window_with_cursor(anchor) + end schedule_nstack_count_update() end @@ -469,6 +524,9 @@ function M.setup(ctx) dispatch(hl.dsp.focus({ window = anchor_selector })) dispatch(hl.dsp.group.toggle({ window = anchor_selector })) notify_tabbed_group("Unable to group tiled windows") + if not focus_window_with_cursor(focused) then + focus_window_with_cursor(anchor) + end return elseif grouped_count < #candidates then notify_tabbed_group("Grouped " .. tostring(grouped_count) .. " of " .. tostring(#candidates) .. " tiled windows") @@ -479,7 +537,9 @@ function M.setup(ctx) order = original_order, windows = candidate_addresses, } - dispatch(hl.dsp.focus({ window = anchor_selector })) + if not focus_window_with_cursor(focused) then + focus_window_with_cursor(anchor) + end end local function force_columns_layout() diff --git a/dotfiles/config/hypr/hyprland/settings.lua b/dotfiles/config/hypr/hyprland/settings.lua index 3bb2d256..94afe2d3 100644 --- a/dotfiles/config/hypr/hyprland/settings.lua +++ b/dotfiles/config/hypr/hyprland/settings.lua @@ -429,6 +429,20 @@ function M.setup(ctx) }) hl.window_rule({ match = { title = "^(Confirm)$" }, float = true }) + -- The AI desktop apps fire xdg-activation requests while streaming + -- responses; with misc:focus_on_activate=true that steals focus from + -- whatever window the user is actually working in. focus_on_activate is + -- a dynamic rule (applies to already-mapped windows on reload); + -- suppress_event only applies at map time. + for index, class in ipairs({ "^(claude-desktop)$", "^(codex-desktop)$" }) do + hl.window_rule({ + name = "ai-app-no-activate-focus-" .. tostring(index), + match = { class = class }, + focus_on_activate = false, + suppress_event = "activatefocus", + }) + end + for index, match in ipairs({ { class = "^(flameshot)$" }, { title = "^(flameshot)$" },