hypr: fix AI desktop apps stealing focus via xdg-activation

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:22:08 -07:00
parent 5aa543209d
commit c75cf6c29c
2 changed files with 77 additions and 3 deletions

View File

@@ -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()

View File

@@ -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)$" },