diff --git a/lib/ui.eigs b/lib/ui.eigs index 0f85a97..45de4bc 100644 --- a/lib/ui.eigs +++ b/lib/ui.eigs @@ -477,6 +477,15 @@ define push_modal(dlg, parent_w, parent_h) as: # moment it is pushed, and its children need absolute positions to be # hit-tested (#575). app_loop re-lays it every frame after this. _layout of [dlg, 0, 0] + # Scope keyboard focus to the dialog too (#631): rebuild the focus list + # from the dialog's own children and drop any background focus, so Tab + # cycles inside the modal and a stray Enter cannot fire a widget behind + # it before the user has Tabbed. Mouse modality already scopes via dispatch. + _ui.focus_list is [] + _build_focus_list of dlg + _ui.focus_index is 0 - 1 + _ui.focused_widget is null + _ui.focused_input is null define pop_modal() as: n is len of _ui.modal_stack @@ -488,6 +497,18 @@ define pop_modal() as: for i in range of (n - 1): append of [new_stack, _ui.modal_stack[i]] _ui.modal_stack is new_stack + # Restore focus to whatever is now on top (#631): the next modal down, or + # the app root once the stack is empty. edit_root is null until app_loop + # runs, so a headless push/pop just clears focus rather than erroring. + focus_root is _ui.edit_root + if (len of new_stack) > 0: + focus_root is new_stack[(len of new_stack) - 1] + _ui.focus_list is [] + if focus_root != null: + _build_focus_list of focus_root + _ui.focus_index is 0 - 1 + _ui.focused_widget is null + _ui.focused_input is null return top define show_menu(menu_widget, x, y) as: @@ -565,19 +586,21 @@ define handle_key(root, ev) as: # Event dispatch — registry-driven define dispatch(root, ev) as: + # A modal owns ALL input — pointer AND keyboard (#631). Rebind the root to + # the top of the stack BEFORE routing anything, so hit-testing, the + # registry walk, hover, drag AND keyboard focus navigation/activation all + # run against the dialog's subtree — nothing behind it can be reached. The + # keys previously returned above this rebind (mouse-only modality), which + # let a user Tab to a background widget and Enter-activate it through the + # dialog. This is also what lets a dialog host children (#575): they are + # dispatched to like any other widget. Escape still pops the modal — + # handle_key checks the stack directly, so it survives the rebind. + if (len of _ui.modal_stack) > 0: + root is _ui.modal_stack[(len of _ui.modal_stack) - 1] # Keys route through the same public handler app_loop uses (#563), so # dispatch is the single event door for mouse, wheel and keyboard. if ev.type == "keydown" or ev.type == "keyup": return handle_key of [root, ev] - # A modal owns the pointer: the top of the stack becomes the root for - # this event, so everything below — hit-testing, the registry walk, - # focus, hover, drag — runs against the dialog's subtree and nothing - # behind it can be reached. This is also what lets a dialog host - # children (#575): they are dispatched to like any other widget - # instead of through a special-case click handler that knew only - # about the dialog's own buttons. - if (len of _ui.modal_stack) > 0: - root is _ui.modal_stack[(len of _ui.modal_stack) - 1] mx is 0 my is 0 if ev.type == "mousemove" or ev.type == "mousedown" or ev.type == "mouseup": diff --git a/tests/test_ui.eigs b/tests/test_ui.eigs index 92ff623..c085fd6 100644 --- a/tests/test_ui.eigs +++ b/tests/test_ui.eigs @@ -1205,6 +1205,62 @@ remove_file of "/tmp/eigs_fd_probe/a.wav" remove_file of "/tmp/eigs_fd_probe/sub/inner.txt" _ui.modal_stack is [] +# ============================================================ +# #631: a modal owns the keyboard, not just the pointer +# ============================================================ +# Before the fix, dispatch returned keys with the real root above the modal +# rebind, so Tab escaped to a background widget and Enter activated it — an +# "Are you sure? [Cancel]" dialog could fire the destructive action behind it. +_ui.modal_stack is [] +_ui.focus_list is [] +_ui.focused_widget is null +_ui.focused_input is null +_ui.focus_index is 0 - 1 + +m631_root is panel of ["m631_root", 0, 0, 800, 600] +m631_fired is [0] +define m631_boom(b) as: + m631_fired[0] is m631_fired[0] + 1 +m631_bg is button of ["M631_BEHIND", 0, 0, 100, 30, "danger", m631_boom] +add_child of [m631_root, m631_bg] +_layout of [m631_root, 0, 0] +_ui.edit_root is m631_root +_build_focus_list of m631_root + +# A dialog hosting two focusable children of its own (#575). +m631_dlg is dialog of ["m631_dlg", 400, 300, "Modal", "", ["OK"], null] +m631_hit is [0] +define m631_ok(b) as: + m631_hit[0] is m631_hit[0] + 1 +m631_c1 is button of ["M631_C1", 0, 0, 60, 24, "One", m631_ok] +m631_c2 is button of ["M631_C2", 0, 0, 60, 24, "Two", m631_ok] +add_child of [m631_dlg, m631_c1] +add_child of [m631_dlg, m631_c2] +push_modal of [m631_dlg, 800, 600] + +# Tab scopes to the dialog, never the background. +dispatch of [m631_root, {"type": "keydown", "key": "tab", "shift": 0, "ctrl": 0, "alt": 0}] +assert_true of [_ui.focused_widget != null, "#631 Tab under a modal focuses a dialog child, not nothing"] +assert_eq of [_ui.focused_widget.id, "M631_C1", "#631 Tab lands on the dialog's first child"] + +# Enter fires the DIALOG child, never the button behind the modal. +dispatch of [m631_root, {"type": "keydown", "key": "return", "shift": 0, "ctrl": 0, "alt": 0}] +assert_eq of [m631_fired[0], 0, "#631 Enter does not fire the button behind the modal"] +assert_eq of [m631_hit[0], 1, "#631 Enter fires the focused dialog child"] + +# Tab again cycles within the dialog (C1 -> C2), still not the background. +dispatch of [m631_root, {"type": "keydown", "key": "tab", "shift": 0, "ctrl": 0, "alt": 0}] +assert_eq of [_ui.focused_widget.id, "M631_C2", "#631 Tab cycles between the dialog's own children"] + +# After pop, focus returns to the background list. +pop_modal of null +dispatch of [m631_root, {"type": "keydown", "key": "tab", "shift": 0, "ctrl": 0, "alt": 0}] +assert_eq of [_ui.focused_widget.id, "M631_BEHIND", "#631 after pop_modal, Tab returns to the background widget"] +assert_eq of [m631_fired[0], 0, "#631 the background button never fired via keyboard"] + +_ui.modal_stack is [] +_ui.edit_root is null + # ============================================================ # Summary # ============================================================