Skip to content

Add keyboard navigation to the stacked-window switcher - #1808

Open
MyronKoch wants to merge 1 commit into
rxhanson:mainfrom
MyronKoch:feat/stack-badge-keyboard-nav
Open

Add keyboard navigation to the stacked-window switcher#1808
MyronKoch wants to merge 1 commit into
rxhanson:mainfrom
MyronKoch:feat/stack-badge-keyboard-nav

Conversation

@MyronKoch

@MyronKoch MyronKoch commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Follow-on to the hover badge from #1795. Arrow keys move the highlight, Return raises the selected window and leaves the list up so you can walk the stack, Escape closes.

How the keys are delivered, since this is the part worth your judgement. Rectangle is an accessory app, so its panels can never become key, and a focus-based approach genuinely doesn't work — I built it that way first and it required a click before the arrows did anything, then leaked arrow keys into the window it had just raised. The list uses a consuming CGEvent tap instead, scoped as narrowly as I could make it:

  • only bare Up/Down/Return/Escape are claimed; any held modifier passes straight through, Shift included, so shift-Return still inserts a newline in whatever is underneath
  • the tap is not installed at all while VoiceOver is running, since Quick Nav owns the bare arrow keys
  • it exists only while the list is open, and an untouched list expires after five seconds, so it can't sit holding the arrow keys
  • it goes away with the list on mouse-out, Space change, display change, or macOS hiding the panel

I'd rather ask than assume: is a key-consuming event tap acceptable here at all? You know Rectangle's accessibility bar and its users far better than I do, and if the answer is no, the list works fine mouse-only and I'll drop this half.

Known limitation, stated plainly. With a CJK input method composing, bare arrows and Return are candidate-selection keys, and there's no reliable way to detect that an IME owns the keystroke. While the list is open — at most five seconds without interaction — those keys would go to the list instead of the IME. Mouse interaction is unaffected. I couldn't find an honest fix for this; flagging it rather than papering over it.

Also here, because they're the same subsystem:

  • A screen-covering window now joins a stack the tiled windows already form, but never forms one on its own. A maximized window sitting over a left-half window was reading as a stack of two. (This is the badge half of Detect stacks of maximized windows in the overlap offset and badge #1813, which is otherwise folded into Bound the overlap-offset scan and offset maximized windows too #1810.)
  • Rows that would fall off the bottom of the screen aren't built at all — arrow keys could otherwise select, and Return raise, a window with no visible row. The badge still shows the true count.
  • A recordable shortcut to toggle the badge, following the app's existing conventions: recording suspends the binding, session changes re-register it, conflicts with window actions and Todo shortcuts are rejected, and it round-trips through config export/import.
  • ActiveEventMonitor start/stop is serialized and tap-timeout recovery re-enables the port in place instead of tearing the monitor down from inside its own callback. Snapping shares that class, so the force-unwrap that recovery path could hit wasn't scoped to this feature.
  • The view code moves into StackBadgeWindow, StackBadgeListPanel and StackBadgeRowView, one view per file, matching FootprintWindow.

This builds on #1810 for OverlapOffsetGeometry.coversScreen, which the covering-window rule uses. The diff here will shrink to the badge changes alone once that merges; happy to reorder if you'd rather take them the other way round.

Everything stays behind the existing badge preference, off by default.

@MyronKoch
MyronKoch marked this pull request as ready for review August 8, 2026 03:41
@MyronKoch
MyronKoch marked this pull request as draft August 11, 2026 19:26
@MyronKoch
MyronKoch force-pushed the feat/stack-badge-keyboard-nav branch from c900192 to 3e94718 Compare August 18, 2026 16:30
@MyronKoch
MyronKoch marked this pull request as ready for review August 18, 2026 16:30
@rxhanson

Copy link
Copy Markdown
Owner

How the keys are delivered, since this is the part worth your judgement.

Indeed we'll want to take this a different route. There are two options:

  1. Use an NSPanel that has styleMask: .nonactivatingPanel for your StackBadgeWindow. This can receive keystrokes without the app becoming frontmost.

  2. Use NSMenu.

I'm fine with either approach, and both have their own pros & cons.

@MyronKoch
MyronKoch force-pushed the feat/stack-badge-keyboard-nav branch from 3e94718 to d251b39 Compare August 18, 2026 22:11
Arrow keys move the highlight, Return raises the selected window and
leaves the list up so the stack can be walked, Escape closes. Rectangle
is an accessory app, so its panels can never become key and ordinary
focus cannot deliver these keys - the list uses a consuming event tap
instead, scoped as tightly as it can be. Only bare navigation keys are
claimed: any held modifier goes to the app underneath, the tap is not
installed at all while VoiceOver is running, since Quick Nav owns the
arrow keys, and an untouched list expires after five seconds so it can
never sit holding them.

macOS reports every arrow keystroke as carrying .function and
.numericPad. Those are properties of the key rather than modifiers being
held, so only the four keys a user can actually hold are treated as a
reason to pass a keystroke through.

The badge also stops counting stacks that are not there: a window
covering the screen joins a stack the tiled windows already form, but
never forms one on its own. Rows that would fall off the bottom of the
screen are not built, since arrow keys could otherwise select - and
Return raise - a window with no visible row.

The toggle shortcut follows the app's existing shortcut conventions, and
the view code moves into StackBadgeWindow, StackBadgeListPanel and
StackBadgeRowView, one view per file.
@MyronKoch
MyronKoch force-pushed the feat/stack-badge-keyboard-nav branch from d251b39 to 5e506d8 Compare August 19, 2026 14:08
@rxhanson

Copy link
Copy Markdown
Owner

I was thinking about this a little more and figured I'd elaborate a bit.

I think it's ok to remove the badge number entirely, and only show the list of window titles that can be selected. This lends itself to the NSMenu approach quite well.

The NSPanel is certainly the better choice for the user experience, but it's also a lot more involved to get right. My initial thought is perhaps simply go with the NSMenu for the first cut, since that should be pretty easy and can be converted over to the NSPanel later.

In the meantime, I'm going to go ahead and push out a release and will roll this change into the next one once it's ready.

@MyronKoch

Copy link
Copy Markdown
Contributor Author

Happy to go whichever way you prefer. One data point before you decide: I had a go at the NSPanel route and it's further along than you might expect - canBecomeKey plus makeKeyAndOrderFront was the whole trick, and the list takes arrows, Return and Escape with no click and no event tap. It came out as a net deletion of about 60 lines, since all the tap's safety machinery went away with it.

The one thing that pushed me toward the panel: an NSMenu closes on selection, so you lose being able to walk a stack - down, Return, look, down, Return - which turns out to be the thing I use this for most. With the panel the list stays up and keeps the keyboard.

If you'd still rather have NSMenu as the first cut I'll do that instead, no argument. And no rush on my side given you're mid-release.

On dropping the count: I can see the case, though the badge is what tells you a stack is there in the first place. Would you want the list on hover with no indicator at all beforehand?

@rxhanson

Copy link
Copy Markdown
Owner

Oh, nice! Let's continue with the NSPanel route for sure.

In my testing, the badge and the list are shown at the same time on hover, and that's how I ended up thinking down that path. Is the intent to show the badge even when not hovering, or is it to first show the badge, then the list?

@rxhanson

Copy link
Copy Markdown
Owner

Provided we're not going the NSMenu route, I'm actually good with however you'd like to take it - I was just looking to simplify in case there were road bumps going with the NSPanel.

@MyronKoch

Copy link
Copy Markdown
Contributor Author

Good question, and I think you spotted something. Today both appear together on dwell, which does make the count redundant - if you can see three rows, a pill saying 3 is not earning its place. That is the version you were testing, and it is a fair reason to wonder why the badge is there at all.

The intent is the two-stage one: dwell on a stack corner shows just the badge, and the list opens when you move onto it. That makes the badge the affordance you act on rather than a label duplicating the list, and passing your cursor over a stack on the way to somewhere else costs you a small pill instead of a full popup.

There is a second reason that only applies now that we are on the panel: showing the list means taking key status, since that is how it gets the arrow keys. If the list opens on dwell alone, resting the cursor near a stack corner quietly takes the keyboard from whatever you were typing in. Gating it behind a deliberate move onto the badge means we only take the keyboard when someone has actually opted in.

I will implement that and update the PR. If you would rather it stayed one stage, say so and I will drop it back - it is a small change either way.

@rxhanson

Copy link
Copy Markdown
Owner

I like the two stage concept. 👍

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.

2 participants