Files
paseo/docs/keyboard-shortcuts.md
T
1c4400194a Support hardware keyboard shortcuts across native and desktop apps (#8625)
* feat(keyboard): support hardware keyboards on iPad

Register app shortcuts as iOS UIKeyCommands so a paired hardware keyboard
drives the same bindings as web and desktop. Overlays (menus, comboboxes,
modals, command center) get first refusal on bare keys, and a focused
terminal keeps the keys it needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(keyboard): record iOS hardware key observation spike

With a UITextView focused on iPad Pro M4/iOS 18.6, window and controller observers received Cmd+B, Cmd+P and Escape. Escape also invoked UIKeyCommand when registered, so the stream must be the sole emitter. Use observation for shortcuts and commands only for text-control ownership. Simulator intercepted Cmd+K as HID184; input capture blocked synthetic typing entirely. Keep capture off and verify the toggle via a rebind during simulator QA.

* refactor(keyboard): give overlays one key registration API

Overlay ownership now follows one layer-ordered registry on every runtime. Web retains focus trapping and restoration through its adapter; native registers the same key handler without a DOM scope. Picker navigation and dismissal no longer require a parallel native stack.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): route native hardware keys through the shared resolver

Observe each physical press once, including native modal windows, and derive focus from declared composer and terminal surfaces. Native bindings and rebinds now use the same resolver and overlay precedence as browser and Electron input.

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* feat(keyboard): show shortcuts when hardware keyboards are present

Keep routing independent of compact layout and detect hardware capability for hints and settings. Rebind capture uses the shared overlay interface on native too. Keep the filtered sidebar model available for keyboard navigation while its panel is hidden.

Co-authored-by: Les Stroud <public@lesstroud.com>

Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>

* test(keyboard): allow shortcut hints in compact menu assertions

Compact menus now display hardware keyboard hints. Assert action labels, order and uniqueness without treating a trailing shortcut badge as part of the action label.

* fix(keyboard): retain an overlay's opening layer in state

Capture the registry's current top when an overlay opens and retain that layer through rerenders. React Compiler removed the previous useMemo cache after the registry getter moved across modules, allowing a parent modal to rise above its child and start an infinite focus loop.

Compile this hook in Vitest as Expo does. The new hook regression failed first with layer 220 instead of 20, then passed with explicit opening state. Focused unit and browser tests pass, and the previously failing onboarding E2E completes through the real Expo compiler. Typecheck, lint and format pass.

* refactor(keyboard): let overlays own native hardware input

Remove the TextInput key handlers that duplicate declared overlay arrows and Escape in Add Project and Command Center. Remove Add Project's hidden responder now that native window sources observe keys without it. Keep soft-keyboard submit editing behavior.

* fix(keyboard): close Add Project outside its state updater

Root Escape previously called the parent close action from a React state updater, producing a cross-component render warning when navigation was still queued. Compute the previous page in the event handler and close outside the updater.

The existing Escape/Back E2E now batches navigation and dismissal through the DOM key source. It failed first with the AddProjectFlowHost render warning, then passed after the correction. Typecheck, lint and format pass; native Escape checks pass on Android and iPad.

* fix(keyboard): preserve Android dead-key composition

* fix(keyboard): recognize focused native text inputs

* test(keyboard): replace JSDOM coverage with browser tests

* refactor(menu): spell out arrow navigation branches

* fix(composer): claim fullscreen Escape on native

* refactor(composer): one fullscreen Escape module for every platform

The shared registration lived in fullscreen-exit.shared.ts with two one-line
re-exports (fullscreen-exit.ts and fullscreen-exit.native.ts). Metro needs no
native override now that the Android back button is its own adapter, so the
implementation moves into fullscreen-exit.ts and the browser test imports the
module every platform resolves.

---------

Co-authored-by: Ubuntu <jeroenfrenken@icloud.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>
2026-11-03 18:08:27 +07:00

5.0 KiB
Raw Blame History

Keyboard shortcuts

Define shortcuts in the shared binding table. Platforms contribute key sources, not separate action maps, so matching and user rebinds stay consistent across runtimes. Add a shortcut through a binding; add platform support through a key source.

Badges and routing

Routing runs whenever the app mounts its key sources, independently of layout. Use useHardwareKeyboardPresent() for hints and the Shortcuts settings section. iOS reads GCKeyboard.coalesced and connection notifications. Android watches input devices and hardware keyboard configuration. Web starts with a fine hover pointer and recognizes the first modified keydown, including on touch devices. Compact Electron keeps its bindings.

Keyboard navigation needs the sidebar's filtered workspace model even when its visual panel is hidden. Retain that model while keyboard navigation is active; gating it on panel visibility leaves number shortcuts with no targets after a compact startup.

Composer send-key customization remains a separate capability. Native retains its existing Return submission path; advertising its web-only customization would expose settings the native composer does not implement.

Native sources

Use the iOS window press stream for hardware keys. UIKeyCommand only takes keys that must bypass the focused text control: composer Return and the top overlay's Enter and arrows. Keep the stream as the sole shortcut emitter: a command-owned press is still observed by the window.

The source spike used an iPad Pro 11-inch (M4) simulator on iOS 18.6 with a UITextView first responder. Both UIWindow.sendEvent and the root controller's pressesBegan received Cmd+B, Cmd+P and Escape. With Escape registered as a UIKeyCommand, the window observed it and the command handler also ran.

Simulator reserves Cmd+K for Toggle Software Keyboard. Injecting it produced HID 184 with empty characters rather than K; plain K, Cmd+B and Cmd+P arrived normally. Keep Simulator input capture off: Send Keyboard Input to Device prevented synthetic AppleScript input from arriving, including plain text. For simulator QA, use Cmd+B/P to check delivery and rebind the command center to Cmd+J to check toggle and rebind behavior. Cmd+K delivery needs a physical iPad. Simulator also reserves Cmd+1…4, Cmd+Shift+A and Cmd+comma. Exercise those actions through rebinds. For numeric injection, use AppleScript physical key codes (for example 25 for 9); keystroke "9" did not deliver a digit in the QA session. Simulator exposes a virtual GCKeyboard even with Connect Hardware Keyboard off. Verify the iOS absence case on a physical iPhone.

Never register Backspace: it would take deletion away from text fields. Changes to the native module require a native rebuild; an over-the-air JavaScript update cannot deliver them.

Overlay ownership

Register keyboard handling with useOverlayRegistration from @/keyboard. Pass the surface's open state, layer, handled keys and onKeyDown. The highest layer gets first refusal; the latest registration wins a tie. Native registration does not need a DOM ref. Web adds focus trapping and restoration through its adapter.

Capture a global overlay’s layer in state for its opening lifetime. Memo caches can be discarded or removed by React Compiler; recomputing against the registry lets an open modal rise above its own children and creates competing focus traps.

Declare Enter and arrow keys only while the surface owns them. Native sources use the top overlay's declarations to take those keys before text controls edit. Backspace stays with the focused input. Composition confirmation must not select an overlay row or submit the composer.

Android modals have their own windows. Observe them through React Native's ExtraWindowEventListener as well as the activity window. The dev client bypasses Expo's root-container handler, so an activity root-view wrapper misses presses. A modal can forward an unhandled event into the activity window. Observe the event once across that nested delivery without retaining recycled Android KeyEvents. Keep an overlay-owned press consumed through key-up, even if its key-down closes the overlay; otherwise the modal's default Escape handler can close a second layer.

iOS scene startup installs the observing window after Expo creates its root view. Keep that integration in the hardware keyboard module's config plugin. Its window owns both the press stream and the text-control key commands, including above modals.

Focus scopes

The composer and terminal declare focus with useKeyboardFocusSurface. Native scope derives from those declarations and command-center state. Releasing an old surface must not clear a newer owner's declaration during a tab switch. Web keeps its DOM-derived scope.

Escape interrupts an agent only in the message input or an unclaimed surface. A focused terminal excludes that binding. The source leaves terminal Escape alone; terminal Escape/Ctrl forwarding is a separate feature.