diff --git a/CLAUDE.md b/CLAUDE.md index 71682af7e9..c9b6e0db7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it | | [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | | [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash | +| [docs/keyboard-shortcuts.md](docs/keyboard-shortcuts.md) | The binding table, per-platform key sources, badges vs routing, iOS `UIKeyCommand` limits | | [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu | | [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas | | [docs/notifications.md](docs/notifications.md) | Notification tap ownership, native replay and acknowledgement, startup handoff, and payload QA | diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md new file mode 100644 index 0000000000..eac3ee8103 --- /dev/null +++ b/docs/keyboard-shortcuts.md @@ -0,0 +1,86 @@ +# Keyboard shortcuts + +Define shortcuts in [the shared binding table](../packages/app/src/keyboard/keyboard-shortcuts.ts). 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 `KeyEvent`s. +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. diff --git a/packages/app/app.config.js b/packages/app/app.config.js index eb7907e5ce..fecdcce31a 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -142,6 +142,7 @@ export default { searchPaths: ["../../node_modules", "./node_modules"], }, plugins: [ + "./modules/paseo-hardware-keyboard/app.plugin", "expo-router", withPasteInput, withAndroidScroll, diff --git a/packages/app/app.config.test.ts b/packages/app/app.config.test.ts index d0de16bd03..dde61f4560 100644 --- a/packages/app/app.config.test.ts +++ b/packages/app/app.config.test.ts @@ -94,3 +94,21 @@ it("allows all orientations on iPhone and iPad", async () => { await rm(root, { recursive: true, force: true }); } }, 30_000); + +// Scene startup is the integration boundary for the iOS keyboard window. +describe("hardware keyboard scene integration", () => { + it("installs observation after Expo creates the scene window and is idempotent", () => { + const { + configureHardwareKeyboardAppDelegate, + } = require("./modules/paseo-hardware-keyboard/app.plugin.js"); + const source = + "import Expo\nclass Scene {\n super.scene(scene, willConnectTo: session, options: connectionOptions)\n}\n"; + const configured = configureHardwareKeyboardAppDelegate(source); + expect(configured).toContain("import PaseoHardwareKeyboard"); + expect(configured).toContain("PaseoHardwareKeyboardWindow.observing(window)"); + expect(configured.indexOf("super.scene")).toBeLessThan( + configured.indexOf(".observing(window)"), + ); + expect(configureHardwareKeyboardAppDelegate(configured)).toBe(configured); + }); +}); diff --git a/packages/app/e2e/browser/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts index 1beb670f93..bce5adcaaf 100644 --- a/packages/app/e2e/browser/add-project-flow.spec.ts +++ b/packages/app/e2e/browser/add-project-flow.spec.ts @@ -15,6 +15,8 @@ import { expectNewWorkspaceForAddedProject, expectAddProjectResultVisible, navigateAddProjectResults, + navigateAndDismissAddProject, + watchAddProjectRenderWarnings, openAddProjectFlow, openAddProjectHostSelection, } from "../support/helpers/add-project-flow"; @@ -146,6 +148,7 @@ test.describe("Add Project command-center flow", () => { test("Escape and Back restore searchable page input before closing at the root", async ({ page, }) => { + const renderWarnings = watchAddProjectRenderWarnings(page); await gotoAppShell(page); await addConnectedHostAndReload(page, { serverId: secondaryHost.serverId, @@ -183,8 +186,9 @@ test.describe("Add Project command-center flow", () => { await page.keyboard.press("Escape"); await expectAddProjectPage(page, "host"); - await page.keyboard.press("Escape"); + await navigateAndDismissAddProject(page); await expect(addProjectFlow(page)).not.toBeVisible(); + expect(renderWarnings).toEqual([]); }); test("New directory creates a Project on the selected remote host", async ({ page }) => { diff --git a/packages/app/e2e/browser/compact-git-actions-menu.spec.ts b/packages/app/e2e/browser/compact-git-actions-menu.spec.ts index e2e2cdb34f..59fe207e51 100644 --- a/packages/app/e2e/browser/compact-git-actions-menu.spec.ts +++ b/packages/app/e2e/browser/compact-git-actions-menu.spec.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import { mkdtemp, writeFile } from "node:fs/promises"; import path from "node:path"; +import type { Locator } from "@playwright/test"; import { test, expect, type Page } from "../support/fixtures"; import { getServerId } from "../support/helpers/server-id"; import { connectSeedClient } from "../support/helpers/seed-client"; @@ -59,6 +60,14 @@ async function openCompactActionsMenu(page: Page, workspaceId: string) { return menu; } +async function expectActionsInOrder(menu: Locator, labels: readonly string[]) { + const items = menu.getByRole("menuitem"); + await expect(items).toHaveCount(labels.length); + for (const [index, label] of labels.entries()) { + await expect(items.nth(index).getByText(label, { exact: true })).toBeVisible(); + } +} + test("compact git actions menu lists a promoted Pull once", async ({ page }) => { const repoPath = await createRemoteRepo("compact-menu-pull-"); cleanupTasks.push(async () => { @@ -83,12 +92,7 @@ test("compact git actions menu lists a promoted Pull once", async ({ page }) => const menu = await openCompactActionsMenu(page, workspaceId); await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1); - await expect(menu.getByRole("menuitem")).toHaveText([ - "Pull", - "Push", - "Pull and push", - "Archive workspace", - ]); + await expectActionsInOrder(menu, ["Pull", "Push", "Pull and push", "Archive workspace"]); }); test("compact git actions menu lists a promoted Push once", async ({ page }) => { @@ -106,10 +110,5 @@ test("compact git actions menu lists a promoted Push once", async ({ page }) => const menu = await openCompactActionsMenu(page, workspaceId); await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1); - await expect(menu.getByRole("menuitem")).toHaveText([ - "Push", - "Pull", - "Pull and push", - "Archive workspace", - ]); + await expectActionsInOrder(menu, ["Push", "Pull", "Pull and push", "Archive workspace"]); }); diff --git a/packages/app/e2e/support/helpers/add-project-flow.ts b/packages/app/e2e/support/helpers/add-project-flow.ts index 3aea419f4e..1190bf0676 100644 --- a/packages/app/e2e/support/helpers/add-project-flow.ts +++ b/packages/app/e2e/support/helpers/add-project-flow.ts @@ -117,3 +117,25 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P export async function chooseAddHostFromProjectFlow(page: Page): Promise { await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click(); } + +export async function navigateAndDismissAddProject(page: Page): Promise { + // Keep both keys in one task so Escape closes with a selection update still queued. + await page.evaluate(() => { + for (const key of ["ArrowDown", "Escape"]) { + window.dispatchEvent( + new KeyboardEvent("keydown", { key, code: key, bubbles: true, cancelable: true }), + ); + } + }); +} + +export function watchAddProjectRenderWarnings(page: Page): string[] { + const warnings: string[] = []; + page.on("console", (message) => { + const text = message.text(); + if (text.includes("Cannot update a component") && text.includes("AddProjectFlow")) { + warnings.push(text); + } + }); + return warnings; +} diff --git a/packages/app/modules/paseo-hardware-keyboard/android/build.gradle b/packages/app/modules/paseo-hardware-keyboard/android/build.gradle new file mode 100644 index 0000000000..62031857e0 --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/android/build.gradle @@ -0,0 +1,16 @@ +apply plugin: 'com.android.library' +apply plugin: 'expo-module-gradle-plugin' + +group = 'sh.paseo.keyboard' +version = '0.1.0' +android { + namespace 'sh.paseo.keyboard' + defaultConfig { + versionCode 1 + versionName '0.1.0' + } +} +dependencies { + implementation 'com.facebook.react:react-android' + testImplementation 'junit:junit:4.13.2' +} diff --git a/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/HardwareCharacterKey.kt b/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/HardwareCharacterKey.kt new file mode 100644 index 0000000000..bccf25fe6b --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/HardwareCharacterKey.kt @@ -0,0 +1,7 @@ +package sh.paseo.keyboard + +import android.view.KeyCharacterMap + +internal fun hardwareCharacterKey(character: Int): String = + if (character and KeyCharacterMap.COMBINING_ACCENT != 0) "Dead" + else String(Character.toChars(character)) diff --git a/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/PaseoHardwareKeyboardModule.kt b/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/PaseoHardwareKeyboardModule.kt new file mode 100644 index 0000000000..f8b5fd7d31 --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/PaseoHardwareKeyboardModule.kt @@ -0,0 +1,164 @@ +package sh.paseo.keyboard + +import android.content.ComponentCallbacks +import android.content.res.Configuration +import android.hardware.input.InputManager +import android.os.Handler +import android.os.Looper +import android.view.InputDevice +import android.view.KeyEvent +import android.view.Window +import android.widget.EditText +import android.view.inputmethod.BaseInputConnection +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.bridge.ReactContext +import com.facebook.react.interfaces.ExtraWindowEventListener +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +internal object HardwareKeyboard { + var module: PaseoHardwareKeyboardModule? = null + @Volatile var overlayKeys: Set = emptySet() + private val windows = mutableMapOf() + val dispatcher = WindowKeyDispatcher() + private val consumedPresses = mutableSetOf() + + fun observe(window: Window) { + if (windows.containsKey(window)) return + val callback = KeyboardWindowCallback(window, window.callback) + windows[window] = callback + window.callback = callback + } + fun release(window: Window) { + val callback = windows.remove(window) ?: return + if (window.callback === callback) window.callback = callback.previous + } + fun releaseAll() { + windows.keys.toList().forEach { release(it) } + consumedPresses.clear() + } + + fun dispatch(event: KeyEvent, composing: Boolean): Boolean { + if (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD != 0) return false + if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false + val code = code(event.keyCode) + if (code.isEmpty()) return false + val textModifiers = event.metaState and KeyEvent.META_CTRL_MASK.inv() and KeyEvent.META_META_MASK.inv() + val character = event.getUnicodeChar(textModifiers) + val named = !code.startsWith("Key") && !code.startsWith("Digit") + val key = when { + code.startsWith("Control") -> "Control" + code.startsWith("Meta") -> "Meta" + code.startsWith("Shift") -> "Shift" + code.startsWith("Alt") -> "Alt" + named && (character == 0 || code == "Enter" || code == "Tab" || code == "Backspace") -> code + character != 0 -> hardwareCharacterKey(character) + else -> code + } + module?.emit(mapOf( + "key" to key, "code" to code, + "altKey" to event.isAltPressed, "ctrlKey" to event.isCtrlPressed, + "metaKey" to event.isMetaPressed, "shiftKey" to event.isShiftPressed, + "repeat" to (event.repeatCount > 0), "isComposing" to composing, + "phase" to if (event.action == KeyEvent.ACTION_UP) "up" else "down" + )) + if (event.action == KeyEvent.ACTION_UP) return consumedPresses.remove(event.keyCode) + val overlayOwnsKey = key != "Dead" && key in overlayKeys && !event.isCtrlPressed && !event.isMetaPressed && !event.isAltPressed && !composing + if (overlayOwnsKey) consumedPresses.add(event.keyCode) + return overlayOwnsKey + } + + private fun code(keyCode: Int): String = when (keyCode) { + in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> "Key" + ('A'.code + keyCode - KeyEvent.KEYCODE_A).toChar() + in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> "Digit" + (keyCode - KeyEvent.KEYCODE_0) + in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> "F" + (keyCode - KeyEvent.KEYCODE_F1 + 1) + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> "Enter" + KeyEvent.KEYCODE_ESCAPE -> "Escape" + KeyEvent.KEYCODE_DEL -> "Backspace" + KeyEvent.KEYCODE_FORWARD_DEL -> "Delete" + KeyEvent.KEYCODE_TAB -> "Tab" + KeyEvent.KEYCODE_SPACE -> "Space" + KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp" + KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown" + KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft" + KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight" + KeyEvent.KEYCODE_CTRL_LEFT -> "ControlLeft" + KeyEvent.KEYCODE_CTRL_RIGHT -> "ControlRight" + KeyEvent.KEYCODE_META_LEFT -> "MetaLeft" + KeyEvent.KEYCODE_META_RIGHT -> "MetaRight" + KeyEvent.KEYCODE_SHIFT_LEFT -> "ShiftLeft" + KeyEvent.KEYCODE_SHIFT_RIGHT -> "ShiftRight" + KeyEvent.KEYCODE_ALT_LEFT -> "AltLeft" + KeyEvent.KEYCODE_ALT_RIGHT -> "AltRight" + KeyEvent.KEYCODE_COMMA -> "Comma" + KeyEvent.KEYCODE_PERIOD -> "Period" + KeyEvent.KEYCODE_MINUS -> "Minus" + KeyEvent.KEYCODE_EQUALS -> "Equal" + KeyEvent.KEYCODE_LEFT_BRACKET -> "BracketLeft" + KeyEvent.KEYCODE_RIGHT_BRACKET -> "BracketRight" + KeyEvent.KEYCODE_BACKSLASH -> "Backslash" + KeyEvent.KEYCODE_SEMICOLON -> "Semicolon" + KeyEvent.KEYCODE_APOSTROPHE -> "Quote" + KeyEvent.KEYCODE_GRAVE -> "Backquote" + KeyEvent.KEYCODE_SLASH -> "Slash" + else -> "" + } +} + +private class KeyboardWindowCallback(private val window: Window, val previous: Window.Callback) : Window.Callback by previous { + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val editable = (window.currentFocus as? EditText)?.text + val composing = editable != null && BaseInputConnection.getComposingSpanStart(editable) >= 0 + return HardwareKeyboard.dispatcher.dispatch(event, { HardwareKeyboard.dispatch(event, composing) }, { previous.dispatchKeyEvent(event) }) + } +} + +class PaseoHardwareKeyboardModule : Module(), ExtraWindowEventListener, InputManager.InputDeviceListener, ComponentCallbacks { + private var inputManager: InputManager? = null + private var registeredReactContext: ReactContext? = null + private fun keyboardPresent(): Boolean { + val config = registeredReactContext?.resources?.configuration + return (config?.keyboard == Configuration.KEYBOARD_QWERTY && config.hardKeyboardHidden != Configuration.HARDKEYBOARDHIDDEN_YES) || + InputDevice.getDeviceIds().any { id -> InputDevice.getDevice(id)?.let { !it.isVirtual && it.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC } == true } + } + private fun presenceChanged() { sendEvent("onHardwareKeyboardPresenceChanged", emptyMap()) } + override fun onInputDeviceAdded(id: Int) { presenceChanged() } + override fun onInputDeviceRemoved(id: Int) { presenceChanged() } + override fun onInputDeviceChanged(id: Int) { presenceChanged() } + override fun onConfigurationChanged(config: Configuration) { presenceChanged() } + override fun onLowMemory() {} + override fun onExtraWindowCreate(window: Window) { HardwareKeyboard.observe(window) } + override fun onExtraWindowDestroy(window: Window) { HardwareKeyboard.release(window) } + override fun definition() = ModuleDefinition { + Name("PaseoHardwareKeyboard") + Events("onHardwareKeyboardShortcut", "onHardwareKeyboardPresenceChanged") + OnCreate { + HardwareKeyboard.module = this@PaseoHardwareKeyboardModule + val context = appContext.reactContext + require(context is ReactContext) { "Hardware keyboard observation requires a React context" } + registeredReactContext = context + context.registerComponentCallbacks(this@PaseoHardwareKeyboardModule) + inputManager = context.getSystemService(InputManager::class.java) + inputManager?.registerInputDeviceListener(this@PaseoHardwareKeyboardModule, Handler(Looper.getMainLooper())) + context.addExtraWindowEventListener(this@PaseoHardwareKeyboardModule) + UiThreadUtil.runOnUiThread { appContext.currentActivity?.window?.let { HardwareKeyboard.observe(it) } } + } + OnDestroy { + inputManager?.unregisterInputDeviceListener(this@PaseoHardwareKeyboardModule) + inputManager = null + registeredReactContext?.unregisterComponentCallbacks(this@PaseoHardwareKeyboardModule) + registeredReactContext?.removeExtraWindowEventListener(this@PaseoHardwareKeyboardModule) + registeredReactContext = null + if (HardwareKeyboard.module === this@PaseoHardwareKeyboardModule) { + HardwareKeyboard.module = null + HardwareKeyboard.overlayKeys = emptySet() + UiThreadUtil.runOnUiThread { if (HardwareKeyboard.module == null) HardwareKeyboard.releaseAll() } + } + } + Function("getHardwareKeyboardPresent") { keyboardPresent() } + Function("setOverlayKeys") { keys: List -> + HardwareKeyboard.overlayKeys = keys.filter { it in setOf("Escape", "Enter", "ArrowUp", "ArrowDown") }.toSet() + } + } + internal fun emit(event: Map) { sendEvent("onHardwareKeyboardShortcut", event) } +} diff --git a/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/PaseoHardwareKeyboardPackage.kt b/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/PaseoHardwareKeyboardPackage.kt new file mode 100644 index 0000000000..8084863010 --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/PaseoHardwareKeyboardPackage.kt @@ -0,0 +1,17 @@ +package sh.paseo.keyboard + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import expo.modules.core.interfaces.Package +import expo.modules.core.interfaces.ReactActivityLifecycleListener + +class PaseoHardwareKeyboardPackage : Package { + override fun createReactActivityLifecycleListeners(activityContext: Context): List = listOf( + object : ReactActivityLifecycleListener { + override fun onCreate(activity: Activity, savedInstanceState: Bundle?) { HardwareKeyboard.observe(activity.window) } + override fun onResume(activity: Activity) { HardwareKeyboard.observe(activity.window) } + override fun onDestroy(activity: Activity) { HardwareKeyboard.release(activity.window) } + } + ) +} diff --git a/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/WindowKeyDispatcher.kt b/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/WindowKeyDispatcher.kt new file mode 100644 index 0000000000..a94928b5e2 --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/android/src/main/java/sh/paseo/keyboard/WindowKeyDispatcher.kt @@ -0,0 +1,11 @@ +package sh.paseo.keyboard + +// A modal can forward the same physical event into the activity window. Keep +// observation outside that nested delivery, without retaining recycled KeyEvents. +internal class WindowKeyDispatcher { + private val delivering = java.util.Collections.newSetFromMap(java.util.IdentityHashMap()) + fun dispatch(event: Event, observe: () -> Boolean, deliver: () -> Boolean): Boolean { + if (!delivering.add(event)) return deliver() + return try { observe() || deliver() } finally { delivering.remove(event) } + } +} diff --git a/packages/app/modules/paseo-hardware-keyboard/android/src/test/java/sh/paseo/keyboard/HardwareCharacterKeyTest.kt b/packages/app/modules/paseo-hardware-keyboard/android/src/test/java/sh/paseo/keyboard/HardwareCharacterKeyTest.kt new file mode 100644 index 0000000000..d1a2b05ee7 --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/android/src/test/java/sh/paseo/keyboard/HardwareCharacterKeyTest.kt @@ -0,0 +1,11 @@ +package sh.paseo.keyboard +import org.junit.Assert.assertEquals +import org.junit.Test +class HardwareCharacterKeyTest { + @Test fun deadAccentUsesDomKeyWithoutThrowing() { + assertEquals("Dead", hardwareCharacterKey(Int.MIN_VALUE or 0xB4)) + } + @Test fun ordinaryUnicodeRemainsText() { + assertEquals("é", hardwareCharacterKey(0xE9)) + } +} diff --git a/packages/app/modules/paseo-hardware-keyboard/android/src/test/java/sh/paseo/keyboard/WindowKeyDispatcherTest.kt b/packages/app/modules/paseo-hardware-keyboard/android/src/test/java/sh/paseo/keyboard/WindowKeyDispatcherTest.kt new file mode 100644 index 0000000000..e6fd88607f --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/android/src/test/java/sh/paseo/keyboard/WindowKeyDispatcherTest.kt @@ -0,0 +1,27 @@ +package sh.paseo.keyboard + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class WindowKeyDispatcherTest { + @Test fun observesOnePressAcrossNestedWindows() { + val dispatcher = WindowKeyDispatcher() + val event = Any() + var observations = 0 + var deliveries = 0 + dispatcher.dispatch(event, { observations++; false }, { + dispatcher.dispatch(event, { observations++; false }, { deliveries++; false }) + }) + assertEquals(1, observations) + assertEquals(1, deliveries) + dispatcher.dispatch(event, { observations++; false }, { false }) + assertEquals(2, observations) + } + @Test fun consumedOverlayKeyDoesNotReachTheView() { + val dispatcher = WindowKeyDispatcher() + var deliveries = 0 + assertTrue(dispatcher.dispatch(Any(), { true }, { deliveries++; false })) + assertEquals(0, deliveries) + } +} diff --git a/packages/app/modules/paseo-hardware-keyboard/app.plugin.js b/packages/app/modules/paseo-hardware-keyboard/app.plugin.js new file mode 100644 index 0000000000..0c32c0506d --- /dev/null +++ b/packages/app/modules/paseo-hardware-keyboard/app.plugin.js @@ -0,0 +1,29 @@ +const { withAppDelegate } = require("expo/config-plugins"); +const SCENE_START = "super.scene(scene, willConnectTo: session, options: connectionOptions)"; + +function configureHardwareKeyboardAppDelegate(source) { + source = source.replace( + /^import PaseoHardwareKeyboard$/m, + "internal import PaseoHardwareKeyboard", + ); + if (source.includes("PaseoHardwareKeyboardWindow.observing(window)")) return source; + if (!source.includes(SCENE_START)) { + throw new Error("Hardware keyboard observation requires the app's Expo scene delegate"); + } + const installation = `${SCENE_START} + if let window { + let observedWindow = PaseoHardwareKeyboardWindow.observing(window) + self.window = observedWindow + (UIApplication.shared.delegate as? ExpoReactNativeFactoryProvider)?.window = observedWindow + }`; + return "internal import PaseoHardwareKeyboard\n" + source.replace(SCENE_START, installation); +} +module.exports = function withHardwareKeyboardWindow(config) { + return withAppDelegate(config, (modConfig) => { + modConfig.modResults.contents = configureHardwareKeyboardAppDelegate( + modConfig.modResults.contents, + ); + return modConfig; + }); +}; +module.exports.configureHardwareKeyboardAppDelegate = configureHardwareKeyboardAppDelegate; diff --git a/packages/app/modules/paseo-hardware-keyboard/expo-module.config.json b/packages/app/modules/paseo-hardware-keyboard/expo-module.config.json index 57759b1d87..12d1b54eb8 100644 --- a/packages/app/modules/paseo-hardware-keyboard/expo-module.config.json +++ b/packages/app/modules/paseo-hardware-keyboard/expo-module.config.json @@ -1,7 +1,9 @@ { - "platforms": ["ios"], + "platforms": ["ios", "android"], "ios": { - "modules": ["PaseoHardwareKeyboardModule"], - "reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"] + "modules": ["PaseoHardwareKeyboardModule"] + }, + "android": { + "modules": ["sh.paseo.keyboard.PaseoHardwareKeyboardModule"] } } diff --git a/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboardModule.swift b/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboardModule.swift index cd325748e3..446e1d98c4 100644 --- a/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboardModule.swift +++ b/packages/app/modules/paseo-hardware-keyboard/ios/PaseoHardwareKeyboardModule.swift @@ -1,53 +1,159 @@ import ExpoModulesCore import UIKit +import GameController private let hardwareSubmitEventName = "onHardwareKeyboardSubmit" +private let hardwareShortcutEventName = "onHardwareKeyboardShortcut" private weak var activeModule: PaseoHardwareKeyboardModule? private var isHardwareSubmitEnabled = false +private var overlayKeys: Set = [] -@objc -public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler { - public override func createRootViewController() -> UIViewController? { - return PaseoHardwareKeyboardRootViewController() +/// UIKit spells non-character keys as sentinel input strings rather than as the +/// character they would type. JS sends the DOM `code` and the mapping lives +/// here, so the constants stay in the one place that owns UIKit vocabulary. +private let namedKeyInputs: [String: String] = [ + "Escape": UIKeyCommand.inputEscape, + "ArrowUp": UIKeyCommand.inputUpArrow, + "ArrowDown": UIKeyCommand.inputDownArrow, + "Enter": "\r", +] + +/// Whether the first responder is mid-IME-composition. +/// +/// A committing key has to stay out of the way while a CJK candidate is being +/// chosen: Enter confirms the candidate, and consuming it as a key command +/// would submit the half-composed text instead. +private func isComposingText() -> Bool { + guard let responder = UIResponder.paseoCurrentFirstResponder as? UITextInput else { + return false } + return responder.markedTextRange != nil } public class PaseoHardwareKeyboardModule: Module { + private var keyboardObservers: [NSObjectProtocol] = [] public func definition() -> ModuleDefinition { Name("PaseoHardwareKeyboard") - Events(hardwareSubmitEventName) + Events(hardwareSubmitEventName, hardwareShortcutEventName, "onHardwareKeyboardPresenceChanged") OnCreate { activeModule = self + for name in [Notification.Name.GCKeyboardDidConnect, Notification.Name.GCKeyboardDidDisconnect] { + self.keyboardObservers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.sendEvent("onHardwareKeyboardPresenceChanged", [:]) + }) + } } + Function("getHardwareKeyboardPresent") { GCKeyboard.coalesced != nil } + Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in DispatchQueue.main.async { isHardwareSubmitEnabled = enabled } } + Function("setOverlayKeys") { (keys: [String]) in + DispatchQueue.main.async { + overlayKeys = Set(keys).intersection(["Enter", "ArrowUp", "ArrowDown"]) + UIMenuSystem.main.setNeedsRebuild() + } + } + OnDestroy { + self.keyboardObservers.forEach(NotificationCenter.default.removeObserver) + self.keyboardObservers = [] if activeModule === self { activeModule = nil + isHardwareSubmitEnabled = false + overlayKeys = [] } - isHardwareSubmitEnabled = false } } fileprivate func emitHardwareKeyboardSubmit() { sendEvent(hardwareSubmitEventName, [:]) } + + fileprivate func emitHardwareKeyboardPress(_ press: UIPress, isRepeat: Bool) { + guard let key = press.key else { return } + let code = hardwareKeyCode(key) + let characters = key.characters + let named = namedKeyInputs.first { $0.value == characters }?.key + let keyName: String + if code.hasPrefix("Meta") { keyName = "Meta" } + else if code.hasPrefix("Control") { keyName = "Control" } + else if code.hasPrefix("Shift") { keyName = "Shift" } + else if code.hasPrefix("Alt") { keyName = "Alt" } + else { keyName = named ?? (characters.isEmpty ? code : characters) } + guard !code.isEmpty else { return } + sendEvent(hardwareShortcutEventName, [ + "key": keyName, + "code": code, + "altKey": key.modifierFlags.contains(.alternate), + "ctrlKey": key.modifierFlags.contains(.control), + "metaKey": key.modifierFlags.contains(.command), + "shiftKey": key.modifierFlags.contains(.shift), + "repeat": isRepeat, + "isComposing": isComposingText(), + "phase": press.phase == .ended || press.phase == .cancelled ? "up" : "down", + ]) + } } -private final class PaseoHardwareKeyboardRootViewController: UIViewController { - override var keyCommands: [UIKeyCommand]? { - guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else { - return super.keyCommands - } +public final class PaseoHardwareKeyboardWindow: UIWindow { + private var pressedCodes: Set = [] + public static func observing(_ original: UIWindow) -> UIWindow { + if original is PaseoHardwareKeyboardWindow { return original } + guard let scene = original.windowScene else { return original } + let observed = PaseoHardwareKeyboardWindow(windowScene: scene) + observed.windowLevel = original.windowLevel + observed.backgroundColor = original.backgroundColor + let controller = original.rootViewController + original.rootViewController = nil + original.isHidden = true + observed.rootViewController = controller + observed.makeKeyAndVisible() + return observed + } + public override func sendEvent(_ event: UIEvent) { + if let event = event as? UIPressesEvent { + for press in event.allPresses where press.phase == .began || press.phase == .ended || press.phase == .cancelled { + guard let key = press.key else { continue } + let raw = Int(key.keyCode.rawValue) + let isRepeat = press.phase == .began && pressedCodes.contains(raw) + if press.phase == .began { pressedCodes.insert(raw) } + else { pressedCodes.remove(raw) } + activeModule?.emitHardwareKeyboardPress(press, isRepeat: isRepeat) + } + } + super.sendEvent(event) + } + public override var keyCommands: [UIKeyCommand]? { + var commands = super.keyCommands ?? [] + if isHardwareSubmitEnabled && !isPlainReturnClaimed && !isComposingText() + && UIDevice.current.userInterfaceIdiom == .pad + { + commands.append(makeSubmitCommand()) + } + if !isComposingText() { commands.append(contentsOf: overlayKeys.compactMap(makeShortcutCommand)) } + return commands + } + + /// Whether something registered has already asked for an unmodified Return. + /// + /// The submit command is registered for as long as the composer holds focus, + /// which includes while a picker is open over it. Registering both would put + /// two commands on one press, and the composer would win a press the picker + /// asked for — Enter would create the workspace instead of choosing a project. + private var isPlainReturnClaimed: Bool { + overlayKeys.contains("Enter") + } + + private func makeSubmitCommand() -> UIKeyCommand { let command = UIKeyCommand( input: "\r", modifierFlags: [], @@ -56,7 +162,14 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController { if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true } - return (super.keyCommands ?? []) + [command] + return command + } + + private func makeShortcutCommand(_ key: String) -> UIKeyCommand? { + guard let input = namedKeyInputs[key] else { return nil } + let command = UIKeyCommand(input: input, modifierFlags: [], action: #selector(handleHardwareKeyboardShortcut(_:))) + if #available(iOS 15.0, *) { command.wantsPriorityOverSystemBehavior = true } + return command } @objc @@ -67,6 +180,9 @@ private final class PaseoHardwareKeyboardRootViewController: UIViewController { activeModule?.emitHardwareKeyboardSubmit() } + @objc + private func handleHardwareKeyboardShortcut(_ sender: UIKeyCommand) {} + private func canSubmitCurrentTextInput() -> Bool { guard let responder = UIResponder.paseoCurrentFirstResponder else { return false @@ -97,3 +213,25 @@ private extension UIResponder { UIResponder.currentFirstResponder = self } } + + +private func hardwareKeyCode(_ key: UIKey) -> String { + let raw = key.keyCode.rawValue + if raw >= 4 && raw <= 29 { + let letter = UnicodeScalar(Int(raw) + 61)! + return "Key" + String(letter) + } + if raw >= 30 && raw <= 38 { return "Digit" + String(raw - 29) } + if raw == 39 { return "Digit0" } + let codes: [Int: String] = [ + 40: "Enter", 41: "Escape", 42: "Backspace", 43: "Tab", 44: "Space", + 45: "Minus", 46: "Equal", 47: "BracketLeft", 48: "BracketRight", + 49: "Backslash", 51: "Semicolon", 52: "Quote", 53: "Backquote", + 54: "Comma", 55: "Period", 56: "Slash", 76: "Delete", 79: "ArrowRight", + 80: "ArrowLeft", 81: "ArrowDown", 82: "ArrowUp", 224: "ControlLeft", + 225: "ShiftLeft", 226: "AltLeft", 227: "MetaLeft", 228: "ControlRight", + 229: "ShiftRight", 230: "AltRight", 231: "MetaRight", + ] + if raw >= 58 && raw <= 69 { return "F" + String(raw - 57) } + return codes[Int(raw)] ?? "" +} diff --git a/packages/app/package.json b/packages/app/package.json index a6d039257f..42d791fbf4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -40,7 +40,7 @@ "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios", "build:apk": "npm --prefix ../.. run build:apk", "build:apk:arm64": "npm --prefix ../.. run build:apk:arm64", - "test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false" + "test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :paseo-hardware-keyboard:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false" }, "dependencies": { "@codemirror/commands": "6.10.4", diff --git a/packages/app/src/agent-stream/chat-find/index.web.tsx b/packages/app/src/agent-stream/chat-find/index.web.tsx index e5798286b3..eadcec4e22 100644 --- a/packages/app/src/agent-stream/chat-find/index.web.tsx +++ b/packages/app/src/agent-stream/chat-find/index.web.tsx @@ -15,7 +15,7 @@ import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } f import { Button } from "@/components/ui/button"; import { usePaneFocus } from "@/panels/pane-context"; import { useRetainedPanelActive } from "@/components/retained-panel"; -import { hasActiveWebOverlay } from "@/lib/overlay-root"; +import { hasActiveOverlay } from "@/keyboard"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan"; @@ -104,7 +104,7 @@ export function ChatFind({ useEffect(() => { if (!active || !isInteractive) return; const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event)) + if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event)) return; if (isFindShortcut(event, findShortcutPlatform())) { event.preventDefault(); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a20362d815..483de1c27d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,3 +1,4 @@ +import { useHardwareKeyboardPresent } from "@/keyboard"; import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -90,7 +91,7 @@ import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-acti import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action"; import { useLatchedBoolean } from "@/hooks/use-latched-boolean"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; -import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useKeyboardShortcuts } from "@/keyboard/use-shortcuts"; import { KeyboardShiftProvider } from "@/keyboard/shift"; import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock"; import { useOpenProject } from "@/hooks/use-open-project"; @@ -549,8 +550,12 @@ function SidebarChrome({ const isMobileActive = useIsMobilePanelActive("agent-list"); const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen); const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen); + const hardwareKeyboardPresent = useHardwareKeyboardPresent(); return ( - + {mounted ? : null} diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 309564bcea..1c20f0725d 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -1,3 +1,4 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { Modal } from "@/components/ui/modal"; import equal from "fast-deep-equal"; import { recordRenderProfileReasons } from "@/utils/render-profiler"; @@ -52,11 +53,7 @@ import { useProjects } from "@/hooks/use-projects"; import { useTimeAgo } from "@/hooks/use-time-ago"; import { createProjectIconTarget } from "@/projects/icon-target"; import { useProjectIcons } from "@/projects/icons"; -import { - OverlayLayerProvider, - useGlobalWebOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore, @@ -763,6 +760,10 @@ function SectionRow({ row }: { row: Extract state.key(key), - [state], - ); const submit = useCallback(() => state.key("Enter"), [state]); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (!state.key(event.key)) return false; event.preventDefault(); return true; }, [state], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && state.open && !showBottomSheet, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"], + active: state.open, layer: modalLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, }); if (showBottomSheet) { @@ -911,45 +909,46 @@ export function CommandCenter() { keyboardBlurBehavior="restore" accessible={false} > - - {state.scope === "files" ? ( - - ) : null} - - - - {fileSearchError} - + + + {state.scope === "files" ? ( + + ) : null} + + + + {fileSearchError} + + ); } if (!state.open) return null; return ( - + - + {state.scope === "files" ? ( diff --git a/packages/app/src/command-center/root-registration.tsx b/packages/app/src/command-center/root-registration.tsx index bb39882630..9978aed6e1 100644 --- a/packages/app/src/command-center/root-registration.tsx +++ b/packages/app/src/command-center/root-registration.tsx @@ -1,3 +1,4 @@ +import { usesDesktopBindings } from "@/keyboard"; import { useMemo } from "react"; import { router, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; @@ -15,12 +16,12 @@ import { Settings, } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; -import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useImportSession } from "@/hooks/use-import-session"; import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; -import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { useHardwareKeyboardPresent } from "@/keyboard"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { usePanelStore } from "@/stores/panel-store"; @@ -111,7 +112,7 @@ export function CommandCenterRootActions() { const keyboardActionDispatcher = useKeyboardActionDispatcher(); const { t } = useTranslation(); const { overrides } = useKeyboardShortcutOverrides(); - const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const shortcutsAvailable = useHardwareKeyboardPresent(); const openAddProject = useOpenAddProject(); const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const settingsRoute = useMemo(() => buildSettingsRoute(), []); @@ -128,7 +129,7 @@ export function CommandCenterRootActions() { const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList); const toggleAgentList = isCompact ? toggleMobileAgentList : toggleDesktopAgentList; const shortcutPlatform = useMemo( - () => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }), + () => ({ isMac: getShortcutOs() === "mac", usesDesktopBindings }), [], ); const actions = useMemo(() => { diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index cd9d008c55..41416d30eb 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -1,3 +1,4 @@ +import { usesDesktopBindings } from "@/keyboard"; import { useCallback, useMemo, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { @@ -105,7 +106,7 @@ function staticIcon(element: ReactElement | undefined): CommandCenterIcon | unde } function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceCommandCenterShortcuts { - const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() }; + const platform = { isMac: getShortcutOs() === "mac", usesDesktopBindings }; return resolveWorkspaceCommandCenterShortcuts({ overrides, platform }); } diff --git a/packages/app/src/command-center/workspace-shortcuts.test.ts b/packages/app/src/command-center/workspace-shortcuts.test.ts index bfffce9577..bb8d66c983 100644 --- a/packages/app/src/command-center/workspace-shortcuts.test.ts +++ b/packages/app/src/command-center/workspace-shortcuts.test.ts @@ -6,7 +6,7 @@ describe("resolveWorkspaceCommandCenterShortcuts", () => { expect( resolveWorkspaceCommandCenterShortcuts({ overrides: {}, - platform: { isMac: true, isDesktop: true }, + platform: { isMac: true, usesDesktopBindings: true }, }).newAgent, ).toEqual([["mod", "shift", "A"]]); }); diff --git a/packages/app/src/command-center/workspace-shortcuts.ts b/packages/app/src/command-center/workspace-shortcuts.ts index c51a1fcc60..98be2986cd 100644 --- a/packages/app/src/command-center/workspace-shortcuts.ts +++ b/packages/app/src/command-center/workspace-shortcuts.ts @@ -6,7 +6,7 @@ import type { WorkspaceCommandCenterShortcuts } from "./workspace-contributions" interface ResolveWorkspaceCommandCenterShortcutsInput { overrides: ShortcutOverrides; - platform: { isMac: boolean; isDesktop: boolean }; + platform: { isMac: boolean; usesDesktopBindings: boolean }; } export function resolveWorkspaceCommandCenterShortcuts({ diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 33e61f6ec6..bff7fe34f6 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,3 +1,4 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ComponentProps, ReactNode } from "react"; import { createPortal } from "react-dom"; @@ -6,12 +7,7 @@ import { Keyboard, Pressable, Text, View } from "react-native"; import type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { - getOverlayRoot, - OverlayLayerProvider, - useGlobalWebOverlayLayer, - useWebOverlayRegistration, -} from "../lib/overlay-root"; +import { getOverlayRoot, OverlayLayerProvider, useGlobalOverlayLayer } from "../lib/overlay-root"; import { BottomSheetHandle, useBottomSheetGestureHandlers, @@ -655,7 +651,7 @@ export function AdaptiveModalSheet({ }); const [shouldRenderWeb, setShouldRenderWeb] = useState(visible); const [isWebClosing, setIsWebClosing] = useState(false); - const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && !isMobile && shouldRenderWeb); + const modalLayer = useGlobalOverlayLayer("modal", visible || shouldRenderWeb); const handleDismiss = useCallback(() => { handleSheetDismiss(); onDismiss?.(); @@ -683,8 +679,8 @@ export function AdaptiveModalSheet({ [isWebClosing, modalLayer], ); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (event.key !== "Escape") return false; event.preventDefault(); event.stopPropagation(); @@ -693,10 +689,11 @@ export function AdaptiveModalSheet({ }, [onClose], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && !isMobile && visible, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape"], + active: visible, layer: modalLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, }); useEffect(() => { @@ -735,18 +732,20 @@ export function AdaptiveModalSheet({ accessible={false} presentation={presentation} > - - {children} - + + + {children} + + ); } @@ -790,13 +789,7 @@ export function AdaptiveModalSheet({ style={ABSOLUTE_FILL_STYLE} onPress={onClose} /> - + {cardInner} diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index bcd9bd541a..038e026c76 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,4 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { useAddHost } from "@/hosts/add-host"; import { Modal } from "@/components/ui/modal"; import { usePanelStore } from "@/stores/panel-store"; @@ -69,19 +70,15 @@ import { } from "@/components/project-picker-options"; import { Shortcut } from "@/components/ui/shortcut"; import { useRevealActiveItem } from "@/components/ui/scroll-reveal"; -import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { useHardwareKeyboardPresent } from "@/keyboard"; import { getIsElectronRuntime } from "@/constants/layout"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; import { pickDirectory } from "@/desktop/pick-directory"; import { useFetchQuery } from "@/data/query"; import { getOpenProjectFailureReason, registerProjectDescriptor } from "@/hooks/open-project"; import { useIsLocalDaemon, useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { useCloneGithubProject, useOpenProject } from "@/hooks/use-open-project"; -import { - OverlayLayerProvider, - useGlobalWebOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root"; import { useHosts, useHostRuntimeClient, @@ -302,7 +299,7 @@ function FlowRow({ } function FlowHint({ keys, action }: { keys: string[]; action: string }) { - const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const shortcutsAvailable = useHardwareKeyboardPresent(); if (!shortcutsAvailable) return null; return ( @@ -460,13 +457,10 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { }); const handleBack = useCallback(() => { - setState((current) => { - const previous = backAddProjectPage(current); - if (previous) return previous; - onClose(); - return current; - }); - }, [onClose]); + const previous = backAddProjectPage(state); + if (previous) setState(previous); + else onClose(); + }, [onClose, state]); const openNewWorkspaceForProject = useCallback( (serverId: string, project: WorkspaceProjectDescriptorPayload) => { @@ -815,30 +809,22 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { [activeIndex, handleBack, rows, submitActive], ); - const modalLayer = useGlobalWebOverlayLayer("modal", isWeb); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const modalLayer = useGlobalOverlayLayer("modal", true); + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (!handleKey(event.key)) return false; event.preventDefault(); return true; }, [handleKey], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"], + active: true, layer: modalLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, }); - const handleNativeKeyPress = useCallback( - ({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => { - if (key === "ArrowDown" || key === "ArrowUp" || key === "Escape") { - handleKey(key); - } - }, - [handleKey], - ); - const handleInputChange = useCallback((value: string) => { setState((current) => currentAddProjectPage(current).kind === "new-directory-name" @@ -873,7 +859,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { - {page.kind === "method" && isNative ? ( - // Native hardware-keyboard events need a focused responder even without a visible field. - - ) : null} {page.kind !== "method" ? ( ); - return createElement(OverlayLayerProvider, { layer: isWeb ? modalLayer : 0 }, modal); + return createElement(OverlayLayerProvider, { layer: modalLayer }, modal); } const styles = StyleSheet.create((theme) => ({ @@ -1070,12 +1038,6 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[1], outlineStyle: "none", } as object, - keyboardCapture: { - position: "absolute", - width: 1, - height: 1, - opacity: 0, - }, results: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, resultsContent: { paddingVertical: theme.spacing[2] }, row: { diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx index 08c6042bf8..29ad351976 100644 --- a/packages/app/src/components/attachment-lightbox.tsx +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -1,3 +1,4 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { Modal } from "@/components/ui/modal"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { Pressable, Text, View } from "react-native"; @@ -8,12 +9,12 @@ import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; -import { isNative, isWeb } from "@/constants/platform"; +import { isNative } from "@/constants/platform"; import { SPACING } from "@/styles/theme"; import { WindowChromeRootRegion } from "@/utils/desktop-window"; import { ZoomableImage } from "@/components/zoomable-viewport/image"; import type { ViewportSize } from "@/components/zoomable-viewport/geometry"; -import { useGlobalWebOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root"; +import { useGlobalOverlayLayer } from "@/lib/overlay-root"; export type ImageLightboxSource = | { type: "attachment"; metadata: AttachmentMetadata } @@ -34,14 +35,14 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) const url = source?.type === "uri" ? source.uri : attachmentUrl; const contentSize = source?.type === "uri" ? source.contentSize : undefined; const [errored, setErrored] = useState(false); - const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && source !== null); + const modalLayer = useGlobalOverlayLayer("modal", source !== null); useEffect(() => { setErrored(false); }, [metadata?.id, url]); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (event.key !== "Escape") return false; event.preventDefault(); event.stopPropagation(); @@ -50,10 +51,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) }, [onClose], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && source !== null, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape"], + active: source !== null, layer: modalLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, }); const actions = useMemo( @@ -83,7 +85,7 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) image was opened (inside a sheet tab, the bottom one is already spent). */} - + buildEffectiveBindings(overrides), [overrides]); const sections = useMemo( - () => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }, bindings), - [bindings, isDesktopApp, isMac], + () => buildKeyboardShortcutHelpSections({ isMac, usesDesktopBindings }, bindings), + [bindings, isMac], ); const visibleSections = useMemo( () => filterShortcutHelpSections({ sections, query, translate: t, shortcutOs }), diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index 0eefa77468..3ba3011517 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -18,7 +18,7 @@ import { DensityIcon } from "@/components/ui/density-icon"; import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAppDiagnosticStore } from "@/diagnostics/store"; -import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { useHardwareKeyboardPresent } from "@/keyboard"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore } from "@/stores/session-store"; @@ -79,7 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) { export function SidebarHelpMenu() { const { t } = useTranslation(); - const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const shortcutsAvailable = useHardwareKeyboardPresent(); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); const [open, setOpen] = useState(false); diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index cfa69428f8..9339e9bc95 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -50,13 +50,16 @@ const SidebarModelContext = createContext(null); export function SidebarModelProvider({ active, + keyboardNavigationActive = false, children, }: { active?: boolean; + keyboardNavigationActive?: boolean; children: ReactNode; }) { const { t } = useTranslation(); - const list = useSidebarWorkspacesList({ enabled: active }); + const modelActive = active !== false || keyboardNavigationActive; + const list = useSidebarWorkspacesList({ enabled: modelActive }); const showBackground = useSidebarViewStore((state) => state.showBackground); const groupMode = useSidebarViewStore((state) => state.groupMode); const labelFilter = useSidebarViewStore((state) => state.labelFilter); @@ -98,10 +101,11 @@ export function SidebarModelProvider({ // anything; the label filter reads `labels`, which only exists on an entry. Hydration opens a // live session-store subscription over every workspace on every visible host, so widening this // for a filter that does not need it costs a retained-but-inactive sidebar real work. + // Hardware keyboard navigation needs the same filtered targets while the sidebar is hidden. const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter; const workspaceEntriesByKey = useSidebarWorkspaceEntries( list.workspacePlacements, - active !== false || needsWorkspaceEntries, + modelActive || needsWorkspaceEntries, ); const filteredWorkspaceEntriesByKey = useMemo(() => { const byProject = filterWorkspacesByProjects({ diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 4c5e74ef6d..a841d70fa6 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -84,6 +84,7 @@ import { } from "./terminal-resize-debouncer"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; +import { useKeyboardFocusSurface } from "@/keyboard"; import { applyTerminalRendererReadyChange, resolveTerminalStreamTarget, @@ -458,6 +459,8 @@ export function TerminalPane({ } }, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]); + useKeyboardFocusSurface("terminal", isTerminalPresented && isPaneFocused && terminalId != null); + useEffect(() => { const canRequest = canRequestFocusClaim({ isWorkspaceFocused: isTerminalPresented, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index adcd91afcb..5a2da0a365 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,3 +1,4 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { Modal } from "@/components/ui/modal"; import { useCallback, @@ -62,12 +63,7 @@ import { } from "@/components/adaptive-modal-sheet"; import { FloatingSurface } from "@/components/ui/floating"; import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss"; -import { - getOverlayRoot, - OverlayLayerProvider, - useOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root"; import { buildDesktopFrameStyle } from "./combobox-frame-style"; export { buildDesktopFrameStyle } from "./combobox-frame-style"; @@ -526,7 +522,6 @@ type DesktopKey = "ArrowDown" | "ArrowUp" | "Enter" | "Escape"; interface DesktopKeyHandlerInput { isOpen: boolean; - isMobile: boolean; orderedVisibleOptions: ComboboxOption[]; activeIndex: number; setActiveIndex: React.Dispatch>; @@ -871,11 +866,12 @@ function isDesktopKey(key: string): key is DesktopKey { function dispatchDesktopKey( input: DesktopKeyHandlerInput, key: DesktopKey, - event?: KeyboardEvent, + event?: KeyboardInputEvent, ): boolean { if (!input.isOpen) return false; - if (!IS_WEB && input.isMobile) return false; - + // A native mobile bail used to live here because native had no key source, so + // nothing could call this. It has one now, and the sheet highlights an active + // row like the popover does, so a hardware keyboard drives both. if (key === "ArrowDown" || key === "ArrowUp") { event?.preventDefault(); handleDesktopArrowKey(input, key); @@ -912,6 +908,7 @@ function resolveInitialActiveIndex( } interface MobileBodyProps { + overlayLayer: number; isOpen: boolean; snapPoints: string[]; handleIndicatorStyle: { backgroundColor: string }; @@ -990,45 +987,47 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - + + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + + ); } @@ -1062,7 +1061,8 @@ interface DesktopBodyProps { overlayLayer: number; isOpen: boolean; handleClose: () => void; - handleDesktopKey: (key: DesktopKey, event?: KeyboardEvent) => boolean; + setOverlayScope: (node: unknown) => void; + handleDesktopKey: (key: DesktopKey, event?: KeyboardInputEvent) => boolean; refs: ReturnType["refs"]; shouldUseDesktopFade: boolean; desktopFrameStyle: StyleProp; @@ -1189,25 +1189,13 @@ function DesktopComboboxOptionsBody(props: { function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null { const active = useRetainedPanelActive(); - const handleDesktopKey = props.handleDesktopKey; - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { - if (!isDesktopKey(event.key)) return false; - return handleDesktopKey(event.key, event); - }, - [handleDesktopKey], - ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && props.isOpen, - layer: props.overlayLayer, - onKeyDown: handleWebOverlayKeyDown, - }); + const setOverlayScope = props.setOverlayScope; const setFloatingRef = useCallback( (node: View | null) => { props.refs.setFloating(node); - setWebOverlayScope(node); + setOverlayScope(node); }, - [props.refs, setWebOverlayScope], + [props.refs, setOverlayScope], ); // Remove the portal before a retained chat can freeze its closing update. @@ -1531,11 +1519,10 @@ export function Combobox({ ); const handleDesktopKey = useCallback( - (key: DesktopKey, event?: KeyboardEvent) => { + (key: DesktopKey, event?: KeyboardInputEvent) => { return dispatchDesktopKey( { isOpen, - isMobile, orderedVisibleOptions, activeIndex, setActiveIndex, @@ -1546,7 +1533,7 @@ export function Combobox({ event, ); }, - [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions], + [activeIndex, handleClose, handleSelect, isOpen, orderedVisibleOptions], ); useDismissKeyboardOnOpen(isOpen, isMobile); @@ -1583,12 +1570,20 @@ export function Combobox({ [], ); + const setOverlayScope = useOverlayRegistration({ + active: isOpen, + layer: floatingLayer, + keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"], + onKeyDown: (event) => (isDesktopKey(event.key) ? handleDesktopKey(event.key, event) : false), + }); + const effectiveSearchPlaceholder = searchPlaceholder ?? resolvedPlaceholder; const hasChildren = Boolean(children); if (isMobile) { return ( { + (event: KeyboardInputEvent): boolean => { if (!isWeb) return false; const trigger = triggerRef.current as unknown as HTMLElement | null; if (!trigger) return false; @@ -139,7 +136,7 @@ function WebHoverCard({ return true; } else if ( focusInside() && - event.target === trigger && + document.activeElement === trigger && ["ArrowDown", "Enter", " "].includes(event.key) ) { event.preventDefault(); @@ -158,7 +155,8 @@ function WebHoverCard({ [clearGraceTimer, focusInside, open, openNow], ); - const setOverlayScope = useWebOverlayRegistration({ + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape"], active: !disabled && open, layer, onKeyDown: keyPressed, diff --git a/packages/app/src/components/ui/menu/keyboard-navigation.ts b/packages/app/src/components/ui/menu/keyboard-navigation.ts new file mode 100644 index 0000000000..ca0d854436 --- /dev/null +++ b/packages/app/src/components/ui/menu/keyboard-navigation.ts @@ -0,0 +1,4 @@ +import type { KeyboardInputEvent } from "@/keyboard"; +export function navigateMenuKeyboard(_event: KeyboardInputEvent): boolean { + return false; +} diff --git a/packages/app/src/components/ui/menu/keyboard-navigation.web.ts b/packages/app/src/components/ui/menu/keyboard-navigation.web.ts new file mode 100644 index 0000000000..ba2dd1f1e5 --- /dev/null +++ b/packages/app/src/components/ui/menu/keyboard-navigation.web.ts @@ -0,0 +1,37 @@ +import type { KeyboardInputEvent } from "@/keyboard"; +export function navigateMenuKeyboard(event: KeyboardInputEvent): boolean { + const target = document.activeElement; + const surface = target?.closest('[data-menu-surface="true"]'); + if (!surface) return false; + const items = Array.from( + surface.querySelectorAll( + '[data-menu-item="true"]:not([data-menu-disabled="true"])', + ), + ); + if (items.length === 0) return false; + const currentIndex = items.findIndex((item) => item === document.activeElement); + let nextIndex: number | null = null; + if (event.key === "ArrowDown") { + if (currentIndex < 0) nextIndex = 0; + else nextIndex = (currentIndex + 1) % items.length; + } + if (event.key === "ArrowUp") { + if (currentIndex < 0) nextIndex = items.length - 1; + else nextIndex = (currentIndex - 1 + items.length) % items.length; + } + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = items.length - 1; + if (nextIndex !== null) { + event.preventDefault(); + event.stopPropagation(); + items[nextIndex]?.focus(); + return true; + } + if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) { + event.preventDefault(); + event.stopPropagation(); + items[currentIndex]?.click(); + return true; + } + return false; +} diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 3979c5b633..5bfcd9b5bf 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -1,3 +1,5 @@ +import { navigateMenuKeyboard } from "./keyboard-navigation"; +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { Modal } from "@/components/ui/modal"; import { useCallback, @@ -24,12 +26,7 @@ import { StyleSheet } from "react-native-unistyles"; import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating"; import { isWeb } from "@/constants/platform"; import type { KeyboardFocusScope } from "@/keyboard/actions"; -import { - getOverlayRoot, - OverlayLayerProvider, - useOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root"; import { computePosition, getTransformOrigin, @@ -405,8 +402,8 @@ export function MenuOverlay({ }): ReactElement | null { const floatingLayer = useOverlayLayer("floating"); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); @@ -414,44 +411,15 @@ export function MenuOverlay({ return true; } - const target = event.target instanceof Element ? event.target : null; - const surface = target?.closest('[data-menu-surface="true"]'); - if (!surface) return false; - const items = Array.from( - surface.querySelectorAll( - '[data-menu-item="true"]:not([data-menu-disabled="true"])', - ), - ); - if (items.length === 0) return false; - const currentIndex = items.findIndex((item) => item === document.activeElement); - let nextIndex: number | null = null; - if (event.key === "ArrowDown") - nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length; - if (event.key === "ArrowUp") - nextIndex = - currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length; - if (event.key === "Home") nextIndex = 0; - if (event.key === "End") nextIndex = items.length - 1; - if (nextIndex !== null) { - event.preventDefault(); - event.stopPropagation(); - items[nextIndex]?.focus(); - return true; - } - if ((event.key === "Enter" || event.key === " ") && currentIndex >= 0) { - event.preventDefault(); - event.stopPropagation(); - items[currentIndex]?.click(); - return true; - } - return false; + return navigateMenuKeyboard(event); }, [onClose], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && visible, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape"], + active: visible, layer: floatingLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, restoreFocusRef, }); @@ -463,7 +431,7 @@ export function MenuOverlay({ {...{ onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(), }} - ref={setWebOverlayScope} + ref={setOverlayScope} collapsable={false} style={[ styles.overlay, diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index 6be0cf353e..3734902b8d 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -1,7 +1,7 @@ import React, { useMemo, type ReactElement } from "react"; import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { useHardwareKeyboardPresent } from "@/keyboard"; import { normalizeDisplayChord } from "@/components/ui/normalize-display-chord"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; @@ -17,7 +17,7 @@ export function Shortcut({ style?: StyleProp; textStyle?: StyleProp; }): ReactElement | null { - const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const shortcutsAvailable = useHardwareKeyboardPresent(); const displayChord = normalizeDisplayChord(chord, keys); const shortcutOs = getShortcutOs(); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 84e9141b6a..9f72789430 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -125,6 +125,20 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { }); }); + it("keeps keyboard navigation populated when the sidebar starts hidden", async () => { + await act(async () => { + root?.render( + + + , + ); + }); + expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([ + { serverId: "srv", workspaceId: "ws-1" }, + { serverId: "srv", workspaceId: "ws-2" }, + ]); + }); + it("publishes workspace shortcut targets without rendering the sidebar", async () => { await act(async () => { root?.render( diff --git a/packages/app/src/composer/input/fullscreen-exit.browser.test.tsx b/packages/app/src/composer/input/fullscreen-exit.browser.test.tsx new file mode 100644 index 0000000000..c9c62d85be --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.browser.test.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vitest"; +import { dispatchTopOverlayKeyDown } from "@/keyboard"; +import { useComposerFullscreenExit } from "./fullscreen-exit"; + +it("the native entry claims Escape until fullscreen closes", () => { + let exits = 0; + function Harness({ active }: { active: boolean }) { + useComposerFullscreenExit(active, () => exits++); + return null; + } + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + flushSync(() => root.render()); + expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(true); + expect(exits).toBe(1); + flushSync(() => root.render()); + expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(false); + expect(exits).toBe(1); + } finally { + root.unmount(); + container.remove(); + } +}); diff --git a/packages/app/src/composer/input/fullscreen-exit.ts b/packages/app/src/composer/input/fullscreen-exit.ts index b76eeed5fc..b0fbe9e2a5 100644 --- a/packages/app/src/composer/input/fullscreen-exit.ts +++ b/packages/app/src/composer/input/fullscreen-exit.ts @@ -1,4 +1,6 @@ -import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root"; +import { useComposerHardwareBackExit } from "./hardware-back-exit"; +import { useOverlayRegistration } from "@/keyboard"; +import { useOverlayLayer } from "@/lib/overlay-root"; /** * Escape leaves fullscreen before app shortcuts see it. The registration only claims @@ -8,8 +10,10 @@ export function useComposerFullscreenExit( active: boolean, onExit: () => void, ): ((node: unknown) => void) | undefined { + useComposerHardwareBackExit(active, onExit); const layer = useOverlayLayer("modal"); - return useWebOverlayRegistration({ + return useOverlayRegistration({ + keys: ["Escape"], active, layer, manageFocus: false, diff --git a/packages/app/src/composer/input/fullscreen-exit.native.ts b/packages/app/src/composer/input/hardware-back-exit.android.ts similarity index 72% rename from packages/app/src/composer/input/fullscreen-exit.native.ts rename to packages/app/src/composer/input/hardware-back-exit.android.ts index c2344d50b0..96eec89bd6 100644 --- a/packages/app/src/composer/input/fullscreen-exit.native.ts +++ b/packages/app/src/composer/input/hardware-back-exit.android.ts @@ -2,10 +2,7 @@ import { useEffect } from "react"; import { BackHandler } from "react-native"; /** The hardware back button leaves fullscreen instead of the screen. */ -export function useComposerFullscreenExit( - active: boolean, - onExit: () => void, -): ((node: unknown) => void) | undefined { +export function useComposerHardwareBackExit(active: boolean, onExit: () => void): void { useEffect(() => { if (!active) return; const subscription = BackHandler.addEventListener("hardwareBackPress", () => { @@ -14,5 +11,4 @@ export function useComposerFullscreenExit( }); return () => subscription.remove(); }, [active, onExit]); - return undefined; } diff --git a/packages/app/src/composer/input/hardware-back-exit.ts b/packages/app/src/composer/input/hardware-back-exit.ts new file mode 100644 index 0000000000..0fefbbad72 --- /dev/null +++ b/packages/app/src/composer/input/hardware-back-exit.ts @@ -0,0 +1 @@ +export function useComposerHardwareBackExit(_active: boolean, _onExit: () => void): void {} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 8cb8cd4c32..618b923024 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,4 @@ +import { useKeyboardFocusSurface } from "@/keyboard"; import type { SendShortcut } from "@/hooks/use-settings"; import { useComposerEditingSession, @@ -1288,6 +1289,7 @@ export const MessageInput = forwardRef( const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const focusInputKeys = useShortcutKeys("focus-message-input"); const [isInputFocused, setIsInputFocused] = useState(false); + useKeyboardFocusSurface("message-input", isInputFocused && isPaneFocused); // Web text is DOM-owned between deferred draft publications. The action button only needs // this boundary, so publish empty/non-empty transitions without rerendering for every key. const initialHasLiveText = value.trim().length > 0; diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 080da9c37a..2541da9f79 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -1,6 +1,6 @@ import { Platform } from "react-native"; import { getElectronHost } from "@/desktop/electron/host"; -import type { BrowserKeyboardPolicy } from "@/desktop/browser/shortcuts"; +import type { BrowserKeyboardPolicy } from "@/keyboard"; import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; type BrowserAutomationExecuteRequest = Extract< diff --git a/packages/app/src/file-panels/source/find/index.web.tsx b/packages/app/src/file-panels/source/find/index.web.tsx index dedf58389f..2642bb5365 100644 --- a/packages/app/src/file-panels/source/find/index.web.tsx +++ b/packages/app/src/file-panels/source/find/index.web.tsx @@ -12,7 +12,7 @@ import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find"; import { usePaneFocus } from "@/panels/pane-context"; -import { hasActiveWebOverlay } from "@/lib/overlay-root"; +import { hasActiveOverlay } from "@/keyboard"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { FileFindModel } from "./model.web"; @@ -39,7 +39,7 @@ export function FileFind({ useEffect(() => { if (!isInteractive || !active) return; function onKeyDown(event: KeyboardEvent) { - if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event)) + if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event)) return; if (isFindShortcut(event, findShortcutPlatform())) { event.preventDefault(); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts deleted file mode 100644 index 6f332b0a98..0000000000 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { usePathname, useRouter } from "expo-router"; -import { getIsElectronRuntime } from "@/constants/layout"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; -import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews"; -import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; -import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; -import { - type ChordState, - type KeyboardShortcutInput, - resolveKeyboardShortcut, - buildEffectiveBindings, - getWorkspaceIndexJumpModifierKey, -} from "@/keyboard/keyboard-shortcuts"; -import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope"; -import { - buildBrowserKeyboardPolicy, - parseBrowserShortcutInput, - shouldPublishBrowserShortcutPolicy, -} from "@/desktop/browser/shortcuts"; -import type { KeyboardFocusScope, KeyboardShortcutPayload } from "@/keyboard/actions"; -import { - routeKeyboardShortcut, - type ShortcutAction, - type ShortcutCallbackName, -} from "@/keyboard/route-shortcut"; -import { getShortcutOs } from "@/utils/shortcut-platform"; -import { useOpenAddProject } from "@/hooks/use-open-add-project"; -import { useStableEvent } from "@/hooks/use-stable-event"; -import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; -import { isNative } from "@/constants/platform"; -import { keyboardShortcutsAvailable } from "@/keyboard/availability"; -import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; -import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; -import { buildOpenProjectRoute } from "@/utils/host-routes"; -import { hasActiveWebOverlay } from "@/lib/overlay-root"; -import { - type ActiveWorkspaceSelection, - navigateToLastWorkspace, - useActiveWorkspaceSelection, -} from "@/stores/navigation-active-workspace-store"; -import { dispatchTopWebOverlayKeyDown, keyBelongsToDomPopover } from "@/lib/overlay-root"; - -export function useKeyboardShortcuts({ - enabled, - isMobile, - isWorkspaceFocusModeEnabled, - toggleAgentList, - toggleBothSidebars, - exitFocusMode, - cycleTheme, -}: { - enabled: boolean; - isMobile: boolean; - isWorkspaceFocusModeEnabled: boolean; - toggleAgentList: () => void; - toggleBothSidebars?: () => void; - exitFocusMode: () => void; - cycleTheme?: () => void; -}) { - const keyboardActionDispatcher = useKeyboardActionDispatcher(); - const pathname = usePathname(); - const router = useRouter(); - const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); - const { overrides } = useKeyboardShortcutOverrides(); - const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); - const shortcutsAvailable = keyboardShortcutsAvailable({ isNative, isCompact: isMobile }); - const isDesktopApp = getIsElectronRuntime(); - const isMac = getShortcutOs() === "mac"; - const chordStateRef = useRef({ - candidateIndices: [], - step: 0, - timeoutId: null, - }); - const openProjectPickerAction = useOpenAddProject(); - const activeWorkspaceSelection = useActiveWorkspaceSelection(); - const keyboardWorkspaceSelectionRef = useRef(null); - - const publishBrowserShortcutPolicy = useCallback( - (chordState?: ChordState) => { - const policy = - enabled && shortcutsAvailable - ? buildBrowserKeyboardPolicy({ - bindings, - chordState, - isMac, - isDesktop: isDesktopApp, - }) - : { menuPrefixes: [], prefixes: [] }; - void getDesktopHost()?.browser?.setShortcutPolicy?.(policy); - }, - [bindings, enabled, isDesktopApp, isMac, shortcutsAvailable], - ); - - useEffect(() => { - if (activeWorkspaceSelection) { - keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection; - } - }, [activeWorkspaceSelection]); - - useEffect(() => { - if (!isDesktopApp) { - return; - } - - publishBrowserShortcutPolicy(); - }, [isDesktopApp, publishBrowserShortcutPolicy]); - - // Only the modifier that actually performs the workspace-index jump on this - // runtime should reveal the sidebar number badges (Alt on web, Cmd on - // desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown - // to drive badge visibility, so we set the flag matching this runtime. - // Derived from the effective bindings: `null` when the user unassigned or - // rebound the jump shortcut, and no `event.key` ever equals null, so the - // badges simply never appear. - const badgeModifierKey = getWorkspaceIndexJumpModifierKey( - { isMac, isDesktop: isDesktopApp }, - bindings, - ); - - // The keyup listener matches the released key against the current modifier, - // so a modifier held while the jump binding changes could never be released - // and the badges would stay up until a blur. Clear them when it changes. - useEffect(() => { - resetModifiers(); - }, [badgeModifierKey, resetModifiers]); - - const setBadgeModifierDown = (down: boolean) => { - const state = useKeyboardShortcutsStore.getState(); - if (isDesktopApp) { - state.setCmdOrCtrlDown(down); - } else { - state.setAltDown(down); - } - }; - - const shouldHandle = () => { - if (typeof document === "undefined") return false; - if (document.visibilityState !== "visible") return false; - return true; - }; - - const captureCommandCenterFocusRestore = (event: KeyboardEvent) => { - const target = event.target instanceof Element ? event.target : null; - const targetEl = - target?.closest?.("textarea, input, [contenteditable='true']") ?? - (target instanceof HTMLElement ? target : null); - const active = document.activeElement; - const activeEl = active instanceof HTMLElement ? active : null; - setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null); - }; - - const callbacksByName: Record void) | undefined> = { - "toggle-agent-list": toggleAgentList, - "toggle-both-sidebars": toggleBothSidebars, - "cycle-theme": cycleTheme, - }; - - const performShortcutAction = ( - action: ShortcutAction, - event: KeyboardEvent | null, - browserFocusRestoreElement: HTMLElement | null = null, - ): boolean => { - switch (action.kind) { - case "none": - return false; - case "dispatch": - return keyboardActionDispatcher.dispatch(action.action); - case "navigate-workspace": - keyboardWorkspaceSelectionRef.current = { - serverId: action.serverId, - workspaceId: action.workspaceId, - }; - navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId }); - return true; - case "navigate-last-workspace": - if (navigateToLastWorkspace()) { - return true; - } - router.replace(buildOpenProjectRoute()); - return true; - case "router-replace": - router.replace(action.route as Parameters[0]); - return true; - case "router-back": - router.back(); - return true; - case "router-push": - router.push(action.route as Parameters[0]); - return true; - case "open-project-picker": - void openProjectPickerAction(); - return true; - case "callback": - callbacksByName[action.name]?.(); - return true; - case "command-center-toggle": { - if (action.nextOpen) { - if (event) { - captureCommandCenterFocusRestore(event); - } else { - setCommandCenterFocusRestoreElement(browserFocusRestoreElement); - } - } - useKeyboardShortcutsStore - .getState() - .setCommandCenterOpen(action.nextOpen, action.scope ?? null); - return true; - } - case "shortcuts-dialog-toggle": - useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen); - return true; - } - }; - - const routeAndPerformShortcut = (input: { - action: string; - payload: KeyboardShortcutPayload; - domEvent: KeyboardEvent | null; - browserFocusRestoreElement?: HTMLElement | null; - }): boolean => { - const store = useKeyboardShortcutsStore.getState(); - const shortcutAction = routeKeyboardShortcut( - { action: input.action, payload: input.payload }, - { - pathname, - isMobile, - sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets, - navigationActiveWorkspace: - keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection, - commandCenterOpen: store.commandCenterOpen, - shortcutsDialogOpen: store.shortcutsDialogOpen, - }, - ); - const handled = performShortcutAction( - shortcutAction, - input.domEvent, - input.browserFocusRestoreElement, - ); - if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) { - exitFocusMode(); - } - return handled; - }; - - const resolveAndPerformShortcut = (input: { - event: KeyboardShortcutInput; - focusScope: KeyboardFocusScope; - domEvent: KeyboardEvent | null; - browserFocusRestoreElement?: HTMLElement | null; - }) => { - const store = useKeyboardShortcutsStore.getState(); - const previousChordState = chordStateRef.current; - const result = resolveKeyboardShortcut({ - event: input.event, - context: { - isMac, - isDesktop: isDesktopApp, - focusScope: input.focusScope, - commandCenterOpen: store.commandCenterOpen, - }, - chordState: chordStateRef.current, - onChordReset: () => { - chordStateRef.current = { - candidateIndices: [], - step: 0, - timeoutId: null, - }; - publishBrowserShortcutPolicy(); - }, - bindings, - }); - chordStateRef.current = result.nextChordState; - if ( - shouldPublishBrowserShortcutPolicy({ - isBrowserInput: "browserId" in input.event, - previousChordState, - nextChordState: result.nextChordState, - }) - ) { - publishBrowserShortcutPolicy(result.nextChordState); - } - - if (result.preventDefault && input.domEvent) { - input.domEvent.preventDefault(); - input.domEvent.stopPropagation(); - } - - if (!result.match) { - return; - } - - const handled = routeAndPerformShortcut({ - action: result.match.action, - payload: result.match.payload, - domEvent: input.domEvent, - browserFocusRestoreElement: input.browserFocusRestoreElement, - }); - if (!handled || !input.domEvent) { - return; - } - - if (result.match.preventDefault) { - input.domEvent.preventDefault(); - } - if (result.match.stopPropagation) { - input.domEvent.stopPropagation(); - } - }; - - // The window listeners must outlive ordinary re-renders: removing them resets - // any in-progress chord. Stable events let them read the latest render instead - // of being re-registered whenever a route, callback, or selection changes. - const handleKeyDown = useStableEvent((event: KeyboardEvent) => { - if (!shouldHandle()) { - return; - } - - if (dispatchTopWebOverlayKeyDown(event)) { - return; - } - - if (keyBelongsToDomPopover(event)) { - return; - } - - // During IME composition, Enter confirms the candidate selection and must - // not route through global shortcuts like message send. - if (isImeComposingKeyboardEvent(event)) { - return; - } - - const store = useKeyboardShortcutsStore.getState(); - if (store.capturingShortcut) { - return; - } - - const key = event.key ?? ""; - if ( - key === "Escape" && - pathname.startsWith("/settings") && - !isMobile && - hasActiveWebOverlay() - ) { - return; - } - if (key === badgeModifierKey && !event.shiftKey) { - setBadgeModifierDown(true); - } - if (key === "Shift") { - const state = useKeyboardShortcutsStore.getState(); - if (state.altDown || state.cmdOrCtrlDown) { - state.resetModifiers(); - } - } - - const focusScope = resolveKeyboardFocusScope({ - target: event.target, - commandCenterOpen: store.commandCenterOpen, - }); - resolveAndPerformShortcut({ - event, - focusScope, - domEvent: event, - }); - }); - - const handleKeyUp = useStableEvent((event: KeyboardEvent) => { - const key = event.key ?? ""; - if (key === badgeModifierKey) { - setBadgeModifierDown(false); - } - }); - - const handleBrowserShortcutInput = useStableEvent((payload: unknown) => { - const input = parseBrowserShortcutInput(payload); - if (!input) { - return; - } - resolveAndPerformShortcut({ - event: input, - focusScope: "browser", - domEvent: null, - browserFocusRestoreElement: getResidentBrowserWebview(input.browserId), - }); - }); - - useEffect(() => { - if (!enabled) return; - if (!shortcutsAvailable) return; - - const handleBlurOrHide = () => { - resetModifiers(); - }; - - window.addEventListener("keydown", handleKeyDown, true); - window.addEventListener("keyup", handleKeyUp, true); - window.addEventListener("blur", handleBlurOrHide); - document.addEventListener("visibilitychange", handleBlurOrHide); - - const browserShortcutSubscription = isElectronRuntime() - ? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput) - : null; - return () => { - if (chordStateRef.current.timeoutId !== null) { - clearTimeout(chordStateRef.current.timeoutId); - chordStateRef.current = { - candidateIndices: [], - step: 0, - timeoutId: null, - }; - } - window.removeEventListener("keydown", handleKeyDown, true); - window.removeEventListener("keyup", handleKeyUp, true); - window.removeEventListener("blur", handleBlurOrHide); - document.removeEventListener("visibilitychange", handleBlurOrHide); - if (typeof browserShortcutSubscription === "function") { - browserShortcutSubscription(); - } else { - void browserShortcutSubscription?.then((dispose) => dispose()); - } - }; - }, [ - enabled, - handleBrowserShortcutInput, - handleKeyDown, - handleKeyUp, - resetModifiers, - shortcutsAvailable, - ]); -} diff --git a/packages/app/src/hooks/use-shortcut-keys.ts b/packages/app/src/hooks/use-shortcut-keys.ts index d0d8c98c8b..d3eabd5fff 100644 --- a/packages/app/src/hooks/use-shortcut-keys.ts +++ b/packages/app/src/hooks/use-shortcut-keys.ts @@ -1,21 +1,20 @@ +import { usesDesktopBindings } from "@/keyboard"; import { useMemo } from "react"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { getShortcutOs } from "@/utils/shortcut-platform"; -import { getIsElectronRuntime } from "@/constants/layout"; /** `null` action ids are accepted so callers can keep the hook unconditional. */ export function useShortcutKeys(actionId: string | null): ShortcutKey[][] | null { const { overrides } = useKeyboardShortcutOverrides(); const isMac = getShortcutOs() === "mac"; - const isDesktopApp = getIsElectronRuntime(); return useMemo(() => { if (actionId === null) return null; return resolveShortcutKeysForAction(actionId, overrides, { isMac, - isDesktop: isDesktopApp, + usesDesktopBindings, }); - }, [actionId, overrides, isMac, isDesktopApp]); + }, [actionId, overrides, isMac]); } diff --git a/packages/app/src/hosts/host-chooser.tsx b/packages/app/src/hosts/host-chooser.tsx index eb2b02dd87..eb6fdea5a0 100644 --- a/packages/app/src/hosts/host-chooser.tsx +++ b/packages/app/src/hosts/host-chooser.tsx @@ -1,3 +1,4 @@ +import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard"; import { useAddHost } from "@/hosts/add-host"; import { Modal } from "@/components/ui/modal"; import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -12,11 +13,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { HostStatusDotSlot } from "@/components/hosts/host-picker"; import { isWeb } from "@/constants/platform"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; -import { - OverlayLayerProvider, - useGlobalWebOverlayLayer, - useWebOverlayRegistration, -} from "@/lib/overlay-root"; +import { OverlayLayerProvider, useGlobalOverlayLayer } from "@/lib/overlay-root"; import { useHosts } from "@/runtime/host-runtime"; import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection"; @@ -145,7 +142,7 @@ export function HostChooserModal() { const inputRef = useRef(null); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); - const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && request != null); + const modalLayer = useGlobalOverlayLayer("modal", request != null); const requestHosts = useMemo(() => { if (!request) return []; @@ -186,8 +183,8 @@ export function HostChooserModal() { [close, request], ); - const handleWebOverlayKeyDown = useCallback( - (event: KeyboardEvent) => { + const handleOverlayKeyDown = useCallback( + (event: KeyboardInputEvent) => { if ( event.key !== "ArrowDown" && event.key !== "ArrowUp" && @@ -227,51 +224,56 @@ export function HostChooserModal() { }, [activeOptionIndex, chooseHost, close, options], ); - const setWebOverlayScope = useWebOverlayRegistration({ - active: isWeb && request != null, + const setOverlayScope = useOverlayRegistration({ + keys: ["Escape", "Enter", "ArrowUp", "ArrowDown"], + active: request != null, layer: modalLayer, - onKeyDown: handleWebOverlayKeyDown, + onKeyDown: handleOverlayKeyDown, }); if (!request) return null; const modal = ( - - - - - {request.title} - - - - {options.length === 0 ? No matching hosts : null} - {options.map((host, index) => ( - + + + + + {request.title} + - ))} - + + + {options.length === 0 ? ( + No matching hosts + ) : null} + {options.map((host, index) => ( + + ))} + + - + ); diff --git a/packages/app/src/keyboard/availability.test.ts b/packages/app/src/keyboard/availability.test.ts index 282a879006..9bca173528 100644 --- a/packages/app/src/keyboard/availability.test.ts +++ b/packages/app/src/keyboard/availability.test.ts @@ -1,11 +1,68 @@ import { describe, expect, it } from "vitest"; -import { keyboardShortcutsAvailable } from "./availability"; +import { createHardwareKeyboardPresence } from "./internal/hardware-presence"; +import { buildEffectiveBindings } from "./keyboard-shortcuts"; +import { createKeyboardPipeline } from "./internal/pipeline"; +import { createOverlayRegistry } from "./internal/overlays"; +import { nativeKeyboardInput } from "./internal/sources/contract"; -describe("keyboardShortcutsAvailable", () => { - it("matches the environments where the shortcut dispatcher runs", () => { - expect(keyboardShortcutsAvailable({ isNative: false, isCompact: false })).toBe(true); - expect(keyboardShortcutsAvailable({ isNative: false, isCompact: true })).toBe(false); - expect(keyboardShortcutsAvailable({ isNative: true, isCompact: false })).toBe(false); - expect(keyboardShortcutsAvailable({ isNative: true, isCompact: true })).toBe(false); +describe("hardware keyboard availability", () => { + it("publishes connection changes and cleans up the capability subscription", () => { + let present = false; + const changes = new Set<() => void>(); + const presence = createHardwareKeyboardPresence({ + read: () => present, + watch: (changed) => { + changes.add(changed); + return () => { + changes.delete(changed); + }; + }, + }); + const observed: boolean[] = []; + const release = presence.subscribe(() => observed.push(presence.getSnapshot())); + expect(presence.getSnapshot()).toBe(false); + present = true; + for (const changed of changes) changed(); + expect(presence.getSnapshot()).toBe(true); + for (const changed of changes) changed(); + present = false; + for (const changed of changes) changed(); + expect(observed).toEqual([true, false]); + release(); + expect(changes.size).toBe(0); + }); + + it("routes compact Electron input independently of hint availability", () => { + const presence = createHardwareKeyboardPresence({ read: () => false, watch: () => () => {} }); + const actions: string[] = []; + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }), + getBindings: () => buildEffectiveBindings({}), + isCapturing: () => false, + dispatch: (match) => { + actions.push(match.action); + return true; + }, + overlays: createOverlayRegistry(), + }); + pipeline.keyDown( + nativeKeyboardInput( + { + key: "k", + code: "KeyK", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + repeat: false, + preventDefault() {}, + stopPropagation() {}, + }, + "other", + ), + ); + expect(presence.getSnapshot()).toBe(false); + expect(actions).toEqual(["command-center.toggle"]); + pipeline.dispose(); }); }); diff --git a/packages/app/src/keyboard/availability.ts b/packages/app/src/keyboard/availability.ts index ec3957f58f..31395f0f01 100644 --- a/packages/app/src/keyboard/availability.ts +++ b/packages/app/src/keyboard/availability.ts @@ -1,19 +1,9 @@ -import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; - -interface KeyboardShortcutEnvironment { - isNative: boolean; - isCompact: boolean; -} - -export function keyboardShortcutsAvailable({ - isNative: native, - isCompact, -}: KeyboardShortcutEnvironment): boolean { - return !native && !isCompact; -} - -export function useKeyboardShortcutsAvailable(): boolean { - const isCompact = useIsCompactFormFactor(); - return keyboardShortcutsAvailable({ isNative, isCompact }); +import { useSyncExternalStore } from "react"; +import { hardwareKeyboardPresence } from "./internal/presence"; +export function useHardwareKeyboardPresent(): boolean { + return useSyncExternalStore( + hardwareKeyboardPresence.subscribe, + hardwareKeyboardPresence.getSnapshot, + () => false, + ); } diff --git a/packages/app/src/keyboard/index.ts b/packages/app/src/keyboard/index.ts new file mode 100644 index 0000000000..c8836c8e08 --- /dev/null +++ b/packages/app/src/keyboard/index.ts @@ -0,0 +1,13 @@ +export { useOverlayRegistration } from "./internal/overlay-registration"; +export type { KeyboardInputEvent } from "./internal/overlays"; +export { + dispatchTopOverlayKeyDown, + hasActiveOverlay, + getTopOverlayKeys, + getTopOverlayLayer, + subscribeOverlays, +} from "./internal/overlay-dispatch"; +export { useKeyboardFocusSurface } from "./internal/use-focus-surface"; +export { usesDesktopBindings, supportsConfigurableComposerSendKeys } from "./internal/conventions"; +export type { BrowserKeyboardPolicy } from "./internal/sources/electron-browser"; +export { useHardwareKeyboardPresent } from "./availability"; diff --git a/packages/app/src/keyboard/internal/conventions.ts b/packages/app/src/keyboard/internal/conventions.ts new file mode 100644 index 0000000000..0c457cf20d --- /dev/null +++ b/packages/app/src/keyboard/internal/conventions.ts @@ -0,0 +1,3 @@ +export const usesDesktopBindings = true; +// Native composer Return retains its existing submit path in this feature. +export const supportsConfigurableComposerSendKeys = false; diff --git a/packages/app/src/keyboard/internal/conventions.web.ts b/packages/app/src/keyboard/internal/conventions.web.ts new file mode 100644 index 0000000000..0f75b6bc63 --- /dev/null +++ b/packages/app/src/keyboard/internal/conventions.web.ts @@ -0,0 +1,3 @@ +import { isElectronRuntime } from "@/desktop/host"; +export const usesDesktopBindings = isElectronRuntime(); +export const supportsConfigurableComposerSendKeys = true; diff --git a/packages/app/src/keyboard/internal/dom-popover.ts b/packages/app/src/keyboard/internal/dom-popover.ts new file mode 100644 index 0000000000..e7e9717fca --- /dev/null +++ b/packages/app/src/keyboard/internal/dom-popover.ts @@ -0,0 +1,6 @@ +export function keyBelongsToDomPopover(_event: { key: string }): boolean { + return false; +} +export function hasOpenDomPopover(): boolean { + return false; +} diff --git a/packages/app/src/keyboard/internal/dom-popover.web.ts b/packages/app/src/keyboard/internal/dom-popover.web.ts new file mode 100644 index 0000000000..ac4b6570a6 --- /dev/null +++ b/packages/app/src/keyboard/internal/dom-popover.web.ts @@ -0,0 +1,24 @@ +/** + * True while an element shown with the Popover API is open. Such a layer + * floats above the app like a registered overlay but lives outside the + * registry: plugins draw their panels this way, since they cannot register. + */ +export function hasOpenDomPopover(): boolean { + if (typeof document === "undefined") return false; + try { + return document.querySelector(":popover-open") !== null; + } catch { + // An engine without the Popover API rejects the selector, and has none open. + return false; + } +} + +/** + * Whether a key belongs to an open popover rather than to the app's shortcuts. + * An open popover (a plugin's panel) is the top layer and closes on its own + * Escape listener, which runs after the shortcut engine's capture listener: + * Escape must not also interrupt the running agent. + */ +export function keyBelongsToDomPopover(event: Pick): boolean { + return event.key === "Escape" && hasOpenDomPopover(); +} diff --git a/packages/app/src/keyboard/internal/focus-surfaces.ts b/packages/app/src/keyboard/internal/focus-surfaces.ts new file mode 100644 index 0000000000..5bdeb6fa0d --- /dev/null +++ b/packages/app/src/keyboard/internal/focus-surfaces.ts @@ -0,0 +1,23 @@ +import { hasFocusedTextInput } from "./focused-text-input"; +import type { KeyboardFocusScope } from "../actions"; +export type KeyboardFocusSurface = "message-input" | "terminal"; + +export function createKeyboardFocusScope(hasEditableFocus: () => boolean = () => false) { + const surfaces = new Map(); + return { + declare(surface: KeyboardFocusSurface) { + const token = Symbol(surface); + surfaces.set(token, surface); + return () => { + surfaces.delete(token); + }; + }, + resolve(commandCenterOpen: boolean): KeyboardFocusScope { + if (commandCenterOpen) return "command-center"; + let focused: KeyboardFocusScope = hasEditableFocus() ? "editable" : "other"; + for (const surface of surfaces.values()) focused = surface; + return focused; + }, + }; +} +export const focusSurfaces = createKeyboardFocusScope(hasFocusedTextInput); diff --git a/packages/app/src/keyboard/internal/focused-text-input.ts b/packages/app/src/keyboard/internal/focused-text-input.ts new file mode 100644 index 0000000000..87c7159e7e --- /dev/null +++ b/packages/app/src/keyboard/internal/focused-text-input.ts @@ -0,0 +1,6 @@ +// oxlint-disable-next-line no-restricted-imports -- Keyboard boundary reads focus state; it does not render an input. +import { TextInput } from "react-native"; + +export function hasFocusedTextInput(): boolean { + return TextInput.State.currentlyFocusedInput() !== null; +} diff --git a/packages/app/src/keyboard/internal/focused-text-input.web.ts b/packages/app/src/keyboard/internal/focused-text-input.web.ts new file mode 100644 index 0000000000..2a9de8633f --- /dev/null +++ b/packages/app/src/keyboard/internal/focused-text-input.web.ts @@ -0,0 +1,3 @@ +export function hasFocusedTextInput(): boolean { + return false; +} diff --git a/packages/app/src/keyboard/internal/hardware-presence.ts b/packages/app/src/keyboard/internal/hardware-presence.ts new file mode 100644 index 0000000000..701b67c919 --- /dev/null +++ b/packages/app/src/keyboard/internal/hardware-presence.ts @@ -0,0 +1,32 @@ +interface PresenceSource { + read(): boolean; + watch(changed: () => void): () => void; +} +export function createHardwareKeyboardPresence(source: PresenceSource) { + let present = source.read(); + const listeners = new Set<() => void>(); + let stop: (() => void) | undefined; + const update = () => { + const next = source.read(); + if (present === next) return; + present = next; + for (const listener of listeners) listener(); + }; + return { + getSnapshot: () => present, + subscribe(listener: () => void) { + listeners.add(listener); + if (listeners.size === 1) { + stop = source.watch(update); + update(); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + stop?.(); + stop = undefined; + } + }; + }, + }; +} diff --git a/packages/app/src/keyboard/internal/overlay-contract.ts b/packages/app/src/keyboard/internal/overlay-contract.ts new file mode 100644 index 0000000000..1061ecc42a --- /dev/null +++ b/packages/app/src/keyboard/internal/overlay-contract.ts @@ -0,0 +1,10 @@ +import type { RefObject } from "react"; +import type { KeyboardInputEvent } from "./overlays"; +export interface OverlayRegistration { + active: boolean; + layer: number; + keys: readonly string[]; + onKeyDown(event: KeyboardInputEvent): boolean; + restoreFocusRef?: RefObject; + manageFocus?: boolean; +} diff --git a/packages/app/src/keyboard/internal/overlay-dispatch.ts b/packages/app/src/keyboard/internal/overlay-dispatch.ts new file mode 100644 index 0000000000..0d38f2afe6 --- /dev/null +++ b/packages/app/src/keyboard/internal/overlay-dispatch.ts @@ -0,0 +1,8 @@ +import { overlays } from "./overlays"; +export const dispatchTopOverlayKeyDown = overlays.dispatch; +export const hasActiveOverlay = overlays.hasActive; +export const getTopOverlayKeys = overlays.getKeys; +export const subscribeOverlays = overlays.subscribe; +export function getTopOverlayLayer(): number { + return overlays.getTop()?.getLayer() ?? 0; +} diff --git a/packages/app/src/keyboard/internal/overlay-registration.ts b/packages/app/src/keyboard/internal/overlay-registration.ts new file mode 100644 index 0000000000..31c2c0d494 --- /dev/null +++ b/packages/app/src/keyboard/internal/overlay-registration.ts @@ -0,0 +1,18 @@ +import type { OverlayRegistration } from "./overlay-contract"; +import { useLayoutEffect, useRef } from "react"; +import { overlays } from "./overlays"; + +const setScope = (_node: unknown) => {}; +export function useOverlayRegistration(input: OverlayRegistration) { + const current = useRef(input); + current.current = input; + useLayoutEffect(() => { + if (!input.active) return; + return overlays.register({ + getLayer: () => current.current.layer, + getKeys: () => current.current.keys, + onKeyDown: (event) => current.current.onKeyDown(event), + }); + }, [input.active]); + return setScope; +} diff --git a/packages/app/src/keyboard/internal/overlay-registration.web.ts b/packages/app/src/keyboard/internal/overlay-registration.web.ts new file mode 100644 index 0000000000..92beb60acf --- /dev/null +++ b/packages/app/src/keyboard/internal/overlay-registration.web.ts @@ -0,0 +1,264 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; +import { overlays, type OverlayEntry, type KeyboardInputEvent } from "./overlays"; +import type { OverlayRegistration } from "./overlay-contract"; +type WebOverlayKeyHandler = (event: KeyboardInputEvent) => boolean; + +interface WebOverlayEntry { + getLayer: () => number; + getKeys: () => readonly string[]; + getScope: () => HTMLElement | null; + getKeyHandler: () => WebOverlayKeyHandler; + restoreFocus: HTMLElement | null; + manageFocus: boolean; +} + +const focusEntries = new WeakMap(); +let webOverlayListenersAttached = false; +let webOverlayFocusCheckQueued = false; + +interface RemoveWebOverlayOptions { + restoreFocus?: boolean; +} + +function getTopWebOverlay(): WebOverlayEntry | undefined { + const top = overlays.getTop(); + return top ? focusEntries.get(top) : undefined; +} + +function getFocusableElements(scope: HTMLElement): HTMLElement[] { + const selector = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', + ].join(","); + + return Array.from(scope.querySelectorAll(selector)).filter( + (element) => + element.getAttribute("aria-hidden") !== "true" && + !element.hasAttribute("hidden") && + element.getClientRects().length > 0, + ); +} + +function focusFirstElement(scope: HTMLElement): void { + const firstMenuItem = scope.querySelector( + '[data-menu-item="true"]:not([data-menu-disabled="true"])', + ); + const first = firstMenuItem ?? getFocusableElements(scope)[0]; + (first ?? scope).focus(); +} + +function handleWebOverlayFocus(event: FocusEvent): void { + const top = getTopWebOverlay(); + const scope = top?.getScope(); + if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return; + if (webOverlayFocusCheckQueued) return; + + // React can autofocus a child before its parent scope ref attaches. Defer + // enforcement until the commit finishes so a newly mounted higher overlay + // can register without the previous scope stealing the requested focus. + webOverlayFocusCheckQueued = true; + queueMicrotask(() => { + webOverlayFocusCheckQueued = false; + const currentTop = getTopWebOverlay(); + const currentScope = currentTop?.getScope(); + if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement)) + return; + focusFirstElement(currentScope); + }); +} + +/** + * Gives the top painted overlay first refusal on a key before app-wide shortcuts run. + * The window listener below is the fallback for keys nobody routes through the shortcut engine. + */ +function dispatchOverlayEntry(entry: WebOverlayEntry, event: KeyboardInputEvent): boolean { + // Composition keys belong to the active text field. A modal must not turn an + // IME candidate confirmation into an overlay shortcut before the browser has + // committed the composed text. + if (event.isComposing || event.key === "Process") return false; + + const top = entry; + const scope = entry.getScope(); + if (!scope) return top.getKeyHandler()(event); + + if (top.manageFocus && event.key === "Tab") { + const focusable = getFocusableElements(scope); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + const shouldWrapBackward = event.shiftKey && (!scope.contains(active) || active === first); + const shouldWrapForward = !event.shiftKey && (!scope.contains(active) || active === last); + if (shouldWrapBackward || shouldWrapForward) { + event.preventDefault(); + event.stopPropagation(); + (shouldWrapBackward ? last : first)?.focus(); + if (!first) scope.focus(); + return true; + } + } + + if (!top.getKeyHandler()(event)) return false; + event.stopPropagation(); + return true; +} + +function handleWebOverlayKeyDown(event: KeyboardEvent): void { + if (overlays.dispatch(event)) event.stopImmediatePropagation(); +} + +function attachWebOverlayListeners(): void { + if (webOverlayListenersAttached) return; + window.addEventListener("keydown", handleWebOverlayKeyDown, true); + document.addEventListener("focusin", handleWebOverlayFocus, true); + webOverlayListenersAttached = true; +} + +function detachWebOverlayListeners(): void { + if (!webOverlayListenersAttached || overlays.hasActive()) return; + window.removeEventListener("keydown", handleWebOverlayKeyDown, true); + document.removeEventListener("focusin", handleWebOverlayFocus, true); + webOverlayListenersAttached = false; +} + +function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptions) => void { + const registration: OverlayEntry = { + getLayer: entry.getLayer, + getKeys: entry.getKeys, + onKeyDown: (event) => dispatchOverlayEntry(entry, event), + }; + focusEntries.set(registration, entry); + const remove = overlays.register(registration); + attachWebOverlayListeners(); + + const focusFrame = window.requestAnimationFrame(() => { + const scope = entry.getScope(); + if ( + entry.manageFocus && + getTopWebOverlay() === entry && + scope && + !scope.contains(document.activeElement) + ) { + focusFirstElement(scope); + } + }); + + return (options) => { + window.cancelAnimationFrame(focusFrame); + remove(); + detachWebOverlayListeners(); + if ( + entry.manageFocus && + options?.restoreFocus !== false && + entry.restoreFocus && + document.contains(entry.restoreFocus) + ) { + entry.restoreFocus.focus(); + } + }; +} + +/** + * Registers a focus scope in the same relative layer model used for painting. + * Only the highest painted overlay receives keyboard input. By default, focus is trapped + * there and restored to the opener when it closes; hover surfaces preserve focus instead. + */ +export function useOverlayRegistration({ + active, + layer, + keys, + onKeyDown, + manageFocus = true, + restoreFocusRef: preferredRestoreFocusRef, +}: OverlayRegistration) { + const scopeRef = useRef(null); + const keysRef = useRef(keys); + keysRef.current = keys; + const layerRef = useRef(layer); + const keyHandlerRef = useRef(onKeyDown); + const capturedRestoreFocusRef = useRef(null); + const removeEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null); + const detachedRemoveEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null); + const registeredRef = useRef(false); + const activeRef = useRef(active); + const wasActiveRef = useRef(false); + + activeRef.current = active; + layerRef.current = layer; + keyHandlerRef.current = onKeyDown; + if (active && !wasActiveRef.current && typeof document !== "undefined") { + const requestedRestoreTarget = preferredRestoreFocusRef?.current; + if (requestedRestoreTarget instanceof HTMLElement) { + capturedRestoreFocusRef.current = requestedRestoreTarget; + } else { + capturedRestoreFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + } + wasActiveRef.current = active; + + const syncRegistration = useCallback(() => { + const shouldRegister = + activeRef.current && typeof window !== "undefined" && typeof document !== "undefined"; + if (!shouldRegister) { + const shouldRestoreFocus = !activeRef.current; + if (registeredRef.current || shouldRestoreFocus) { + removeEntryRef.current?.({ restoreFocus: shouldRestoreFocus }); + } + registeredRef.current = false; + if (shouldRestoreFocus) { + removeEntryRef.current = null; + detachedRemoveEntryRef.current = null; + } + return; + } + if (registeredRef.current) return; + + detachedRemoveEntryRef.current = null; + const entry: WebOverlayEntry = { + getLayer: () => layerRef.current, + getKeys: () => keysRef.current, + getScope: () => scopeRef.current, + getKeyHandler: () => keyHandlerRef.current, + restoreFocus: capturedRestoreFocusRef.current, + manageFocus, + }; + removeEntryRef.current = addWebOverlay(entry); + registeredRef.current = true; + }, [manageFocus]); + + const setScope = useCallback( + (node: unknown) => { + scopeRef.current = + typeof HTMLElement !== "undefined" && node instanceof HTMLElement ? node : null; + if (!scopeRef.current && activeRef.current) { + const removeEntry = removeEntryRef.current; + removeEntry?.({ restoreFocus: false }); + detachedRemoveEntryRef.current = removeEntry; + removeEntryRef.current = null; + registeredRef.current = false; + return; + } + // Host refs attach before descendant layout effects and autofocus. Register + // here so the previous overlay cannot redirect that pending focus. + syncRegistration(); + }, + [syncRegistration], + ); + + useLayoutEffect(() => { + syncRegistration(); + return () => { + const removeEntry = removeEntryRef.current ?? detachedRemoveEntryRef.current; + removeEntry?.(); + removeEntryRef.current = null; + detachedRemoveEntryRef.current = null; + registeredRef.current = false; + }; + }, [active, syncRegistration]); + + return setScope; +} diff --git a/packages/app/src/keyboard/internal/overlays.test.ts b/packages/app/src/keyboard/internal/overlays.test.ts new file mode 100644 index 0000000000..09f734e606 --- /dev/null +++ b/packages/app/src/keyboard/internal/overlays.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { createOverlayRegistry } from "./overlays"; + +function keyEvent(key: string) { + return { + key, + code: key, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + repeat: false, + isComposing: false, + preventDefault() {}, + stopPropagation() {}, + }; +} + +describe("overlay keyboard ownership", () => { + it("offers a key only to the highest layer and consumes it once without DOM focus", () => { + const registry = createOverlayRegistry(); + const calls: string[] = []; + const remove = registry.register({ + getLayer: () => 20, + getKeys: () => ["Escape"], + onKeyDown: (event) => { + calls.push(`modal:${event.key}`); + return true; + }, + }); + const removeMenu = registry.register({ + getLayer: () => 30, + getKeys: () => ["Enter"], + onKeyDown: (event) => { + calls.push(`menu:${event.key}`); + return event.key === "Enter"; + }, + }); + expect(registry.dispatch(keyEvent("Escape"))).toBe(false); + expect(calls).toEqual(["menu:Escape"]); + expect(registry.getKeys()).toEqual(["Enter"]); + removeMenu(); + expect(registry.dispatch(keyEvent("Escape"))).toBe(true); + expect(calls).toEqual(["menu:Escape", "modal:Escape"]); + remove(); + expect(registry.dispatch(keyEvent("Escape"))).toBe(false); + }); +}); + +describe("overlay registration lifecycle", () => { + it("uses the newest registration at equal layers and ignores composition", () => { + const registry = createOverlayRegistry(); + const calls: string[] = []; + const first = registry.register({ + getLayer: () => 20, + getKeys: () => ["Escape"], + onKeyDown: () => { + calls.push("first"); + return true; + }, + }); + const second = registry.register({ + getLayer: () => 20, + getKeys: () => ["Enter"], + onKeyDown: () => { + calls.push("second"); + return true; + }, + }); + expect(registry.dispatch({ ...keyEvent("Enter"), isComposing: true })).toBe(false); + expect(calls).toEqual([]); + expect(registry.dispatch(keyEvent("Enter"))).toBe(true); + expect(calls).toEqual(["second"]); + second(); + expect(registry.dispatch(keyEvent("Escape"))).toBe(true); + expect(calls).toEqual(["second", "first"]); + first(); + }); + + it("notifies subscribers when top key ownership changes", () => { + const registry = createOverlayRegistry(); + const observed: string[][] = []; + const unsubscribe = registry.subscribe(() => observed.push([...registry.getKeys()])); + const release = registry.register({ + getLayer: () => 20, + getKeys: () => ["Enter"], + onKeyDown: () => false, + }); + release(); + release(); + unsubscribe(); + expect(observed).toEqual([["Enter"], []]); + }); +}); diff --git a/packages/app/src/keyboard/internal/overlays.ts b/packages/app/src/keyboard/internal/overlays.ts new file mode 100644 index 0000000000..150842f6c6 --- /dev/null +++ b/packages/app/src/keyboard/internal/overlays.ts @@ -0,0 +1,53 @@ +import type { KeyboardShortcutInput } from "../keyboard-shortcuts"; + +export interface KeyboardInputEvent extends KeyboardShortcutInput { + isComposing?: boolean; + preventDefault(): void; + stopPropagation(): void; +} +export interface OverlayEntry { + getLayer(): number; + getKeys(): readonly string[]; + onKeyDown(event: KeyboardInputEvent): boolean; +} +export function createOverlayRegistry() { + const entries: OverlayEntry[] = []; + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + function getTop(): OverlayEntry | undefined { + return entries.reduce( + (top, entry) => (!top || entry.getLayer() >= top.getLayer() ? entry : top), + undefined, + ); + } + return { + getTop, + getKeys: () => getTop()?.getKeys() ?? [], + hasActive: () => entries.length > 0, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + register(entry: OverlayEntry) { + entries.push(entry); + notify(); + return () => { + const index = entries.indexOf(entry); + if (index < 0) return; + entries.splice(index, 1); + notify(); + }; + }, + dispatch(event: KeyboardInputEvent): boolean { + if (event.isComposing || event.key === "Process") return false; + if (!getTop()?.onKeyDown(event)) return false; + event.stopPropagation(); + return true; + }, + }; +} +export const overlays = createOverlayRegistry(); diff --git a/packages/app/src/keyboard/internal/pipeline.ts b/packages/app/src/keyboard/internal/pipeline.ts new file mode 100644 index 0000000000..297cb7eabd --- /dev/null +++ b/packages/app/src/keyboard/internal/pipeline.ts @@ -0,0 +1,60 @@ +import { + resolveKeyboardShortcut, + type ChordState, + type KeyboardShortcutContext, + type KeyboardShortcutMatch, + type ParsedShortcutBinding, +} from "../keyboard-shortcuts"; +import type { KeyboardSourceInput } from "./sources/contract"; +import type { createOverlayRegistry } from "./overlays"; + +interface KeyboardPipelineOptions { + getContext(): Omit; + getBindings(): readonly ParsedShortcutBinding[]; + isCapturing(): boolean; + dispatch(match: KeyboardShortcutMatch, input: KeyboardSourceInput): boolean; + overlays: ReturnType; + beforeResolve?(input: KeyboardSourceInput): boolean; + chordChanged?(previous: ChordState, next: ChordState, input: KeyboardSourceInput | null): void; +} +function emptyChord(): ChordState { + return { candidateIndices: [], step: 0, timeoutId: null }; +} + +export function createKeyboardPipeline(options: KeyboardPipelineOptions) { + let chord = emptyChord(); + function reset() { + const previous = chord; + if (chord.timeoutId !== null) clearTimeout(chord.timeoutId); + chord = emptyChord(); + options.chordChanged?.(previous, chord, null); + } + return { + dispose: reset, + keyDown(input: KeyboardSourceInput): boolean { + const event = input.event; + if (options.overlays.dispatch(event)) return true; + if (event.isComposing || event.key === "Process" || options.isCapturing()) return false; + if (options.beforeResolve && !options.beforeResolve(input)) return false; + const previous = chord; + const result = resolveKeyboardShortcut({ + event, + context: { ...options.getContext(), focusScope: input.focusScope }, + bindings: options.getBindings(), + chordState: chord, + onChordReset: reset, + }); + chord = result.nextChordState; + options.chordChanged?.(previous, chord, input); + if (result.preventDefault) { + event.preventDefault(); + event.stopPropagation(); + } + if (!result.match) return false; + if (!options.dispatch(result.match, input)) return false; + if (result.match.preventDefault) event.preventDefault(); + if (result.match.stopPropagation) event.stopPropagation(); + return true; + }, + }; +} diff --git a/packages/app/src/keyboard/internal/presence.ts b/packages/app/src/keyboard/internal/presence.ts new file mode 100644 index 0000000000..9856f2b2e9 --- /dev/null +++ b/packages/app/src/keyboard/internal/presence.ts @@ -0,0 +1,6 @@ +import { createHardwareKeyboardPresence } from "./hardware-presence"; +import { getHardwareKeyboardPresent, subscribeHardwareKeyboardPresence } from "./sources/hardware"; +export const hardwareKeyboardPresence = createHardwareKeyboardPresence({ + read: getHardwareKeyboardPresent, + watch: subscribeHardwareKeyboardPresence, +}); diff --git a/packages/app/src/keyboard/internal/presence.web.ts b/packages/app/src/keyboard/internal/presence.web.ts new file mode 100644 index 0000000000..ff92ad24f2 --- /dev/null +++ b/packages/app/src/keyboard/internal/presence.web.ts @@ -0,0 +1,31 @@ +import { createHardwareKeyboardPresence } from "./hardware-presence"; + +export function createWebHardwareKeyboardPresence( + pointer: MediaQueryList | null, + events: Window | null, +) { + let observedKeyboard = false; + return createHardwareKeyboardPresence({ + read: () => observedKeyboard || pointer?.matches === true, + watch(changed) { + if (!events) return () => {}; + const onKeyDown = (event: KeyboardEvent) => { + if (!(event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)) return; + observedKeyboard = true; + changed(); + }; + pointer?.addEventListener("change", changed); + events.addEventListener("keydown", onKeyDown, true); + return () => { + pointer?.removeEventListener("change", changed); + events.removeEventListener("keydown", onKeyDown, true); + }; + }, + }); +} +const events = typeof window === "undefined" ? null : window; +const pointer = + events && typeof events.matchMedia === "function" + ? events.matchMedia("(hover: hover) and (pointer: fine)") + : null; +export const hardwareKeyboardPresence = createWebHardwareKeyboardPresence(pointer, events); diff --git a/packages/app/src/keyboard/internal/sources/contract.ts b/packages/app/src/keyboard/internal/sources/contract.ts new file mode 100644 index 0000000000..886dbc3548 --- /dev/null +++ b/packages/app/src/keyboard/internal/sources/contract.ts @@ -0,0 +1,27 @@ +import type { KeyboardFocusScope } from "../../actions"; +import type { KeyboardInputEvent } from "../overlays"; +export interface KeyboardSourceInput { + event: KeyboardInputEvent; + focusScope: KeyboardFocusScope; + captureFocusRestore(): void; +} +export interface KeyboardSourceHandlers { + keyDown(input: KeyboardSourceInput): void; + keyUp(event: KeyboardInputEvent): void; + blur(): void; + commandCenterOpen(): boolean; +} +export function nativeKeyboardInput( + event: KeyboardInputEvent, + focusScope: KeyboardFocusScope, +): KeyboardSourceInput { + return { event, focusScope, captureFocusRestore() {} }; +} + +import type { ChordState, ParsedShortcutBinding } from "../../keyboard-shortcuts"; +export interface KeyboardPolicyInput { + bindings: readonly ParsedShortcutBinding[]; + chordState?: ChordState; + isMac: boolean; + enabled: boolean; +} diff --git a/packages/app/src/keyboard/internal/sources/dom.web.ts b/packages/app/src/keyboard/internal/sources/dom.web.ts new file mode 100644 index 0000000000..004be62836 --- /dev/null +++ b/packages/app/src/keyboard/internal/sources/dom.web.ts @@ -0,0 +1,44 @@ +import { resolveKeyboardFocusScope } from "../../focus-scope"; +import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; +import { keyBelongsToDomPopover } from "../dom-popover"; +import type { KeyboardSourceHandlers } from "./contract"; +export function attachDomKeyboardSource(handlers: KeyboardSourceHandlers): () => void { + const down = (event: KeyboardEvent) => { + if (document.visibilityState !== "visible" || keyBelongsToDomPopover(event)) return; + handlers.keyDown({ + event: { + key: event.key, + code: event.code, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + repeat: event.repeat, + isComposing: event.isComposing || event.keyCode === 229, + preventDefault: () => event.preventDefault(), + stopPropagation: () => event.stopImmediatePropagation(), + }, + focusScope: resolveKeyboardFocusScope({ + target: event.target, + commandCenterOpen: handlers.commandCenterOpen(), + }), + captureFocusRestore() { + const target = event.target instanceof Element ? event.target : null; + const editable = target?.closest("textarea, input, [contenteditable='true']"); + const focused = editable instanceof HTMLElement ? editable : document.activeElement; + setCommandCenterFocusRestoreElement(focused instanceof HTMLElement ? focused : null); + }, + }); + }; + const up = (event: KeyboardEvent) => handlers.keyUp(event); + window.addEventListener("keydown", down, true); + window.addEventListener("keyup", up, true); + window.addEventListener("blur", handlers.blur); + document.addEventListener("visibilitychange", handlers.blur); + return () => { + window.removeEventListener("keydown", down, true); + window.removeEventListener("keyup", up, true); + window.removeEventListener("blur", handlers.blur); + document.removeEventListener("visibilitychange", handlers.blur); + }; +} diff --git a/packages/app/src/desktop/browser/shortcuts.test.ts b/packages/app/src/keyboard/internal/sources/electron-browser.test.ts similarity index 75% rename from packages/app/src/desktop/browser/shortcuts.test.ts rename to packages/app/src/keyboard/internal/sources/electron-browser.test.ts index 804ed73369..abcc98b7fd 100644 --- a/packages/app/src/desktop/browser/shortcuts.test.ts +++ b/packages/app/src/keyboard/internal/sources/electron-browser.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; import { + attachElectronBrowserSource, buildBrowserKeyboardPolicy, parseBrowserShortcutInput, shouldPublishBrowserShortcutPolicy, -} from "./shortcuts"; -import { buildEffectiveBindings, resolveKeyboardShortcut } from "../../keyboard/keyboard-shortcuts"; +} from "./electron-browser"; +import { buildEffectiveBindings, resolveKeyboardShortcut } from "../../keyboard-shortcuts"; describe("buildBrowserKeyboardPolicy", () => { it("publishes only chord starts while no browser chord is pending", () => { @@ -13,7 +14,11 @@ describe("buildBrowserKeyboardPolicy", () => { "workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+F12 Ctrl+F11", }); - const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ + bindings, + isMac: false, + usesDesktopBindings: true, + }); expect(policy.prefixes).toContainEqual({ alt: false, @@ -51,7 +56,7 @@ describe("buildBrowserKeyboardPolicy", () => { bindings, chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null }, isMac: false, - isDesktop: true, + usesDesktopBindings: true, }); expect(policy.prefixes).toEqual([ @@ -85,7 +90,7 @@ describe("buildBrowserKeyboardPolicy", () => { context: { commandCenterOpen: false, focusScope: "browser", - isDesktop: true, + usesDesktopBindings: true, isMac: false, }, chordState: { candidateIndices: [chordIndex], step: 1, timeoutId: null }, @@ -101,7 +106,11 @@ describe("buildBrowserKeyboardPolicy", () => { "settings-toggle-ctrl-comma-non-mac": "Ctrl+F10 F9", }); - const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ + bindings, + isMac: false, + usesDesktopBindings: true, + }); expect(policy.prefixes).not.toContainEqual({ alt: false, @@ -125,7 +134,7 @@ describe("buildBrowserKeyboardPolicy", () => { }); expect( - buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }).prefixes, + buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true }).prefixes, ).toContainEqual({ alt: false, code: "KeyY", @@ -141,7 +150,11 @@ describe("buildBrowserKeyboardPolicy", () => { "workspace-tab-close-current-ctrl-w-non-mac": "Ctrl+Y", }); - const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ + bindings, + isMac: false, + usesDesktopBindings: true, + }); expect(policy.prefixes).not.toContainEqual({ alt: false, @@ -164,7 +177,7 @@ describe("buildBrowserKeyboardPolicy", () => { it("leaves macOS browser back and forward shortcuts in the guest", () => { const bindings = buildEffectiveBindings({}); - const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true }); for (const code of ["BracketLeft", "BracketRight"]) { expect(policy.prefixes).not.toContainEqual({ @@ -180,7 +193,7 @@ describe("buildBrowserKeyboardPolicy", () => { it("marks editable-only exclusions for enforcement inside the guest", () => { const bindings = buildEffectiveBindings({}); - const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true }); expect(policy.prefixes).toContainEqual({ alt: false, @@ -194,7 +207,11 @@ describe("buildBrowserKeyboardPolicy", () => { it("does not publish plain browser keys", () => { const bindings = buildEffectiveBindings({}); - const policy = buildBrowserKeyboardPolicy({ bindings, isMac: false, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ + bindings, + isMac: false, + usesDesktopBindings: true, + }); expect(policy.prefixes).not.toContainEqual({ alt: false, @@ -214,7 +231,7 @@ describe("buildBrowserKeyboardPolicy", () => { it("publishes Cmd+B with its logical key for non-QWERTY layouts", () => { const bindings = buildEffectiveBindings({}); - const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true }); expect(policy.prefixes).toContainEqual({ alt: false, @@ -228,7 +245,7 @@ describe("buildBrowserKeyboardPolicy", () => { it("publishes the physical code needed for macOS Option shortcuts", () => { const bindings = buildEffectiveBindings({}); - const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, isDesktop: true }); + const policy = buildBrowserKeyboardPolicy({ bindings, isMac: true, usesDesktopBindings: true }); expect(policy.prefixes).toContainEqual({ alt: true, @@ -320,3 +337,49 @@ describe("parseBrowserShortcutInput", () => { expect(parseBrowserShortcutInput(payload)).toBeNull(); }); }); + +describe("Electron guest source through shared resolution", () => { + it("replays guest input through user rebinds and preserves focus restoration", async () => { + const { createKeyboardPipeline } = await import("../pipeline"); + const { createOverlayRegistry } = await import("../overlays"); + const actions: string[] = []; + const restored: string[] = []; + let receive: (payload: unknown) => void = () => {}; + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }), + getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }), + isCapturing: () => false, + overlays: createOverlayRegistry(), + dispatch: (match, input) => { + actions.push(match.action); + input.captureFocusRestore(); + return true; + }, + }); + const detach = attachElectronBrowserSource( + { + subscribe(handler) { + receive = handler; + return () => { + receive = () => {}; + }; + }, + captureFocusRestore: (id) => restored.push(id), + }, + { keyDown: pipeline.keyDown, keyUp() {}, blur() {}, commandCenterOpen: () => false }, + ); + receive({ + browserId: "tab-1", + key: "j", + code: "KeyJ", + alt: false, + control: false, + meta: true, + shift: false, + }); + expect(actions).toEqual(["command-center.toggle"]); + expect(restored).toEqual(["tab-1"]); + detach(); + pipeline.dispose(); + }); +}); diff --git a/packages/app/src/desktop/browser/shortcuts.ts b/packages/app/src/keyboard/internal/sources/electron-browser.ts similarity index 84% rename from packages/app/src/desktop/browser/shortcuts.ts rename to packages/app/src/keyboard/internal/sources/electron-browser.ts index 2bd7def5ed..64c6edb754 100644 --- a/packages/app/src/desktop/browser/shortcuts.ts +++ b/packages/app/src/keyboard/internal/sources/electron-browser.ts @@ -3,8 +3,8 @@ import { matchesKeyboardShortcutContext, type KeyboardShortcutInput, type ParsedShortcutBinding, -} from "../../keyboard/keyboard-shortcuts"; -import type { KeyCombo } from "../../keyboard/shortcut-string"; +} from "../../keyboard-shortcuts"; +import type { KeyCombo } from "../../shortcut-string"; export interface BrowserShortcutPrefix { alt: boolean; @@ -32,7 +32,7 @@ interface BrowserShortcutPolicyInput { bindings: readonly ParsedShortcutBinding[]; chordState?: ChordState; isMac: boolean; - isDesktop: boolean; + usesDesktopBindings: boolean; } export function shouldPublishBrowserShortcutPolicy(input: { @@ -149,7 +149,7 @@ function buildBrowserShortcutPrefixes(input: BrowserShortcutPolicyInput): Browse const prefixes = new Map(); const context = { isMac: input.isMac, - isDesktop: input.isDesktop, + usesDesktopBindings: input.usesDesktopBindings, focusScope: "browser" as const, commandCenterOpen: false, }; @@ -209,3 +209,26 @@ export function buildBrowserKeyboardPolicy( } return { menuPrefixes, prefixes }; } + +interface ElectronBrowserKeySource { + subscribe(handler: (payload: unknown) => void): (() => void) | Promise<() => void>; + captureFocusRestore(browserId: string): void; +} +export function attachElectronBrowserSource( + source: ElectronBrowserKeySource, + handlers: import("./contract").KeyboardSourceHandlers, +): () => void { + const subscription = source.subscribe((payload) => { + const input = parseBrowserShortcutInput(payload); + if (!input) return; + handlers.keyDown({ + event: { ...input, preventDefault() {}, stopPropagation() {} }, + focusScope: "browser", + captureFocusRestore: () => source.captureFocusRestore(input.browserId), + }); + }); + return () => { + if (typeof subscription === "function") subscription(); + else void subscription.then((dispose) => dispose()); + }; +} diff --git a/packages/app/src/keyboard/internal/sources/hardware.android.ts b/packages/app/src/keyboard/internal/sources/hardware.android.ts new file mode 100644 index 0000000000..cfe7a15cb9 --- /dev/null +++ b/packages/app/src/keyboard/internal/sources/hardware.android.ts @@ -0,0 +1,29 @@ +import { requireNativeModule } from "expo-modules-core"; +import type { HardwareKeyboardEvent } from "./hardware"; +interface HardwareKeyboardModule { + getHardwareKeyboardPresent(): boolean; + addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void }; + setOverlayKeys(keys: readonly string[]): void; + addListener( + event: "onHardwareKeyboardShortcut", + handler: (event: HardwareKeyboardEvent) => void, + ): { remove(): void }; +} +const module = requireNativeModule("PaseoHardwareKeyboard"); +export function subscribeHardwareKeyboard( + handler: (event: HardwareKeyboardEvent) => void, +): () => void { + const subscription = module.addListener("onHardwareKeyboardShortcut", handler); + return () => subscription.remove(); +} +export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void { + module.setOverlayKeys(keys); +} + +export function getHardwareKeyboardPresent(): boolean { + return module.getHardwareKeyboardPresent(); +} +export function subscribeHardwareKeyboardPresence(changed: () => void): () => void { + const subscription = module.addListener("onHardwareKeyboardPresenceChanged", changed); + return () => subscription.remove(); +} diff --git a/packages/app/src/keyboard/internal/sources/hardware.ios.ts b/packages/app/src/keyboard/internal/sources/hardware.ios.ts new file mode 100644 index 0000000000..cfe7a15cb9 --- /dev/null +++ b/packages/app/src/keyboard/internal/sources/hardware.ios.ts @@ -0,0 +1,29 @@ +import { requireNativeModule } from "expo-modules-core"; +import type { HardwareKeyboardEvent } from "./hardware"; +interface HardwareKeyboardModule { + getHardwareKeyboardPresent(): boolean; + addListener(event: "onHardwareKeyboardPresenceChanged", handler: () => void): { remove(): void }; + setOverlayKeys(keys: readonly string[]): void; + addListener( + event: "onHardwareKeyboardShortcut", + handler: (event: HardwareKeyboardEvent) => void, + ): { remove(): void }; +} +const module = requireNativeModule("PaseoHardwareKeyboard"); +export function subscribeHardwareKeyboard( + handler: (event: HardwareKeyboardEvent) => void, +): () => void { + const subscription = module.addListener("onHardwareKeyboardShortcut", handler); + return () => subscription.remove(); +} +export function setHardwareKeyboardOverlayKeys(keys: readonly string[]): void { + module.setOverlayKeys(keys); +} + +export function getHardwareKeyboardPresent(): boolean { + return module.getHardwareKeyboardPresent(); +} +export function subscribeHardwareKeyboardPresence(changed: () => void): () => void { + const subscription = module.addListener("onHardwareKeyboardPresenceChanged", changed); + return () => subscription.remove(); +} diff --git a/packages/app/src/keyboard/internal/sources/hardware.ts b/packages/app/src/keyboard/internal/sources/hardware.ts new file mode 100644 index 0000000000..c6631886d1 --- /dev/null +++ b/packages/app/src/keyboard/internal/sources/hardware.ts @@ -0,0 +1,18 @@ +import type { KeyboardShortcutInput } from "../../keyboard-shortcuts"; +export interface HardwareKeyboardEvent extends KeyboardShortcutInput { + isComposing: boolean; + phase: "down" | "up"; +} +export function subscribeHardwareKeyboard( + _handler: (event: HardwareKeyboardEvent) => void, +): () => void { + return () => {}; +} +export function setHardwareKeyboardOverlayKeys(_keys: readonly string[]): void {} + +export function getHardwareKeyboardPresent(): boolean { + return false; +} +export function subscribeHardwareKeyboardPresence(_changed: () => void): () => void { + return () => {}; +} diff --git a/packages/app/src/keyboard/internal/sources/index.ts b/packages/app/src/keyboard/internal/sources/index.ts new file mode 100644 index 0000000000..5d886b979a --- /dev/null +++ b/packages/app/src/keyboard/internal/sources/index.ts @@ -0,0 +1,11 @@ +import type { KeyboardSourceHandlers } from "./contract"; +import { setHardwareKeyboardOverlayKeys, subscribeHardwareKeyboard } from "./hardware"; +import { attachHardwareKeyboardSource } from "./native"; +export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void { + return attachHardwareKeyboardSource( + { subscribe: subscribeHardwareKeyboard, setOverlayKeys: setHardwareKeyboardOverlayKeys }, + handlers, + ); +} + +export function publishKeyboardPolicy(_input: import("./contract").KeyboardPolicyInput): void {} diff --git a/packages/app/src/keyboard/internal/sources/index.web.ts b/packages/app/src/keyboard/internal/sources/index.web.ts new file mode 100644 index 0000000000..da6023be5b --- /dev/null +++ b/packages/app/src/keyboard/internal/sources/index.web.ts @@ -0,0 +1,32 @@ +import { usesDesktopBindings } from "../conventions"; +import { getDesktopHost } from "@/desktop/host"; +import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews"; +import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; +import { buildBrowserKeyboardPolicy, attachElectronBrowserSource } from "./electron-browser"; +import { attachDomKeyboardSource } from "./dom.web"; +import type { KeyboardSourceHandlers } from "./contract"; +export function attachKeyboardSources(handlers: KeyboardSourceHandlers): () => void { + const detachDom = attachDomKeyboardSource(handlers); + const host = getDesktopHost(); + const subscribeGuest = host?.events?.on; + const detachGuest = subscribeGuest + ? attachElectronBrowserSource( + { + subscribe: (handler) => subscribeGuest("browser-shortcut-input", handler), + captureFocusRestore: (browserId) => + setCommandCenterFocusRestoreElement(getResidentBrowserWebview(browserId)), + }, + handlers, + ) + : () => {}; + return () => { + detachDom(); + detachGuest(); + }; +} +export function publishKeyboardPolicy(input: import("./contract").KeyboardPolicyInput): void { + const policy = input.enabled + ? buildBrowserKeyboardPolicy({ ...input, usesDesktopBindings: usesDesktopBindings }) + : { menuPrefixes: [], prefixes: [] }; + void getDesktopHost()?.browser?.setShortcutPolicy?.(policy); +} diff --git a/packages/app/src/keyboard/internal/sources/native.ts b/packages/app/src/keyboard/internal/sources/native.ts new file mode 100644 index 0000000000..995e9df491 --- /dev/null +++ b/packages/app/src/keyboard/internal/sources/native.ts @@ -0,0 +1,31 @@ +import { focusSurfaces } from "../focus-surfaces"; +import { overlays } from "../overlays"; +import { nativeKeyboardInput, type KeyboardSourceHandlers } from "./contract"; +import type { HardwareKeyboardEvent } from "./hardware"; +interface HardwareKeySource { + subscribe(handler: (event: HardwareKeyboardEvent) => void): () => void; + setOverlayKeys(keys: readonly string[]): void; +} +export function attachHardwareKeyboardSource( + source: HardwareKeySource, + handlers: KeyboardSourceHandlers, +): () => void { + const updateKeys = () => source.setOverlayKeys(overlays.getKeys()); + updateKeys(); + const unsubscribeOverlays = overlays.subscribe(updateKeys); + const unsubscribeHardware = source.subscribe((input) => { + const event = { ...input, preventDefault() {}, stopPropagation() {} }; + if (input.phase === "up") { + handlers.keyUp(event); + return; + } + handlers.keyDown( + nativeKeyboardInput(event, focusSurfaces.resolve(handlers.commandCenterOpen())), + ); + }); + return () => { + unsubscribeHardware(); + unsubscribeOverlays(); + source.setOverlayKeys([]); + }; +} diff --git a/packages/app/src/keyboard/internal/use-focus-surface.ts b/packages/app/src/keyboard/internal/use-focus-surface.ts new file mode 100644 index 0000000000..e10514285e --- /dev/null +++ b/packages/app/src/keyboard/internal/use-focus-surface.ts @@ -0,0 +1,7 @@ +import { useLayoutEffect } from "react"; +import { focusSurfaces, type KeyboardFocusSurface } from "./focus-surfaces"; +export function useKeyboardFocusSurface(surface: KeyboardFocusSurface, focused: boolean): void { + useLayoutEffect(() => { + if (focused) return focusSurfaces.declare(surface); + }, [surface, focused]); +} diff --git a/packages/app/src/keyboard/internal/use-focus-surface.web.ts b/packages/app/src/keyboard/internal/use-focus-surface.web.ts new file mode 100644 index 0000000000..6f74924a81 --- /dev/null +++ b/packages/app/src/keyboard/internal/use-focus-surface.web.ts @@ -0,0 +1,2 @@ +import type { KeyboardFocusSurface } from "./focus-surfaces"; +export function useKeyboardFocusSurface(_surface: KeyboardFocusSurface, _focused: boolean): void {} diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 770d7e710c..29179901ee 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -40,7 +40,7 @@ function shortcutContext( ): KeyboardShortcutContext { return { isMac: false, - isDesktop: false, + usesDesktopBindings: false, focusScope: "other", commandCenterOpen: false, ...overrides, @@ -128,7 +128,7 @@ interface HelpSectionCase { name: string; context: { isMac: boolean; - isDesktop: boolean; + usesDesktopBindings: boolean; }; expectedKeys: Record; } @@ -174,49 +174,49 @@ describe("keyboard-shortcuts", () => { { name: "matches workspace index jump on web via Alt+digit", event: { key: "2", code: "Digit2", altKey: true }, - context: { isDesktop: false }, + context: { usesDesktopBindings: false }, action: "workspace.navigate.index", payload: { index: 2 }, }, { name: "matches workspace index jump on desktop via Mod+digit", event: { key: "2", code: "Digit2", metaKey: true }, - context: { isMac: true, isDesktop: true }, + context: { isMac: true, usesDesktopBindings: true }, action: "workspace.navigate.index", payload: { index: 2 }, }, { name: "matches tab index jump on mac desktop via Cmd+Alt+digit", event: { key: "@", code: "Digit2", metaKey: true, altKey: true }, - context: { isMac: true, isDesktop: true }, + context: { isMac: true, usesDesktopBindings: true }, action: "workspace.tab.navigate.index", payload: { index: 2 }, }, { name: "matches tab index jump on non-mac desktop via Alt+digit", event: { key: "2", code: "Digit2", altKey: true }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, action: "workspace.tab.navigate.index", payload: { index: 2 }, }, { name: "matches tab index jump on web via Alt+Shift+digit", event: { key: "@", code: "Digit2", altKey: true, shiftKey: true }, - context: { isDesktop: false }, + context: { usesDesktopBindings: false }, action: "workspace.tab.navigate.index", payload: { index: 2 }, }, { name: "matches workspace relative navigation on web via Alt+[", event: { key: "[", code: "BracketLeft", altKey: true }, - context: { isDesktop: false }, + context: { usesDesktopBindings: false }, action: "workspace.navigate.relative", payload: { delta: -1 }, }, { name: "matches workspace relative navigation on desktop via Mod+]", event: { key: "]", code: "BracketRight", ctrlKey: true }, - context: { isDesktop: true }, + context: { usesDesktopBindings: true }, action: "workspace.navigate.relative", payload: { delta: 1 }, }, @@ -235,19 +235,19 @@ describe("keyboard-shortcuts", () => { { name: "matches Alt+Shift+W to close current tab on web", event: { key: "W", code: "KeyW", altKey: true, shiftKey: true }, - context: { isDesktop: false }, + context: { usesDesktopBindings: false }, action: "workspace.tab.close.current", }, { name: "matches Cmd+W to close current tab on mac desktop", event: { key: "w", code: "KeyW", metaKey: true }, - context: { isMac: true, isDesktop: true }, + context: { isMac: true, usesDesktopBindings: true }, action: "workspace.tab.close.current", }, { name: "matches Ctrl+W to close current tab on non-mac desktop", event: { key: "w", code: "KeyW", ctrlKey: true }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, action: "workspace.tab.close.current", }, { @@ -371,7 +371,7 @@ describe("keyboard-shortcuts", () => { { name: "matches Alt+[ to previous workspace on macOS web when Option substitutes event.key", event: { key: "\u201C", code: "BracketLeft", altKey: true }, - context: { isMac: true, isDesktop: false }, + context: { isMac: true, usesDesktopBindings: false }, action: "workspace.navigate.relative", payload: { delta: -1 }, preventDefault: true, @@ -380,7 +380,7 @@ describe("keyboard-shortcuts", () => { { name: "matches Alt+] to next workspace on macOS web when Option substitutes event.key", event: { key: "\u2018", code: "BracketRight", altKey: true }, - context: { isMac: true, isDesktop: false }, + context: { isMac: true, usesDesktopBindings: false }, action: "workspace.navigate.relative", payload: { delta: 1 }, preventDefault: true, @@ -389,7 +389,7 @@ describe("keyboard-shortcuts", () => { { name: "matches Alt+Shift+W to close current tab on macOS web when Option substitutes event.key", event: { key: "\u201E", code: "KeyW", altKey: true, shiftKey: true }, - context: { isMac: true, isDesktop: false }, + context: { isMac: true, usesDesktopBindings: false }, action: "workspace.tab.close.current", }, ]; @@ -453,12 +453,12 @@ describe("keyboard-shortcuts", () => { { name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)", event: { key: "w", code: "KeyW", ctrlKey: true }, - context: { isMac: true, isDesktop: true }, + context: { isMac: true, usesDesktopBindings: true }, }, { name: "does not close tab with Ctrl+W on non-mac desktop when terminal is focused", event: { key: "w", code: "KeyW", ctrlKey: true }, - context: { isMac: false, isDesktop: true, focusScope: "terminal" }, + context: { isMac: false, usesDesktopBindings: true, focusScope: "terminal" }, }, { name: "does not match Ctrl+T on mac (Cmd only)", @@ -468,7 +468,7 @@ describe("keyboard-shortcuts", () => { { name: "keeps mac Option+digit available for international text input", event: { key: "@", code: "Digit2", altKey: true }, - context: { isMac: true, isDesktop: true, focusScope: "message-input" }, + context: { isMac: true, usesDesktopBindings: true, focusScope: "message-input" }, }, { name: "does not match Ctrl+K for command center on non-mac in terminal", @@ -538,7 +538,7 @@ describe("keyboard-shortcuts", () => { { name: "keeps Dvorak Cmd+V available for paste in message input", event: { key: "v", code: "Period", metaKey: true }, - context: { isMac: true, isDesktop: true, focusScope: "message-input" }, + context: { isMac: true, usesDesktopBindings: true, focusScope: "message-input" }, }, // Sanity: the macOS Option-substitution fallback must still respect // modifier checks — pressing Option+T alone (no Cmd) must not trigger @@ -595,7 +595,7 @@ describe("keyboard-shortcuts", () => { const firstResult = resolveShortcut({ event: { key: "w", code: "KeyW", ctrlKey: true }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, bindings, }); @@ -606,7 +606,7 @@ describe("keyboard-shortcuts", () => { const secondResult = resolveShortcut({ event: { key: "s", code: "KeyS" }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, chordState: firstResult.nextChordState, bindings, }); @@ -622,7 +622,7 @@ describe("keyboard-shortcuts", () => { it("resolves a browser-origin shortcut with browser focus instead of host focus", () => { expectShortcutResolution({ event: { key: "t", code: "KeyT", ctrlKey: true }, - context: { isDesktop: true, focusScope: "browser" }, + context: { usesDesktopBindings: true, focusScope: "browser" }, action: "workspace.tab.menu.open", }); }); @@ -636,12 +636,12 @@ describe("keyboard-shortcuts", () => { // so the stream for Ctrl+K, release, Ctrl+J is four keydowns, not two. const firstModifier = resolveShortcut({ event: { key: "Control", code: "ControlLeft", ctrlKey: true }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, bindings, }); const firstStep = resolveShortcut({ event: { key: "k", code: "KeyK", ctrlKey: true }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, chordState: firstModifier.nextChordState, bindings, }); @@ -649,13 +649,13 @@ describe("keyboard-shortcuts", () => { const secondModifier = resolveShortcut({ event: { key: "Control", code: "ControlLeft", ctrlKey: true }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, chordState: firstStep.nextChordState, bindings, }); const secondStep = resolveShortcut({ event: { key: "j", code: "KeyJ", ctrlKey: true }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, chordState: secondModifier.nextChordState, bindings, }); @@ -673,7 +673,7 @@ describe("keyboard-shortcuts", () => { const result = resolveShortcut({ event: { key: "w", code: "KeyW", ctrlKey: true }, - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, onChordReset, bindings, }); @@ -703,7 +703,7 @@ describe("keyboard-shortcut help sections", () => { const helpCases: HelpSectionCase[] = [ { name: "uses web defaults for workspace and tab jump", - context: { isMac: true, isDesktop: false }, + context: { isMac: true, usesDesktopBindings: false }, expectedKeys: { "new-agent": ["mod", "O"], "workspace-tab-new": ["mod", "T"], @@ -717,7 +717,7 @@ describe("keyboard-shortcut help sections", () => { }, { name: "uses desktop defaults for workspace and tab jump", - context: { isMac: true, isDesktop: true }, + context: { isMac: true, usesDesktopBindings: true }, expectedKeys: { "new-agent": ["mod", "O"], "new-workspace": ["mod", "N"], @@ -735,7 +735,7 @@ describe("keyboard-shortcut help sections", () => { }, { name: "uses non-mac desktop defaults for tab jump and close tab", - context: { isMac: false, isDesktop: true }, + context: { isMac: false, usesDesktopBindings: true }, expectedKeys: { "workspace-tab-jump-index": ["alt", "1-9"], "workspace-tab-close-current": ["ctrl", "W"], @@ -743,7 +743,7 @@ describe("keyboard-shortcut help sections", () => { }, { name: "uses ctrl+b for the left sidebar and ctrl+period for both sidebars on non-mac", - context: { isMac: false, isDesktop: false }, + context: { isMac: false, usesDesktopBindings: false }, // Derived from `combo: "Ctrl+B"` / `"Ctrl+."`, so the token is `ctrl` where // these rows used to be hand-authored as `mod`. Both bindings are non-mac // only and `formatShortcut` labels either token "Ctrl" there, so the badge @@ -764,7 +764,7 @@ describe("keyboard-shortcut help sections", () => { }); describe("rows derive their keys from the binding that fires", () => { - const macDesktop = { isMac: true, isDesktop: true }; + const macDesktop = { isMac: true, usesDesktopBindings: true }; const NEW_WORKSPACE_BINDING = "workspace-new-cmd-n-mac"; const MAC_INDEX_BINDING = "workspace-navigate-index-cmd-digit-mac"; const PANE_FOCUS_LEFT_BINDING = "workspace-pane-focus-left-cmd-shift-left"; @@ -849,7 +849,10 @@ describe("keyboard-shortcut help sections", () => { // Same story on the other side: these rows said `mod`, the non-mac combos // say `Ctrl`, and non-mac labels both as "Ctrl". it("still renders the sidebar toggles as Ctrl+B and Ctrl+. on non-mac", () => { - const sections = buildKeyboardShortcutHelpSections({ isMac: false, isDesktop: false }); + const sections = buildKeyboardShortcutHelpSections({ + isMac: false, + usesDesktopBindings: false, + }); const left = findRow(sections, "toggle-left-sidebar")?.chord; const both = findRow(sections, "toggle-both-sidebars")?.chord; expect(formatShortcut(left?.[0] ?? [], "non-mac")).toBe("Ctrl+B"); @@ -858,7 +861,7 @@ describe("keyboard-shortcut help sections", () => { }); it("returns stable i18n keys for section titles and help rows", () => { - const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); + const sections = buildKeyboardShortcutHelpSections({ isMac: true, usesDesktopBindings: true }); const workspaces = sections.find((section) => section.id === "workspaces"); const layout = sections.find((section) => section.id === "layout"); const openProject = findRow(sections, "new-agent"); @@ -875,10 +878,10 @@ describe("keyboard-shortcut help sections", () => { it("gives every help row an explicit place in its section's order", () => { const platforms = [ - { isMac: true, isDesktop: true }, - { isMac: false, isDesktop: true }, - { isMac: true, isDesktop: false }, - { isMac: false, isDesktop: false }, + { isMac: true, usesDesktopBindings: true }, + { isMac: false, usesDesktopBindings: true }, + { isMac: true, usesDesktopBindings: false }, + { isMac: false, usesDesktopBindings: false }, ]; const unplaced: string[] = []; for (const platform of platforms) { @@ -894,7 +897,7 @@ describe("keyboard-shortcut help sections", () => { }); it("leads the general section with the command center and file search", () => { - const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); + const sections = buildKeyboardShortcutHelpSections({ isMac: true, usesDesktopBindings: true }); expect(sections[0]?.id).toBe("general"); expect(sections[0]?.rows.slice(0, 2).map((row) => row.id)).toEqual([ @@ -904,28 +907,30 @@ describe("keyboard-shortcut help sections", () => { }); it("reuses the project-picker binding ids for rebindable file search", () => { - expect(getBindingIdForAction("search-files", { isMac: true, isDesktop: true })).toBe( + expect(getBindingIdForAction("search-files", { isMac: true, usesDesktopBindings: true })).toBe( "workspace-project-pick-cmd-p-mac", ); - expect(getBindingIdForAction("search-files", { isMac: false, isDesktop: true })).toBe( + expect(getBindingIdForAction("search-files", { isMac: false, usesDesktopBindings: true })).toBe( "workspace-project-pick-ctrl-p-non-mac", ); expect( - findRow(buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }), "search-files") - ?.chord, + findRow( + buildKeyboardShortcutHelpSections({ isMac: true, usesDesktopBindings: true }), + "search-files", + )?.chord, ).not.toBeNull(); }); it("does not expose Enter send behavior as rebindable shortcut rows", () => { - const sections = buildKeyboardShortcutHelpSections({ isMac: true, isDesktop: true }); + const sections = buildKeyboardShortcutHelpSections({ isMac: true, usesDesktopBindings: true }); expect(findRow(sections, "message-input-send")).toBeNull(); expect(findRow(sections, "message-input-queue")).toBeNull(); expect( - getBindingIdForAction("message-input-send", { isMac: true, isDesktop: true }), + getBindingIdForAction("message-input-send", { isMac: true, usesDesktopBindings: true }), ).toBeNull(); expect( - getBindingIdForAction("message-input-queue", { isMac: true, isDesktop: true }), + getBindingIdForAction("message-input-queue", { isMac: true, usesDesktopBindings: true }), ).toBeNull(); }); }); @@ -934,45 +939,61 @@ describe("getWorkspaceIndexJumpModifierKey", () => { const MAC_INDEX_BINDING = "workspace-navigate-index-cmd-digit-mac"; it("uses Alt on web, regardless of OS", () => { - expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: false })).toBe("Alt"); - expect(getWorkspaceIndexJumpModifierKey({ isMac: false, isDesktop: false })).toBe("Alt"); - }); - - it("uses Cmd (Meta) on desktop Mac, not Control or Alt", () => { - expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true })).toBe("Meta"); - }); - - it("uses Ctrl on desktop non-Mac, not Meta or Alt", () => { - expect(getWorkspaceIndexJumpModifierKey({ isMac: false, isDesktop: true })).toBe("Control"); - }); - - it("derives the modifier from the effective binding, not the platform", () => { - const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Alt+Digit" }); - expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBe( + expect(getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: false })).toBe( + "Alt", + ); + expect(getWorkspaceIndexJumpModifierKey({ isMac: false, usesDesktopBindings: false })).toBe( "Alt", ); }); + it("uses Cmd (Meta) on desktop Mac, not Control or Alt", () => { + expect(getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true })).toBe( + "Meta", + ); + }); + + it("uses Ctrl on desktop non-Mac, not Meta or Alt", () => { + expect(getWorkspaceIndexJumpModifierKey({ isMac: false, usesDesktopBindings: true })).toBe( + "Control", + ); + }); + + it("derives the modifier from the effective binding, not the platform", () => { + const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Alt+Digit" }); + expect( + getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings), + ).toBe("Alt"); + }); + it("suppresses the badges when the jump shortcut is unassigned", () => { const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: UNASSIGNED_COMBO }); - expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull(); + expect( + getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings), + ).toBeNull(); }); it("suppresses the badges when the jump shortcut is rebound to one concrete digit", () => { // Capture can only ever produce a concrete digit, never the 1-9 wildcard, // so the badges would advertise eight workspaces that no longer respond. const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+3" }); - expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull(); + expect( + getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings), + ).toBeNull(); }); it("suppresses the badges for a multi-step chord", () => { const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+K Cmd+Digit" }); - expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull(); + expect( + getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings), + ).toBeNull(); }); it("suppresses the badges when the combo needs a second modifier", () => { const bindings = buildEffectiveBindings({ [MAC_INDEX_BINDING]: "Cmd+Shift+Digit" }); - expect(getWorkspaceIndexJumpModifierKey({ isMac: true, isDesktop: true }, bindings)).toBeNull(); + expect( + getWorkspaceIndexJumpModifierKey({ isMac: true, usesDesktopBindings: true }, bindings), + ).toBeNull(); }); }); @@ -999,7 +1020,7 @@ function withoutDefaultCombo(helpId: string): ParsedShortcutBinding[] { describe("unassigned shortcuts", () => { const TAB_NEW_BINDING = "workspace-tab-new-ctrl-t-non-mac"; - const desktopNonMac = { isMac: false, isDesktop: true }; + const desktopNonMac = { isMac: false, usesDesktopBindings: true }; function findRow(sections: ReturnType, id: string) { for (const section of sections) { @@ -1102,13 +1123,13 @@ describe("unassigned shortcuts", () => { const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! }); expect( - resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match - ?.action, + resolveShortcut({ event, context: { isMac: true, usesDesktopBindings: true }, bindings }) + .match?.action, ).toBe("sidebar.toggle.left"); expect( resolveShortcut({ event: { key: "x", code: "KeyB", metaKey: true }, - context: { isMac: true, isDesktop: true }, + context: { isMac: true, usesDesktopBindings: true }, bindings, }).match, ).toBeNull(); @@ -1293,7 +1314,7 @@ describe("unassigned shortcuts", () => { }); describe("direct new-tab target shortcuts", () => { - const desktopNonMac = { isMac: false, isDesktop: true }; + const desktopNonMac = { isMac: false, usesDesktopBindings: true }; const targetCases = [ ["a", "KeyA", "workspace.tab.target.agent"], ["b", "KeyB", "workspace.tab.target.browser"], @@ -1323,7 +1344,7 @@ describe("direct new-tab target shortcuts", () => { it.each(targetCases)("routes Cmd+Shift+%s directly to %s", (key, code, action) => { const result = resolveShortcut({ event: { key, code, metaKey: true, shiftKey: true }, - context: { isMac: true, isDesktop: true, focusScope: "other" }, + context: { isMac: true, usesDesktopBindings: true, focusScope: "other" }, bindings: buildEffectiveBindings({}), }); expect(result.match?.action).toBe(action); @@ -1368,19 +1389,19 @@ describe("number-group shortcut capture", () => { for (let index = 1; index <= 9; index++) { const result = resolveShortcut({ event: { key: String(index), code: `Digit${index}`, ctrlKey: true }, - context: { isMac: true, isDesktop: true, focusScope: "message-input" }, + context: { isMac: true, usesDesktopBindings: true, focusScope: "message-input" }, bindings, }); expect(result.match?.action).toBe(action); expect(result.match?.payload).toEqual({ index }); } - expect(resolveShortcutKeysForAction(row, overrides, { isMac: true, isDesktop: true })).toEqual([ - ["ctrl", "1-9"], - ]); + expect( + resolveShortcutKeysForAction(row, overrides, { isMac: true, usesDesktopBindings: true }), + ).toEqual([["ctrl", "1-9"]]); expect( resolveShortcut({ event: { key: "0", code: "Digit0", ctrlKey: true }, - context: { isMac: true, isDesktop: true }, + context: { isMac: true, usesDesktopBindings: true }, bindings, }).match, ).toBeNull(); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 9f6329c013..b55e78fc25 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -18,7 +18,7 @@ export type { KeyCombo } from "@/keyboard/shortcut-string"; export interface KeyboardShortcutContext { isMac: boolean; - isDesktop: boolean; + usesDesktopBindings: boolean; focusScope: KeyboardFocusScope; commandCenterOpen: boolean; } @@ -63,14 +63,14 @@ export interface KeyboardShortcutHelpSection { interface KeyboardShortcutPlatformContext { isMac: boolean; - isDesktop: boolean; + usesDesktopBindings: boolean; } interface ShortcutWhen { /** true = mac only, false = non-mac only */ mac?: boolean; - /** true = desktop only, false = web only */ - desktop?: boolean; + /** Desktop keyboard conventions on Electron/native; browser conventions otherwise. */ + desktopBindings?: boolean; /** false = disabled when a text-editing surface is focused */ editable?: false; /** false = disabled when terminal is focused */ @@ -502,7 +502,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-tab-close-current-cmd-w-mac", action: "workspace.tab.close.current", combo: "Cmd+W", - when: { mac: true, desktop: true, commandCenter: false }, + when: { mac: true, desktopBindings: true, commandCenter: false }, help: { id: "workspace-tab-close-current", section: "tabs-panes", @@ -513,7 +513,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-tab-close-current-ctrl-w-non-mac", action: "workspace.tab.close.current", combo: "Ctrl+W", - when: { mac: false, desktop: true, commandCenter: false, terminal: false }, + when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false }, help: { id: "workspace-tab-close-current", section: "tabs-panes", @@ -524,7 +524,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-tab-close-current-alt-shift-w-web", action: "workspace.tab.close.current", combo: "Alt+Shift+W", - when: { desktop: false, commandCenter: false }, + when: { desktopBindings: false, commandCenter: false }, help: { id: "workspace-tab-close-current", section: "tabs-panes", @@ -537,7 +537,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-index-cmd-digit-mac", action: "workspace.navigate.index", combo: "Cmd+Digit", - when: { mac: true, desktop: true, commandCenter: false }, + when: { mac: true, desktopBindings: true, commandCenter: false }, payload: { type: "index" }, help: { id: "workspace-jump-index", @@ -550,7 +550,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-index-ctrl-digit-non-mac", action: "workspace.navigate.index", combo: "Ctrl+Digit", - when: { mac: false, desktop: true, commandCenter: false, terminal: false }, + when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false }, payload: { type: "index" }, help: { id: "workspace-jump-index", @@ -563,7 +563,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-index-alt-digit-web", action: "workspace.navigate.index", combo: "Alt+Digit", - when: { desktop: false, commandCenter: false }, + when: { desktopBindings: false, commandCenter: false }, payload: { type: "index" }, help: { id: "workspace-jump-index", @@ -578,7 +578,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-tab-navigate-index-cmd-alt-digit-mac-desktop", action: "workspace.tab.navigate.index", combo: "Cmd+Alt+Digit", - when: { mac: true, desktop: true, commandCenter: false }, + when: { mac: true, desktopBindings: true, commandCenter: false }, payload: { type: "index" }, help: { id: "workspace-tab-jump-index", @@ -591,7 +591,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-tab-navigate-index-alt-digit-desktop", action: "workspace.tab.navigate.index", combo: "Alt+Digit", - when: { mac: false, desktop: true, commandCenter: false }, + when: { mac: false, desktopBindings: true, commandCenter: false }, payload: { type: "index" }, help: { id: "workspace-tab-jump-index", @@ -604,7 +604,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-tab-navigate-index-alt-shift-digit-web", action: "workspace.tab.navigate.index", combo: "Alt+Shift+Digit", - when: { desktop: false, commandCenter: false }, + when: { desktopBindings: false, commandCenter: false }, payload: { type: "index" }, help: { id: "workspace-tab-jump-index", @@ -619,7 +619,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-relative-cmd-left-mac", action: "workspace.navigate.relative", combo: "Cmd+[", - when: { mac: true, desktop: true, commandCenter: false }, + when: { mac: true, desktopBindings: true, commandCenter: false }, payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", @@ -631,7 +631,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-relative-ctrl-left-non-mac", action: "workspace.navigate.relative", combo: "Ctrl+[", - when: { mac: false, desktop: true, commandCenter: false, terminal: false }, + when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false }, payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", @@ -643,7 +643,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-relative-cmd-right-mac", action: "workspace.navigate.relative", combo: "Cmd+]", - when: { mac: true, desktop: true, commandCenter: false }, + when: { mac: true, desktopBindings: true, commandCenter: false }, payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", @@ -655,7 +655,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-relative-ctrl-right-non-mac", action: "workspace.navigate.relative", combo: "Ctrl+]", - when: { mac: false, desktop: true, commandCenter: false, terminal: false }, + when: { mac: false, desktopBindings: true, commandCenter: false, terminal: false }, payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", @@ -667,7 +667,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-relative-alt-left-web", action: "workspace.navigate.relative", combo: "Alt+[", - when: { desktop: false, commandCenter: false }, + when: { desktopBindings: false, commandCenter: false }, payload: { type: "delta", delta: -1 }, help: { id: "workspace-prev", @@ -679,7 +679,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "workspace-navigate-relative-alt-right-web", action: "workspace.navigate.relative", combo: "Alt+]", - when: { desktop: false, commandCenter: false }, + when: { desktopBindings: false, commandCenter: false }, payload: { type: "delta", delta: 1 }, help: { id: "workspace-next", @@ -1344,7 +1344,8 @@ export function matchesKeyboardShortcutContext( ): boolean { if (!when) return true; if (when.mac !== undefined && when.mac !== context.isMac) return false; - if (when.desktop !== undefined && when.desktop !== context.isDesktop) return false; + if (when.desktopBindings !== undefined && when.desktopBindings !== context.usesDesktopBindings) + return false; if ( when.editable === false && (context.focusScope === "message-input" || context.focusScope === "editable") @@ -1409,7 +1410,8 @@ function helpMatchesPlatform( context: KeyboardShortcutPlatformContext, ): boolean { if (when?.mac !== undefined && when.mac !== context.isMac) return false; - if (when?.desktop !== undefined && when.desktop !== context.isDesktop) return false; + if (when?.desktopBindings !== undefined && when.desktopBindings !== context.usesDesktopBindings) + return false; return true; } @@ -1573,7 +1575,7 @@ export function resolveKeyboardShortcut(input: { export function getBindingIdForAction( actionId: string, - platform: { isMac: boolean; isDesktop: boolean }, + platform: { isMac: boolean; usesDesktopBindings: boolean }, ): string | null { for (const binding of DEFAULT_BINDINGS) { if (binding.help?.id !== actionId) { @@ -1611,7 +1613,7 @@ function displayChordForBinding(binding: ParsedShortcutBinding): ShortcutKey[][] export function getDefaultKeysForAction( actionId: string, - platform: { isMac: boolean; isDesktop: boolean }, + platform: { isMac: boolean; usesDesktopBindings: boolean }, bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS, ): ShortcutKey[][] | null { for (const binding of bindings) { @@ -1638,7 +1640,7 @@ export function getDefaultKeysForAction( export function resolveShortcutKeysForAction( actionId: string, overrides: ShortcutOverrides, - platform: { isMac: boolean; isDesktop: boolean }, + platform: { isMac: boolean; usesDesktopBindings: boolean }, ): ShortcutKey[][] | null { const bindingId = getBindingIdForAction(actionId, platform); if (bindingId === null) { @@ -1680,7 +1682,7 @@ export function resolveShortcutKeysForAction( * the shortcut delivers. */ export function getWorkspaceIndexJumpModifierKey( - platform: { isMac: boolean; isDesktop: boolean }, + platform: { isMac: boolean; usesDesktopBindings: boolean }, bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS, ): "Alt" | "Meta" | "Control" | null { const binding = bindings.find(function (candidate) { diff --git a/packages/app/src/keyboard/native-shortcuts.browser.test.ts b/packages/app/src/keyboard/native-shortcuts.browser.test.ts new file mode 100644 index 0000000000..004eb912b2 --- /dev/null +++ b/packages/app/src/keyboard/native-shortcuts.browser.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { buildEffectiveBindings } from "./keyboard-shortcuts"; +import { createKeyboardPipeline } from "./internal/pipeline"; +import { createOverlayRegistry } from "./internal/overlays"; +describe("DOM source adapter", () => { + it("runs DOM input through the same resolver, preserving cancellation and cleanup", async () => { + const { attachDomKeyboardSource } = await import("./internal/sources/dom.web"); + const actions: string[] = []; + const input = document.createElement("textarea"); + input.dataset.testid = "message-input"; + document.body.append(input); + input.focus(); + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac: true, usesDesktopBindings: false, commandCenterOpen: false }), + getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }), + isCapturing: () => false, + dispatch: (match) => { + actions.push(match.action); + return true; + }, + overlays: createOverlayRegistry(), + }); + const detach = attachDomKeyboardSource({ + keyDown: pipeline.keyDown, + keyUp() {}, + blur() {}, + commandCenterOpen: () => false, + }); + const event = new KeyboardEvent("keydown", { + key: "j", + code: "KeyJ", + metaKey: true, + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(event); + expect(actions).toEqual(["command-center.toggle"]); + expect(event.defaultPrevented).toBe(true); + detach(); + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "j", code: "KeyJ", metaKey: true, bubbles: true }), + ); + expect(actions).toHaveLength(1); + input.remove(); + pipeline.dispose(); + }); +}); diff --git a/packages/app/src/keyboard/native-shortcuts.test.ts b/packages/app/src/keyboard/native-shortcuts.test.ts new file mode 100644 index 0000000000..e155fa2ba1 --- /dev/null +++ b/packages/app/src/keyboard/native-shortcuts.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { buildEffectiveBindings } from "./keyboard-shortcuts"; +import { createKeyboardPipeline } from "./internal/pipeline"; +import { createOverlayRegistry } from "./internal/overlays"; +import { nativeKeyboardInput } from "./internal/sources/contract"; + +function keyboardEvent(key: string, code: string, isMac = true) { + return { + key, + code, + altKey: false, + ctrlKey: !isMac, + metaKey: isMac, + shiftKey: false, + repeat: false, + preventDefault() {}, + stopPropagation() {}, + }; +} + +describe("shared keyboard source pipeline", () => { + it.each([true, false])("resolves hardware input and user rebinds with mac=%s", (isMac) => { + const actions: string[] = []; + const bindings = buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }); + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac, usesDesktopBindings: true, commandCenterOpen: false }), + getBindings: () => bindings, + isCapturing: () => false, + dispatch: (match) => { + actions.push(match.action); + return true; + }, + overlays: createOverlayRegistry(), + }); + pipeline.keyDown(nativeKeyboardInput(keyboardEvent("k", "KeyK", isMac), "message-input")); + expect(actions).toEqual(isMac ? [] : ["command-center.toggle"]); + pipeline.keyDown(nativeKeyboardInput(keyboardEvent("j", "KeyJ", isMac), "message-input")); + expect(actions).toEqual(["command-center.toggle"]); + pipeline.dispose(); + }); +}); + +describe("overlay and focus precedence in the source pipeline", () => { + it("dismisses an overlay without interrupting, then interrupts only the composer", () => { + const registry = createOverlayRegistry(); + const actions: string[] = []; + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }), + getBindings: () => buildEffectiveBindings({}), + isCapturing: () => false, + dispatch: (match) => { + actions.push(match.action); + return true; + }, + overlays: registry, + }); + let dismissals = 0; + const remove = registry.register({ + getLayer: () => 20, + getKeys: () => ["Escape"], + onKeyDown: (event) => { + if (event.key !== "Escape") return false; + dismissals++; + return true; + }, + }); + const escape = { ...keyboardEvent("Escape", "Escape"), metaKey: false }; + pipeline.keyDown(nativeKeyboardInput(escape, "message-input")); + expect(dismissals).toBe(1); + expect(actions).toEqual([]); + remove(); + pipeline.keyDown(nativeKeyboardInput(escape, "terminal")); + expect(actions).toEqual([]); + pipeline.keyDown(nativeKeyboardInput(escape, "message-input")); + expect(actions).toEqual(["agent.interrupt"]); + pipeline.dispose(); + }); + + it("does not turn IME confirmation into overlay selection", () => { + const registry = createOverlayRegistry(); + let selections = 0; + const remove = registry.register({ + getLayer: () => 20, + getKeys: () => ["Enter"], + onKeyDown: () => { + selections++; + return true; + }, + }); + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac: true, usesDesktopBindings: true, commandCenterOpen: false }), + getBindings: () => buildEffectiveBindings({}), + isCapturing: () => false, + dispatch: () => true, + overlays: registry, + }); + pipeline.keyDown( + nativeKeyboardInput( + { ...keyboardEvent("Enter", "Enter"), metaKey: false, isComposing: true }, + "message-input", + ), + ); + expect(selections).toBe(0); + remove(); + pipeline.dispose(); + }); + + it.each([true, false])( + "uses hardware conventions for workspace index jumps with mac=%s", + (isMac) => { + const payloads: unknown[] = []; + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac, usesDesktopBindings: true, commandCenterOpen: false }), + getBindings: () => buildEffectiveBindings({}), + isCapturing: () => false, + dispatch: (match) => { + payloads.push([match.action, match.payload]); + return true; + }, + overlays: createOverlayRegistry(), + }); + pipeline.keyDown(nativeKeyboardInput(keyboardEvent("3", "Digit3", isMac), "message-input")); + expect(payloads).toEqual([["workspace.navigate.index", { index: 3 }]]); + pipeline.dispose(); + }, + ); +}); + +describe("hardware source adapter", () => { + it.each([true, false])( + "delivers hardware down/up and declared focus with mac=%s", + async (isMac) => { + const { attachHardwareKeyboardSource } = await import("./internal/sources/native"); + const { focusSurfaces } = await import("./internal/focus-surfaces"); + const actions: string[] = []; + const releases: string[] = []; + const events = new Set< + (event: import("./internal/sources/hardware").HardwareKeyboardEvent) => void + >(); + const releaseFocus = focusSurfaces.declare("message-input"); + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac, usesDesktopBindings: true, commandCenterOpen: false }), + getBindings: () => buildEffectiveBindings({}), + isCapturing: () => false, + dispatch: (match) => { + actions.push(match.action); + return true; + }, + overlays: createOverlayRegistry(), + }); + const detach = attachHardwareKeyboardSource( + { + subscribe(handler) { + events.add(handler); + return () => { + events.delete(handler); + }; + }, + setOverlayKeys() {}, + }, + { + keyDown: pipeline.keyDown, + keyUp: (event) => releases.push(event.key), + blur() {}, + commandCenterOpen: () => false, + }, + ); + const down = { + ...keyboardEvent("k", "KeyK", isMac), + isComposing: false, + phase: "down" as const, + }; + for (const listener of events) listener(down); + for (const listener of events) listener({ ...down, phase: "up" }); + expect(actions).toEqual(["command-center.toggle"]); + expect(releases).toEqual(["k"]); + detach(); + releaseFocus(); + pipeline.dispose(); + expect(events.size).toBe(0); + }, + ); +}); diff --git a/packages/app/src/keyboard/native-terminal-keyboard.test.ts b/packages/app/src/keyboard/native-terminal-keyboard.test.ts new file mode 100644 index 0000000000..fbebddcfab --- /dev/null +++ b/packages/app/src/keyboard/native-terminal-keyboard.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { createKeyboardFocusScope } from "./internal/focus-surfaces"; + +describe("declared keyboard focus", () => { + it("recognizes ordinary native editors while preserving declared scopes", () => { + let focused = false; + const scope = createKeyboardFocusScope(() => focused); + expect(scope.resolve(false)).toBe("other"); + focused = true; + expect(scope.resolve(false)).toBe("editable"); + const release = scope.declare("message-input"); + expect(scope.resolve(false)).toBe("message-input"); + expect(scope.resolve(true)).toBe("command-center"); + release(); + expect(scope.resolve(false)).toBe("editable"); + focused = false; + expect(scope.resolve(false)).toBe("other"); + }); + it("tracks composer and terminal focus without stale cleanup losing the new owner", () => { + const scope = createKeyboardFocusScope(); + expect(scope.resolve(false)).toBe("other"); + const releaseTerminal = scope.declare("terminal"); + expect(scope.resolve(false)).toBe("terminal"); + const releaseComposer = scope.declare("message-input"); + releaseTerminal(); + expect(scope.resolve(false)).toBe("message-input"); + expect(scope.resolve(true)).toBe("command-center"); + releaseComposer(); + releaseComposer(); + expect(scope.resolve(false)).toBe("other"); + }); +}); diff --git a/packages/app/src/keyboard/shortcut-help-search.test.ts b/packages/app/src/keyboard/shortcut-help-search.test.ts index 201d4865f0..b5cecf2a4e 100644 --- a/packages/app/src/keyboard/shortcut-help-search.test.ts +++ b/packages/app/src/keyboard/shortcut-help-search.test.ts @@ -7,7 +7,7 @@ import { } from "./keyboard-shortcuts"; import { filterShortcutHelpSections, shortcutSearchText } from "./shortcut-help-search"; -const MAC_DESKTOP = { isMac: true, isDesktop: true }; +const MAC_DESKTOP = { isMac: true, usesDesktopBindings: true }; const NEW_WORKSPACE_BINDING = "workspace-new-cmd-n-mac"; /** diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts index 6b80009633..05c83aff51 100644 --- a/packages/app/src/keyboard/shortcut-string.ts +++ b/packages/app/src/keyboard/shortcut-string.ts @@ -237,7 +237,9 @@ export function chordStringToShortcutKeys(s: string): ShortcutKey[][] { return s.split(" ").map(comboStringToShortcutKeys); } -export function heldModifiersFromEvent(event: KeyboardEvent): string | null { +export function heldModifiersFromEvent( + event: Pick, +): string | null { const parts: string[] = []; if (event.ctrlKey) parts.push("Ctrl"); if (event.altKey) parts.push("Alt"); @@ -255,7 +257,9 @@ export function isModifierKeyCode(code: string): boolean { return MODIFIER_CODES.has(code); } -export function keyboardEventToComboString(event: KeyboardEvent): string | null { +export function keyboardEventToComboString( + event: Pick, +): string | null { if (isModifierKeyCode(event.code)) { return null; } diff --git a/packages/app/src/keyboard/use-shortcuts.ts b/packages/app/src/keyboard/use-shortcuts.ts new file mode 100644 index 0000000000..b762d8dfcf --- /dev/null +++ b/packages/app/src/keyboard/use-shortcuts.ts @@ -0,0 +1,274 @@ +import { usesDesktopBindings } from "./internal/conventions"; +import { createKeyboardPipeline } from "./internal/pipeline"; +import { overlays } from "./internal/overlays"; +import { attachKeyboardSources, publishKeyboardPolicy } from "./internal/sources"; +import type { KeyboardSourceInput } from "./internal/sources/contract"; +import type { KeyboardInputEvent } from "./internal/overlays"; +import { hasActiveOverlay } from "./internal/overlay-dispatch"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { usePathname, useRouter } from "expo-router"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; +import { + type ChordState, + buildEffectiveBindings, + getWorkspaceIndexJumpModifierKey, +} from "@/keyboard/keyboard-shortcuts"; +import { shouldPublishBrowserShortcutPolicy } from "@/keyboard/internal/sources/electron-browser"; +import type { KeyboardShortcutPayload } from "@/keyboard/actions"; +import { + routeKeyboardShortcut, + type ShortcutAction, + type ShortcutCallbackName, +} from "@/keyboard/route-shortcut"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; +import { buildOpenProjectRoute } from "@/utils/host-routes"; +import { + type ActiveWorkspaceSelection, + navigateToLastWorkspace, + useActiveWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; + +export function useKeyboardShortcuts({ + enabled, + isMobile, + isWorkspaceFocusModeEnabled, + toggleAgentList, + toggleBothSidebars, + exitFocusMode, + cycleTheme, +}: { + enabled: boolean; + isMobile: boolean; + isWorkspaceFocusModeEnabled: boolean; + toggleAgentList: () => void; + toggleBothSidebars?: () => void; + exitFocusMode: () => void; + cycleTheme?: () => void; +}) { + const keyboardActionDispatcher = useKeyboardActionDispatcher(); + const pathname = usePathname(); + const router = useRouter(); + const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); + const { overrides } = useKeyboardShortcutOverrides(); + const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); + const isMac = getShortcutOs() === "mac"; + const openProjectPickerAction = useOpenAddProject(); + const activeWorkspaceSelection = useActiveWorkspaceSelection(); + const keyboardWorkspaceSelectionRef = useRef(null); + const publishBrowserShortcutPolicy = useCallback( + (chordState?: ChordState) => { + publishKeyboardPolicy({ bindings, chordState, isMac, enabled }); + }, + [bindings, enabled, isMac], + ); + + useEffect(() => { + if (activeWorkspaceSelection) { + keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection; + } + }, [activeWorkspaceSelection]); + + useEffect(() => { + publishBrowserShortcutPolicy(); + }, [publishBrowserShortcutPolicy]); + + // Only the modifier that actually performs the workspace-index jump on this + // runtime should reveal the sidebar number badges (Alt on web, Cmd on + // desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown + // to drive badge visibility, so we set the flag matching this runtime. + // Derived from the effective bindings: `null` when the user unassigned or + // rebound the jump shortcut, and no `event.key` ever equals null, so the + // badges simply never appear. + const badgeModifierKey = getWorkspaceIndexJumpModifierKey( + { isMac, usesDesktopBindings: usesDesktopBindings }, + bindings, + ); + + // The keyup listener matches the released key against the current modifier, + // so a modifier held while the jump binding changes could never be released + // and the badges would stay up until a blur. Clear them when it changes. + useEffect(() => { + resetModifiers(); + }, [badgeModifierKey, resetModifiers]); + + const setBadgeModifierDown = (down: boolean) => { + const state = useKeyboardShortcutsStore.getState(); + if (usesDesktopBindings) { + state.setCmdOrCtrlDown(down); + } else { + state.setAltDown(down); + } + }; + + const callbacksByName: Record void) | undefined> = { + "toggle-agent-list": toggleAgentList, + "toggle-both-sidebars": toggleBothSidebars, + "cycle-theme": cycleTheme, + }; + + const performShortcutAction = ( + action: ShortcutAction, + captureFocusRestore: () => void, + ): boolean => { + switch (action.kind) { + case "none": + return false; + case "dispatch": + return keyboardActionDispatcher.dispatch(action.action); + case "navigate-workspace": + keyboardWorkspaceSelectionRef.current = { + serverId: action.serverId, + workspaceId: action.workspaceId, + }; + navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId }); + return true; + case "navigate-last-workspace": + if (navigateToLastWorkspace()) { + return true; + } + router.replace(buildOpenProjectRoute()); + return true; + case "router-replace": + router.replace(action.route as Parameters[0]); + return true; + case "router-back": + router.back(); + return true; + case "router-push": + router.push(action.route as Parameters[0]); + return true; + case "open-project-picker": + void openProjectPickerAction(); + return true; + case "callback": + callbacksByName[action.name]?.(); + return true; + case "command-center-toggle": { + if (action.nextOpen) { + captureFocusRestore(); + } + useKeyboardShortcutsStore + .getState() + .setCommandCenterOpen(action.nextOpen, action.scope ?? null); + return true; + } + case "shortcuts-dialog-toggle": + useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen); + return true; + } + }; + + const routeAndPerformShortcut = (input: { + action: string; + payload: KeyboardShortcutPayload; + captureFocusRestore: () => void; + }): boolean => { + const store = useKeyboardShortcutsStore.getState(); + const shortcutAction = routeKeyboardShortcut( + { action: input.action, payload: input.payload }, + { + pathname, + isMobile, + sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets, + navigationActiveWorkspace: + keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection, + commandCenterOpen: store.commandCenterOpen, + shortcutsDialogOpen: store.shortcutsDialogOpen, + }, + ); + const handled = performShortcutAction(shortcutAction, input.captureFocusRestore); + if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) { + exitFocusMode(); + } + return handled; + }; + + const getContext = useStableEvent(() => ({ + isMac, + usesDesktopBindings: usesDesktopBindings, + commandCenterOpen: useKeyboardShortcutsStore.getState().commandCenterOpen, + })); + const getBindings = useStableEvent(() => bindings); + const isCapturing = useStableEvent(() => useKeyboardShortcutsStore.getState().capturingShortcut); + const dispatchMatch = useStableEvent( + (match: import("./keyboard-shortcuts").KeyboardShortcutMatch, input: KeyboardSourceInput) => + routeAndPerformShortcut({ + action: match.action, + payload: match.payload, + captureFocusRestore: input.captureFocusRestore, + }), + ); + const chordChanged = useStableEvent( + (previous: ChordState, next: ChordState, input: KeyboardSourceInput | null) => { + const isBrowserInput = input !== null && "browserId" in input.event; + if ( + !input || + shouldPublishBrowserShortcutPolicy({ + isBrowserInput, + previousChordState: previous, + nextChordState: next, + }) + ) { + publishBrowserShortcutPolicy(next); + } + }, + ); + const beforeResolve = useStableEvent((input: KeyboardSourceInput) => { + const event = input.event; + const key = event.key ?? ""; + if (key === "Escape" && pathname.startsWith("/settings") && !isMobile && hasActiveOverlay()) { + return false; + } + if (key === badgeModifierKey && !event.shiftKey) { + setBadgeModifierDown(true); + } + if (key === "Shift") { + const state = useKeyboardShortcutsStore.getState(); + if (state.altDown || state.cmdOrCtrlDown) { + state.resetModifiers(); + } + } + + return true; + }); + + const pipeline = useMemo( + () => + createKeyboardPipeline({ + getContext, + getBindings, + isCapturing, + dispatch: dispatchMatch, + overlays, + beforeResolve, + chordChanged, + }), + [getContext, getBindings, isCapturing, dispatchMatch, beforeResolve, chordChanged], + ); + + const handleKeyUp = useStableEvent((event: KeyboardInputEvent) => { + const key = event.key ?? ""; + if (key === badgeModifierKey) { + setBadgeModifierDown(false); + } + }); + + useEffect(() => { + if (!enabled) return; + const detach = attachKeyboardSources({ + keyDown: pipeline.keyDown, + keyUp: handleKeyUp, + blur: resetModifiers, + commandCenterOpen: () => useKeyboardShortcutsStore.getState().commandCenterOpen, + }); + return () => { + detach(); + pipeline.dispose(); + }; + }, [enabled, pipeline, handleKeyUp, resetModifiers]); +} diff --git a/packages/app/src/lib/overlay-root.browser.test.tsx b/packages/app/src/lib/overlay-root.browser.test.tsx index bd20d2aeeb..7f0d5115ac 100644 --- a/packages/app/src/lib/overlay-root.browser.test.tsx +++ b/packages/app/src/lib/overlay-root.browser.test.tsx @@ -1,19 +1,22 @@ +import { useGlobalOverlayLayer } from "./overlay-root"; +import { useOverlayRegistration } from "@/keyboard"; import React, { type RefCallback } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - hasOpenDomPopover, - keyBelongsToDomPopover, - useWebOverlayRegistration, -} from "./overlay-root"; +import { hasOpenDomPopover, keyBelongsToDomPopover } from "@/keyboard/internal/dom-popover"; function nextFrame(): Promise { return new Promise((resolve) => requestAnimationFrame(() => resolve())); } function OverlayHarness({ active, showScope }: { active: boolean; showScope: boolean }) { - const setScope = useWebOverlayRegistration({ active, layer: 20, onKeyDown: () => false }); + const setScope = useOverlayRegistration({ + keys: ["Escape"], + active, + layer: 20, + onKeyDown: () => false, + }); return showScope ? (
} tabIndex={-1}> @@ -21,7 +24,7 @@ function OverlayHarness({ active, showScope }: { active: boolean; showScope: boo ) : null; } -describe("useWebOverlayRegistration in the browser", () => { +describe("useOverlayRegistration in the browser", () => { let root: Root; let container: HTMLDivElement; let opener: HTMLButtonElement; @@ -104,3 +107,93 @@ describe("Escape with a plugin popover open", () => { expect(keyBelongsToDomPopover({ key: "Escape" })).toBe(true); }); }); + +describe("DOM source through the shared resolver", () => { + it("uses user rebinds from a focused composer and cancels the DOM default", async () => { + const { attachDomKeyboardSource } = await import("@/keyboard/internal/sources/dom.web"); + const { createKeyboardPipeline } = await import("@/keyboard/internal/pipeline"); + const { createOverlayRegistry } = await import("@/keyboard/internal/overlays"); + const { buildEffectiveBindings } = await import("@/keyboard/keyboard-shortcuts"); + const actions: string[] = []; + const input = document.createElement("textarea"); + input.dataset.testid = "message-input-root"; + document.body.append(input); + input.focus(); + const pipeline = createKeyboardPipeline({ + getContext: () => ({ isMac: true, usesDesktopBindings: false, commandCenterOpen: false }), + getBindings: () => buildEffectiveBindings({ "command-center-toggle-cmd-k-mac": "Cmd+J" }), + isCapturing: () => false, + overlays: createOverlayRegistry(), + dispatch: (match) => { + actions.push(match.action); + return true; + }, + }); + const detach = attachDomKeyboardSource({ + keyDown: pipeline.keyDown, + keyUp() {}, + blur() {}, + commandCenterOpen: () => false, + }); + const event = new KeyboardEvent("keydown", { + key: "j", + code: "KeyJ", + metaKey: true, + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(event); + expect(actions).toEqual(["command-center.toggle"]); + expect(event.defaultPrevented).toBe(true); + detach(); + pipeline.dispose(); + input.remove(); + }); +}); + +it("recognizes a hardware keyboard from modified input without a fine pointer", async () => { + const { createWebHardwareKeyboardPresence } = await import("@/keyboard/internal/presence.web"); + const presence = createWebHardwareKeyboardPresence(window.matchMedia("(width: 0px)"), window); + const values: boolean[] = []; + const release = presence.subscribe(() => values.push(presence.getSnapshot())); + expect(presence.getSnapshot()).toBe(false); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "a", code: "KeyA" })); + expect(presence.getSnapshot()).toBe(false); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", code: "KeyK", ctrlKey: true })); + expect(presence.getSnapshot()).toBe(true); + expect(values).toEqual([true]); + release(); +}); + +function LayerHarness({ active, child }: { active: boolean; child: boolean }) { + const layer = useGlobalOverlayLayer("modal", active); + useOverlayRegistration({ + active: child, + layer: 200, + keys: ["Escape"], + manageFocus: false, + onKeyDown: () => false, + }); + return {layer}; +} + +it("keeps the opening layer across rerenders and captures a new layer on reopening", () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + function render(active: boolean, child: boolean) { + flushSync(() => root.render()); + return container.textContent; + } + try { + expect(render(false, false)).toBe("20"); + expect(render(true, false)).toBe("20"); + expect(render(true, true)).toBe("20"); + expect(render(true, true)).toBe("20"); + render(false, true); + expect(render(true, true)).toBe("220"); + } finally { + root.unmount(); + container.remove(); + } +}); diff --git a/packages/app/src/lib/overlay-root.test.tsx b/packages/app/src/lib/overlay-root.test.tsx deleted file mode 100644 index 6671cd50f9..0000000000 --- a/packages/app/src/lib/overlay-root.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -// @vitest-environment jsdom -import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { dispatchTopWebOverlayKeyDown, useWebOverlayRegistration } from "./overlay-root"; - -describe("useWebOverlayRegistration", () => { - let opener: HTMLButtonElement; - let input: HTMLInputElement; - let scope: HTMLDivElement; - - beforeEach(() => { - vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { - callback(0); - return 0; - }); - vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); - - opener = document.createElement("button"); - input = document.createElement("input"); - scope = document.createElement("div"); - scope.tabIndex = -1; - scope.append(input); - document.body.append(opener, scope); - opener.focus(); - expect(document.activeElement).toBe(opener); - }); - - afterEach(() => { - vi.restoreAllMocks(); - document.body.replaceChildren(); - }); - - it("does not restore opener focus when an active overlay scope detaches", () => { - const { result, unmount } = renderHook(() => - useWebOverlayRegistration({ active: true, layer: 20, onKeyDown: () => false }), - ); - - act(() => result.current(scope)); - const openerFocus = vi.spyOn(opener, "focus"); - input.focus(); - expect(document.activeElement).toBe(input); - - act(() => result.current(null)); - - expect(openerFocus).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(input); - unmount(); - }); - - it("restores opener focus when an overlay closes", () => { - const { result, rerender, unmount } = renderHook( - ({ active }: { active: boolean }) => - useWebOverlayRegistration({ active, layer: 20, onKeyDown: () => false }), - { initialProps: { active: true } }, - ); - - act(() => result.current(scope)); - const openerFocus = vi.spyOn(opener, "focus"); - input.focus(); - expect(document.activeElement).toBe(input); - - act(() => rerender({ active: false })); - - expect(openerFocus).toHaveBeenCalled(); - unmount(); - }); - - it("restores opener focus when an active overlay unmounts after its scope detaches", () => { - const { result, unmount } = renderHook(() => - useWebOverlayRegistration({ active: true, layer: 20, onKeyDown: () => false }), - ); - - act(() => result.current(scope)); - const openerFocus = vi.spyOn(opener, "focus"); - input.focus(); - expect(document.activeElement).toBe(input); - - act(() => result.current(null)); - expect(openerFocus).not.toHaveBeenCalled(); - - unmount(); - - expect(openerFocus).toHaveBeenCalled(); - }); - - it("leaves IME composition keys with the focused editor", () => { - const event = new KeyboardEvent("keydown", { key: "Process", bubbles: true }); - expect(dispatchTopWebOverlayKeyDown(event)).toBe(false); - expect(event.defaultPrevented).toBe(false); - }); -}); diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index d1ab0b9787..b4448b866e 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -1,13 +1,5 @@ -import { - createContext, - createElement, - useCallback, - useContext, - useLayoutEffect, - useMemo, - useRef, - type ReactNode, -} from "react"; +import { createContext, createElement, useContext, useState, type ReactNode } from "react"; +import { getTopOverlayLayer } from "@/keyboard"; /** * Shared overlay root for web portals (modals, toasts, etc.) @@ -62,13 +54,27 @@ export function useOverlayLayer(kind: OverlayKind): number { * was topmost when it opened. Global hosts live outside their opener's React * tree, so context alone cannot preserve that relative ownership. */ -export function useGlobalWebOverlayLayer(kind: OverlayKind, active: boolean): number { +export function useGlobalOverlayLayer(kind: OverlayKind, active: boolean): number { const contextualLayer = useOverlayLayer(kind); - return useMemo(() => { - if (!active) return contextualLayer; - const topLayer = getTopWebOverlay()?.getLayer() ?? 0; - return Math.max(contextualLayer, topLayer + OVERLAY_Z[kind]); - }, [active, contextualLayer, kind]); + const captureOpening = () => ({ + active, + contextualLayer, + kind, + layer: active + ? Math.max(contextualLayer, getTopOverlayLayer() + OVERLAY_Z[kind]) + : contextualLayer, + }); + const [opening, setOpening] = useState(captureOpening); + if ( + opening.active !== active || + opening.contextualLayer !== contextualLayer || + opening.kind !== kind + ) { + const next = captureOpening(); + setOpening(next); + return next.layer; + } + return opening.layer; } export function useCurrentOverlayLayer(): number { @@ -78,304 +84,3 @@ export function useCurrentOverlayLayer(): number { export function OverlayLayerProvider({ layer, children }: { layer: number; children?: ReactNode }) { return createElement(OverlayLayerContext.Provider, { value: layer }, children); } - -type WebOverlayKeyHandler = (event: KeyboardEvent) => boolean; - -interface WebOverlayEntry { - id: symbol; - order: number; - getLayer: () => number; - getScope: () => HTMLElement | null; - getKeyHandler: () => WebOverlayKeyHandler; - restoreFocus: HTMLElement | null; - manageFocus: boolean; -} - -const webOverlayEntries: WebOverlayEntry[] = []; -let webOverlayOrder = 0; -let webOverlayListenersAttached = false; -let webOverlayFocusCheckQueued = false; - -interface RemoveWebOverlayOptions { - restoreFocus?: boolean; -} - -function getTopWebOverlay(): WebOverlayEntry | undefined { - return webOverlayEntries.reduce((top, entry) => { - if (!top) return entry; - const layerDifference = entry.getLayer() - top.getLayer(); - return layerDifference > 0 || (layerDifference === 0 && entry.order > top.order) ? entry : top; - }, undefined); -} - -export function hasActiveWebOverlay(): boolean { - return getTopWebOverlay() !== undefined; -} - -/** - * True while an element shown with the Popover API is open. Such a layer - * floats above the app like a registered overlay but lives outside the - * registry: plugins draw their panels this way, since they cannot register. - */ -export function hasOpenDomPopover(): boolean { - if (typeof document === "undefined") return false; - try { - return document.querySelector(":popover-open") !== null; - } catch { - // An engine without the Popover API rejects the selector, and has none open. - return false; - } -} - -/** - * Whether a key belongs to an open popover rather than to the app's shortcuts. - * An open popover (a plugin's panel) is the top layer and closes on its own - * Escape listener, which runs after the shortcut engine's capture listener: - * Escape must not also interrupt the running agent. - */ -export function keyBelongsToDomPopover(event: Pick): boolean { - return event.key === "Escape" && hasOpenDomPopover(); -} - -function getFocusableElements(scope: HTMLElement): HTMLElement[] { - const selector = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - '[tabindex]:not([tabindex="-1"])', - ].join(","); - - return Array.from(scope.querySelectorAll(selector)).filter( - (element) => - element.getAttribute("aria-hidden") !== "true" && - !element.hasAttribute("hidden") && - element.getClientRects().length > 0, - ); -} - -function focusFirstElement(scope: HTMLElement): void { - const firstMenuItem = scope.querySelector( - '[data-menu-item="true"]:not([data-menu-disabled="true"])', - ); - const first = firstMenuItem ?? getFocusableElements(scope)[0]; - (first ?? scope).focus(); -} - -function handleWebOverlayFocus(event: FocusEvent): void { - const top = getTopWebOverlay(); - const scope = top?.getScope(); - if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return; - if (webOverlayFocusCheckQueued) return; - - // React can autofocus a child before its parent scope ref attaches. Defer - // enforcement until the commit finishes so a newly mounted higher overlay - // can register without the previous scope stealing the requested focus. - webOverlayFocusCheckQueued = true; - queueMicrotask(() => { - webOverlayFocusCheckQueued = false; - const currentTop = getTopWebOverlay(); - const currentScope = currentTop?.getScope(); - if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement)) - return; - focusFirstElement(currentScope); - }); -} - -/** - * Gives the top painted overlay first refusal on a key before app-wide shortcuts run. - * The window listener below is the fallback for keys nobody routes through the shortcut engine. - */ -export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean { - // Composition keys belong to the active text field. A modal must not turn an - // IME candidate confirmation into an overlay shortcut before the browser has - // committed the composed text. - if (event.isComposing || event.key === "Process") return false; - - const top = getTopWebOverlay(); - const scope = top?.getScope(); - if (!top || !scope) return false; - - if (top.manageFocus && event.key === "Tab") { - const focusable = getFocusableElements(scope); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = document.activeElement; - const shouldWrapBackward = event.shiftKey && (!scope.contains(active) || active === first); - const shouldWrapForward = !event.shiftKey && (!scope.contains(active) || active === last); - if (shouldWrapBackward || shouldWrapForward) { - event.preventDefault(); - event.stopImmediatePropagation(); - (shouldWrapBackward ? last : first)?.focus(); - if (!first) scope.focus(); - return true; - } - } - - if (!top.getKeyHandler()(event)) return false; - event.stopImmediatePropagation(); - return true; -} - -function handleWebOverlayKeyDown(event: KeyboardEvent): void { - dispatchTopWebOverlayKeyDown(event); -} - -function attachWebOverlayListeners(): void { - if (webOverlayListenersAttached) return; - window.addEventListener("keydown", handleWebOverlayKeyDown, true); - document.addEventListener("focusin", handleWebOverlayFocus, true); - webOverlayListenersAttached = true; -} - -function detachWebOverlayListeners(): void { - if (!webOverlayListenersAttached || webOverlayEntries.length > 0) return; - window.removeEventListener("keydown", handleWebOverlayKeyDown, true); - document.removeEventListener("focusin", handleWebOverlayFocus, true); - webOverlayListenersAttached = false; -} - -function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptions) => void { - webOverlayEntries.push(entry); - attachWebOverlayListeners(); - - const focusFrame = window.requestAnimationFrame(() => { - const scope = entry.getScope(); - if ( - entry.manageFocus && - getTopWebOverlay() === entry && - scope && - !scope.contains(document.activeElement) - ) { - focusFirstElement(scope); - } - }); - - return (options) => { - window.cancelAnimationFrame(focusFrame); - const index = webOverlayEntries.findIndex((candidate) => candidate.id === entry.id); - if (index !== -1) webOverlayEntries.splice(index, 1); - detachWebOverlayListeners(); - if ( - entry.manageFocus && - options?.restoreFocus !== false && - entry.restoreFocus && - document.contains(entry.restoreFocus) - ) { - entry.restoreFocus.focus(); - } - }; -} - -interface WebOverlayRegistration { - active: boolean; - layer: number; - onKeyDown: WebOverlayKeyHandler; - restoreFocusRef?: React.RefObject; - /** Hover surfaces claim overlay keys while preserving the user's existing focus. */ - manageFocus?: boolean; -} - -/** - * Registers a focus scope in the same relative layer model used for painting. - * Only the highest painted overlay receives keyboard input. By default, focus is trapped - * there and restored to the opener when it closes; hover surfaces preserve focus instead. - */ -export function useWebOverlayRegistration({ - active, - layer, - onKeyDown, - manageFocus = true, - restoreFocusRef: preferredRestoreFocusRef, -}: WebOverlayRegistration) { - const idRef = useRef(Symbol("web-overlay")); - const scopeRef = useRef(null); - const layerRef = useRef(layer); - const keyHandlerRef = useRef(onKeyDown); - const capturedRestoreFocusRef = useRef(null); - const removeEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null); - const detachedRemoveEntryRef = useRef<((options?: RemoveWebOverlayOptions) => void) | null>(null); - const registeredRef = useRef(false); - const activeRef = useRef(active); - const wasActiveRef = useRef(false); - - activeRef.current = active; - layerRef.current = layer; - keyHandlerRef.current = onKeyDown; - if (active && !wasActiveRef.current && typeof document !== "undefined") { - const requestedRestoreTarget = preferredRestoreFocusRef?.current; - if (requestedRestoreTarget instanceof HTMLElement) { - capturedRestoreFocusRef.current = requestedRestoreTarget; - } else { - capturedRestoreFocusRef.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - } - } - wasActiveRef.current = active; - - const syncRegistration = useCallback(() => { - const shouldRegister = - activeRef.current && - scopeRef.current != null && - typeof window !== "undefined" && - typeof document !== "undefined"; - if (!shouldRegister) { - const shouldRestoreFocus = !activeRef.current; - if (registeredRef.current || shouldRestoreFocus) { - removeEntryRef.current?.({ restoreFocus: shouldRestoreFocus }); - } - registeredRef.current = false; - if (shouldRestoreFocus) { - removeEntryRef.current = null; - detachedRemoveEntryRef.current = null; - } - return; - } - if (registeredRef.current) return; - - detachedRemoveEntryRef.current = null; - const entry: WebOverlayEntry = { - id: idRef.current, - order: ++webOverlayOrder, - getLayer: () => layerRef.current, - getScope: () => scopeRef.current, - getKeyHandler: () => keyHandlerRef.current, - restoreFocus: capturedRestoreFocusRef.current, - manageFocus, - }; - removeEntryRef.current = addWebOverlay(entry); - registeredRef.current = true; - }, [manageFocus]); - - const setScope = useCallback( - (node: unknown) => { - scopeRef.current = - typeof HTMLElement !== "undefined" && node instanceof HTMLElement ? node : null; - if (!scopeRef.current && activeRef.current) { - const removeEntry = removeEntryRef.current; - removeEntry?.({ restoreFocus: false }); - detachedRemoveEntryRef.current = removeEntry; - removeEntryRef.current = null; - registeredRef.current = false; - } - // Host refs attach before descendant layout effects and autofocus. Register - // here so the previous overlay cannot redirect that pending focus. - syncRegistration(); - }, - [syncRegistration], - ); - - useLayoutEffect(() => { - syncRegistration(); - return () => { - const removeEntry = removeEntryRef.current ?? detachedRemoveEntryRef.current; - removeEntry?.(); - removeEntryRef.current = null; - detachedRemoveEntryRef.current = null; - registeredRef.current = false; - }; - }, [active, syncRegistration]); - - return setScope; -} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index d7c0ae1b8f..7b7e486654 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1,3 +1,4 @@ +import { useHardwareKeyboardPresent } from "@/keyboard"; import { useAddHost } from "@/hosts/add-host"; import { useCallback, useMemo, useRef, useState } from "react"; import type { ComponentType, ReactNode } from "react"; @@ -155,6 +156,7 @@ interface SidebarSectionItem { icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>; desktopOnly?: boolean; webOnly?: boolean; + hardwareKeyboardOnly?: boolean; /** The page body, for pages that need nothing from the settings screen. */ Content?: ComponentType; } @@ -199,7 +201,7 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, - desktopOnly: true, + hardwareKeyboardOnly: true, Content: KeyboardShortcutsSection, }, { @@ -227,7 +229,12 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ { id: "about", labelKey: "settings.sections.about", icon: Info }, ]; -function isSectionAvailable(item: SidebarSectionItem, isDesktopApp: boolean): boolean { +function isSectionAvailable( + item: SidebarSectionItem, + isDesktopApp: boolean, + hardwareKeyboardPresent: boolean, +): boolean { + if (item.hardwareKeyboardOnly && !hardwareKeyboardPresent) return false; return (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb); } @@ -966,7 +973,10 @@ function SettingsSidebar({ const hasHosts = sortedHosts.length > 0; const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption(); const isDesktopApp = isElectronRuntime(); - const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp)); + const hardwareKeyboardPresent = useHardwareKeyboardPresent(); + const items = SIDEBAR_SECTION_ITEMS.filter((item) => + isSectionAvailable(item, isDesktopApp, hardwareKeyboardPresent), + ); const insets = useSafeAreaInsets(); const isDesktop = layout === "desktop"; const sidebarScroll = useSettingsSidebarScroll(isDesktop); @@ -1110,6 +1120,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false); const [playbackTestResult, setPlaybackTestResult] = useState(null); const isDesktopApp = isElectronRuntime(); + const hardwareKeyboardPresent = useHardwareKeyboardPresent(); const appVersion = resolveAppVersion(); const appVersionText = formatVersionWithPrefix(appVersion); const isCompactLayout = useIsCompactFormFactor(); @@ -1305,7 +1316,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { } if (view.kind === "section") { const item = SIDEBAR_SECTION_ITEMS.find((candidate) => candidate.id === view.section); - if (!item || !isSectionAvailable(item, isDesktopApp)) return null; + if (!item || !isSectionAvailable(item, isDesktopApp, hardwareKeyboardPresent)) return null; switch (view.section) { case "general": return ( diff --git a/packages/app/src/screens/settings/general/sending-section.tsx b/packages/app/src/screens/settings/general/sending-section.tsx index c5d5f01760..bc1712f4a1 100644 --- a/packages/app/src/screens/settings/general/sending-section.tsx +++ b/packages/app/src/screens/settings/general/sending-section.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings"; import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings"; -import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; +import { useHardwareKeyboardPresent, supportsConfigurableComposerSendKeys } from "@/keyboard"; import { getShortcutOs } from "@/utils/shortcut-platform"; const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"]; @@ -10,7 +10,7 @@ const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"]; export function SendingSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); - const shortcutsAvailable = useKeyboardShortcutsAvailable(); + const shortcutsAvailable = useHardwareKeyboardPresent() && supportsConfigurableComposerSendKeys; const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl"; const options = useMemo( () => diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index 9a38e12ac0..c8dc67b5ca 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -1,3 +1,8 @@ +import { + usesDesktopBindings, + useHardwareKeyboardPresent, + useOverlayRegistration, +} from "@/keyboard"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, type PressableStateCallbackType } from "react-native"; @@ -33,8 +38,6 @@ import { import type { ShortcutKey } from "@/utils/format-shortcut"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { getShortcutOs } from "@/utils/shortcut-platform"; -import { getIsElectronRuntime } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; import { getDesktopHost } from "@/desktop/host"; const EMPTY_CAPTURED_COMBOS: string[] = []; @@ -332,6 +335,7 @@ function ShortcutRow({ export function KeyboardShortcutsSection() { const { t } = useTranslation(); + const hardwareKeyboardPresent = useHardwareKeyboardPresent(); const [capturingBindingId, setCapturingBindingId] = useState(null); const [capturedCombos, setCapturedCombos] = useState([]); const [heldModifiers, setHeldModifiers] = useState(null); @@ -342,8 +346,7 @@ export function KeyboardShortcutsSection() { const isFocused = useIsFocused(); const isMac = getShortcutOs() === "mac"; - const isDesktopApp = getIsElectronRuntime(); - const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }); + const sections = buildKeyboardShortcutHelpSections({ isMac, usesDesktopBindings }); const cancelCapture = useCallback(() => { setCapturedCombos([]); @@ -379,35 +382,29 @@ export function KeyboardShortcutsSection() { } }, [isFocused, capturingBindingId, cancelCapture]); - useEffect(() => { - if (isNative) return; - if (capturingBindingId === null) return; - - function handleKeyDown(event: KeyboardEvent) { + useOverlayRegistration({ + active: capturingBindingId !== null, + layer: 0, + keys: [], + manageFocus: false, + onKeyDown(event) { + if (capturingBindingId === null) return false; event.preventDefault(); - event.stopPropagation(); - if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) { - setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current)); - return; + setCapturedCombos((current) => current.slice(0, -1)); + return true; } - const comboString = keyboardEventToComboString(event); if (comboString === null) { setHeldModifiers(heldModifiersFromEvent(event)); - return; + return true; } - setHeldModifiers(null); - const capturedCombo = normalizeCapturedShortcutCombo(capturingBindingId!, comboString); + const capturedCombo = normalizeCapturedShortcutCombo(capturingBindingId, comboString); setCapturedCombos((current) => [...current, capturedCombo]); - } - - window.addEventListener("keydown", handleKeyDown, true); - return () => { - window.removeEventListener("keydown", handleKeyDown, true); - }; - }, [capturingBindingId]); + return true; + }, + }); useEffect(() => { return () => { @@ -418,7 +415,7 @@ export function KeyboardShortcutsSection() { // Suppress desktop zoom accelerators while capturing so combos like Cmd+- are // recorded instead of zooming the window. No-op outside Electron. useEffect(() => { - if (isNative || !capturing) return; + if (!capturing) return; const menu = getDesktopHost()?.menu; void menu?.setCapturingShortcut?.(true); return () => { @@ -436,15 +433,7 @@ export function KeyboardShortcutsSection() { [removeOverride], ); - if (isNative) { - return ( - - - {t("settings.shortcuts.unavailableOnMobile")} - - - ); - } + if (!hardwareKeyboardPresent) return null; const resetAllButton = hasOverrides ? (