Skip to content

fix(ui): a modal owns the keyboard, not just the pointer (#631)#665

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/modal-keyboard-631
Jul 18, 2026
Merged

fix(ui): a modal owns the keyboard, not just the pointer (#631)#665
InauguralPhysicist merged 1 commit into
mainfrom
fix/modal-keyboard-631

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Closes #631

push_modal made a dialog modal for the pointer only. Keyboard focus never consulted _ui.modal_stack, so a user could Tab to a widget behind an open dialog and activate it with Enter/Space.

The concrete failure: an "Are you sure? [Cancel]" dialog is up, the user presses Tab+Enter, and the destructive action behind the dialog fires — the opposite of what a confirmation dialog is for.

Two halves, one root cause

Focus ignored the modal stack:

  1. dispatch scoped the mouse to the top modal but returned keys with the real root above the rebind.
  2. The focus list was only ever built from the real root, so a dialog's own children were never focusable and the background's always were.

Fix — focus respects the modal stack

  • dispatch: move the modal rebind above the key early-return, so handle_key (→ _focus_next_build_focus_list) scopes to the top modal exactly as hit-testing does. Escape still pops the modal — handle_key checks the stack directly, so it survives the rebind.
  • push_modal: rebuild the focus list from the dialog and clear the focus pointers, so Tab cycles inside the modal, the dialog's own children (the file_dialog text_input/item_list from lib/ui: no file/path picker dialog, and the dialog widget cannot host child widgets #575) are immediately focusable, and a stray Enter before any Tab can't reach a background widget.
  • pop_modal: rebuild focus from the newly-exposed context — the next modal down, or edit_root when the stack empties (null-guarded for headless use).

Pre-existing (v0.32.0's dispatch had the same shape), but #575 made it reachable a second way — dialogs can now host focusable children a user would reasonably expect to Tab between.

Verification

The issue's reproduction, after the fix:

after Tab, focused = null (no background widget)   # was "BEHIND"
background button fired via keyboard = 0           # was 1 — Enter had activated it

7 regression asserts in test_ui.eigs (gfx-stubbed, keyboard-only so no gfx dependency): Tab stays in the dialog, Enter fires the dialog child rather than the background button, Tab cycles between the dialog's own children, and pop_modal returns focus to the background. 5 of the 7 fail on the pre-fix toolkit (Tab... got M631_BEHIND, Enter... fired the button behind, got 1); the other two are weaker guards the stronger ones subsume.

Gates

  • Release suite 3085/3085
  • ASan + UBSan detect_leaks=1 3089/3089, leak tally 0

🤖 Generated with Claude Code

push_modal made a dialog modal for the pointer only. Keyboard focus never
consulted _ui.modal_stack, so a user could Tab to a widget BEHIND an open
dialog and activate it with Enter/Space — an "Are you sure? [Cancel]" dialog
could fire the destructive action it exists to guard against.

Two halves, one root cause (focus ignored the modal stack):
1. dispatch scoped the mouse to the top modal but returned keys with the real
   root ABOVE the rebind.
2. the focus list was only ever built from the real root, so a dialog's own
   children were never focusable and the background's always were.

Fix — focus respects the modal stack:
- dispatch: move the modal rebind ABOVE the key early-return, so handle_key
  (and thus _focus_next/_build_focus_list) scopes to the top modal exactly as
  hit-testing does. Escape still pops the modal (handle_key checks the stack
  directly, so it survives the rebind).
- push_modal: rebuild the focus list from the dialog and clear the focus
  pointers, so Tab cycles inside the modal, the dialog's own children (the
  file_dialog text_input/item_list from #575) are immediately focusable, and a
  stray Enter before any Tab cannot reach a background widget.
- pop_modal: rebuild focus from the newly-exposed context — the next modal
  down, or edit_root when the stack empties (null-guarded for headless use).

Pre-existing, not introduced by #575, but #575 made it reachable a second way
(dialogs can now host focusable children a user would expect to Tab between).

Tests: 7 asserts in test_ui.eigs (gfx-stubbed, keyboard-only so no gfx dep) —
Tab stays in the dialog, Enter fires the dialog child not the background
button, Tab cycles between the dialog's children, and pop_modal returns focus
to the background. 5 of the 7 fail on the pre-fix toolkit (the other two are
weaker guards the stronger ones subsume).

Suite 3085/3085 release, 3089/3089 ASan+UBSan (detect_leaks=1, tally 0).
@InauguralPhysicist
InauguralPhysicist merged commit 6857fb9 into main Jul 18, 2026
18 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix/modal-keyboard-631 branch July 18, 2026 04:20
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.

lib/ui: a modal blocks the mouse but not the keyboard — Tab+Enter activates a button behind the dialog

1 participant