Skip to content

fix: Alt+Tab intermittently leaves focus unchanged - #7

Open
ekropotin wants to merge 1 commit into
piyush97:mainfrom
ekropotin:fix/alt-tab-focus-not-applied
Open

fix: Alt+Tab intermittently leaves focus unchanged#7
ekropotin wants to merge 1 commit into
piyush97:mainfrom
ekropotin:fix/alt-tab-focus-not-applied

Conversation

@ekropotin

Copy link
Copy Markdown

Problem

Alt+Tab intermittently leaves focus unchanged: the overlay opens, the highlight moves to another window, releasing Alt dismisses it — and the window never switches. Repeating it often stays stuck on the same window for several presses before working again.

There are two independent causes, and each produces that identical symptom.

1. The switch is undone by the compositor's focus restore

The overlay takes WlrKeyboardFocus.Exclusive. Hyprland hands keyboard focus back to the previously focused toplevel when that layer surface unmaps — tracing the event socket shows closelayer followed ~2 ms later by the restore:

13:48:07.281  closelayer>>piyush-omaswitch
13:48:07.283  activewindow>>vivaldi-stable,…      <- restore
13:48:07.285  activewindowv2>>563efbf7dcd0

focusSelected() dispatched focus while the overlay was still mapped, then called dismiss(). So the switch only survived when the spawned hyprctl lost the race against the unmap; when it won, the restore overwrote it.

Ordering the two operations explicitly, everything else held constant:

Ordering Result
dispatch → unmap (previous behaviour) switch reverted, 6/6
unmap → dispatch switch sticks, 6/6

focusSelected() now stashes the target, dismisses, and applies it when the restore event arrives — with a timer backstop for the case where no restore is emitted (nothing was focused before opening).

2. The pre-selection can point at the window already focused

Ordering came from lastIpcObject.focusHistoryID. That object is a cached snapshot which is not refreshed when focus moves, so it can still name a stale window as rank 0. With a newly created window focused:

current window another window
live hyprctl clients new window, fh=0 fh=1
lastIpcObject as read by the plugin fh=0

isCurrent(rows[0]) then held for the wrong row, selectedIndex stayed 0, and confirming re-focused the current window. Observed as single-tap failing 6/6 while double-tap passed 4/4 — the extra tap advanced off index 0.

The Wayland activated flag cannot stand in either: it reads false for every toplevel while the overlay holds exclusive keyboard focus.

Windows are now ranked by an MRU built from the compositor's own activewindowv2 events, which are observed whether or not the overlay is open. focusHistoryID remains the seed for windows not yet seen focused, where it is accurate. rows[0] is the focused window by construction, so its neighbour is always a real switch target.

Verification

Driven through the real binding path (summon, confirm, then read hyprctl activewindow):

Scenario Before After
2 windows, single tap 2/8 16/16
3 windows, single tap 0/6 8/8
3 windows, double tap 4/4 6/6

Single-tap now alternates between the two most recent windows and double-tap reaches the third, which is the expected Alt+Tab behaviour. No stuck overlays across any run.

Notes

  • sortedWindows() takes an optional rankFn and still defaults to focusRank, so the model remains usable and testable on its own.
  • normalizeAddress() is shared because toplevels expose 0x55… while the activewindowv2 payload is the bare 55….
  • test_model.js covers both additions; existing assertions are unchanged and still pass.

…ed focus

Alt+Tab intermittently left focus unchanged: the overlay opened, the highlight
moved, releasing Alt dismissed it, and the window never switched. Two separate
causes, each of which produces that same symptom.

1. The switch was undone by the compositor's focus restore.

   The overlay takes WlrKeyboardFocus.Exclusive, and Hyprland hands keyboard
   focus back to the previously focused toplevel when the layer surface
   unmaps -- `closelayer` is followed a few milliseconds later by an
   activewindow/activewindowv2 naming the window that was focused before it
   opened. focusSelected() dispatched focus while the overlay was still
   mapped and dismissed immediately after, so the switch only survived when
   the spawned hyprctl lost the race against that restore. When it won, the
   restore overwrote the switch.

   Measured on Hyprland by ordering the two operations explicitly: dispatch
   before unmap reverted the switch 6/6, dispatch after it stuck 6/6.

   focusSelected() now stashes the target, dismisses, and applies it when the
   restore event arrives, with a timer backstop for the case where the
   compositor emits no restore (nothing was focused before opening).

2. The pre-selection could point at the window already focused.

   Ordering came from lastIpcObject.focusHistoryID, a cached snapshot that is
   not refreshed when focus moves, so it can still name a stale window as
   rank 0. With a newly created window focused, the live compositor reported
   it at rank 0 while the plugin's cached copy reported a different window --
   isCurrent(rows[0]) then held for the wrong row and selectedIndex stayed 0,
   so confirming re-focused the current window. The Wayland `activated` flag
   cannot stand in: it reads false for every toplevel while the overlay holds
   exclusive keyboard focus.

   Windows are now ranked by an MRU built from the compositor's own
   activewindowv2 events, which are observed whether or not the overlay is
   open. focusHistoryID remains the seed for windows not yet seen focused,
   where it is accurate. rows[0] is the focused window by construction, so
   the neighbour is always a real switch target.

End to end with three windows: 8/8 single-tap and 6/6 double-tap switches, and
16/16 across two windows. Before the change the same script reverted 6 of 8.

sortedWindows() takes an optional rankFn and still defaults to focusRank, so
the model stays usable on its own; normalizeAddress() is shared because
toplevels expose "0x55..." while the event payload is the bare "55...".
Tests cover both.
@ekropotin

Copy link
Copy Markdown
Author

I've just find out, that the MRU half (problem 2) here duplicates #5, and it does it better.

The other half is independent and not covered by #5 tho. That matters because the ordering fix alone doesn't resolve the symptom.

So I'd rather not rewrite this pre-emptively - I'll wait to see where #5 lands and adjust accordingly. If it is merged, I'll rebase this down to just the unmap fix so the two compose. If it isn't, I'll keep this self-contained. Happy either way, so let me know if you'd prefer one over the other.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant