mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
Support hardware keyboard shortcuts across native and desktop apps (#8625)
* feat(keyboard): support hardware keyboards on iPad Register app shortcuts as iOS UIKeyCommands so a paired hardware keyboard drives the same bindings as web and desktop. Overlays (menus, comboboxes, modals, command center) get first refusal on bare keys, and a focused terminal keeps the keys it needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(keyboard): record iOS hardware key observation spike With a UITextView focused on iPad Pro M4/iOS 18.6, window and controller observers received Cmd+B, Cmd+P and Escape. Escape also invoked UIKeyCommand when registered, so the stream must be the sole emitter. Use observation for shortcuts and commands only for text-control ownership. Simulator intercepted Cmd+K as HID184; input capture blocked synthetic typing entirely. Keep capture off and verify the toggle via a rebind during simulator QA. * refactor(keyboard): give overlays one key registration API Overlay ownership now follows one layer-ordered registry on every runtime. Web retains focus trapping and restoration through its adapter; native registers the same key handler without a DOM scope. Picker navigation and dismissal no longer require a parallel native stack. Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * feat(keyboard): route native hardware keys through the shared resolver Observe each physical press once, including native modal windows, and derive focus from declared composer and terminal surfaces. Native bindings and rebinds now use the same resolver and overlay precedence as browser and Electron input. Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * feat(keyboard): show shortcuts when hardware keyboards are present Keep routing independent of compact layout and detect hardware capability for hints and settings. Rebind capture uses the shared overlay interface on native too. Keep the filtered sidebar model available for keyboard navigation while its panel is hidden. Co-authored-by: Les Stroud <public@lesstroud.com> Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com> * test(keyboard): allow shortcut hints in compact menu assertions Compact menus now display hardware keyboard hints. Assert action labels, order and uniqueness without treating a trailing shortcut badge as part of the action label. * fix(keyboard): retain an overlay's opening layer in state Capture the registry's current top when an overlay opens and retain that layer through rerenders. React Compiler removed the previous useMemo cache after the registry getter moved across modules, allowing a parent modal to rise above its child and start an infinite focus loop. Compile this hook in Vitest as Expo does. The new hook regression failed first with layer 220 instead of 20, then passed with explicit opening state. Focused unit and browser tests pass, and the previously failing onboarding E2E completes through the real Expo compiler. Typecheck, lint and format pass. * refactor(keyboard): let overlays own native hardware input Remove the TextInput key handlers that duplicate declared overlay arrows and Escape in Add Project and Command Center. Remove Add Project's hidden responder now that native window sources observe keys without it. Keep soft-keyboard submit editing behavior. * fix(keyboard): close Add Project outside its state updater Root Escape previously called the parent close action from a React state updater, producing a cross-component render warning when navigation was still queued. Compute the previous page in the event handler and close outside the updater. The existing Escape/Back E2E now batches navigation and dismissal through the DOM key source. It failed first with the AddProjectFlowHost render warning, then passed after the correction. Typecheck, lint and format pass; native Escape checks pass on Android and iPad. * fix(keyboard): preserve Android dead-key composition * fix(keyboard): recognize focused native text inputs * test(keyboard): replace JSDOM coverage with browser tests * refactor(menu): spell out arrow navigation branches * fix(composer): claim fullscreen Escape on native * refactor(composer): one fullscreen Escape module for every platform The shared registration lived in fullscreen-exit.shared.ts with two one-line re-exports (fullscreen-exit.ts and fullscreen-exit.native.ts). Metro needs no native override now that the Android back button is its own adapter, so the implementation moves into fullscreen-exit.ts and the browser test imports the module every platform resolves. --------- Co-authored-by: Ubuntu <jeroenfrenken@icloud.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: jeroenfrenken <16974549+jeroenfrenken@users.noreply.github.com>
This commit is contained in:
co-authored by
Ubuntu
Claude Opus 5.5
jeroenfrenken
parent
1502b7f90f
commit
1c4400194a
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"./modules/paseo-hardware-keyboard/app.plugin",
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -117,3 +117,25 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
|
||||
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
|
||||
}
|
||||
|
||||
export async function navigateAndDismissAddProject(page: Page): Promise<void> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
+7
@@ -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))
|
||||
+164
@@ -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<String> = emptySet()
|
||||
private val windows = mutableMapOf<Window, KeyboardWindowCallback>()
|
||||
val dispatcher = WindowKeyDispatcher<KeyEvent>()
|
||||
private val consumedPresses = mutableSetOf<Int>()
|
||||
|
||||
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<String, Any>()) }
|
||||
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<String> ->
|
||||
HardwareKeyboard.overlayKeys = keys.filter { it in setOf("Escape", "Enter", "ArrowUp", "ArrowDown") }.toSet()
|
||||
}
|
||||
}
|
||||
internal fun emit(event: Map<String, Any>) { sendEvent("onHardwareKeyboardShortcut", event) }
|
||||
}
|
||||
+17
@@ -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<ReactActivityLifecycleListener> = 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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
+11
@@ -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<Event : Any> {
|
||||
private val delivering = java.util.Collections.newSetFromMap(java.util.IdentityHashMap<Event, Boolean>())
|
||||
fun dispatch(event: Event, observe: () -> Boolean, deliver: () -> Boolean): Boolean {
|
||||
if (!delivering.add(event)) return deliver()
|
||||
return try { observe() || deliver() } finally { delivering.remove(event) }
|
||||
}
|
||||
}
|
||||
+11
@@ -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))
|
||||
}
|
||||
}
|
||||
+27
@@ -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<Any>()
|
||||
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<Any>()
|
||||
var deliveries = 0
|
||||
assertTrue(dispatcher.dispatch(Any(), { true }, { deliveries++; false }))
|
||||
assertEquals(0, deliveries)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["PaseoHardwareKeyboardModule"],
|
||||
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
||||
"modules": ["PaseoHardwareKeyboardModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["sh.paseo.keyboard.PaseoHardwareKeyboardModule"]
|
||||
}
|
||||
}
|
||||
|
||||
+150
-12
@@ -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<String> = []
|
||||
|
||||
@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<Int> = []
|
||||
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)] ?? ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<SidebarModelProvider active={active}>
|
||||
<SidebarModelProvider
|
||||
active={active}
|
||||
keyboardNavigationActive={keyboardShortcutsEnabled && hardwareKeyboardPresent}
|
||||
>
|
||||
{mounted ? <LeftSidebar active={active} /> : null}
|
||||
<WorkspaceShortcutTargetsSubscriber enabled={keyboardShortcutsEnabled} />
|
||||
</SidebarModelProvider>
|
||||
|
||||
@@ -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<CommandCenterListRow, { kind: "secti
|
||||
);
|
||||
}
|
||||
|
||||
// Escape arrives without asking; it is registered for `agent.interrupt` anyway.
|
||||
// Backspace stays off deliberately — the palette pops its scope with it on web,
|
||||
// but a registered command would stop the search field deleting text.
|
||||
|
||||
export function CommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -780,7 +781,7 @@ export function CommandCenter() {
|
||||
interaction,
|
||||
);
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const modalLayer = useGlobalWebOverlayLayer("modal", isWeb && state.open && !showBottomSheet);
|
||||
const modalLayer = useGlobalOverlayLayer("modal", state.open);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
isEnabled: showBottomSheet,
|
||||
@@ -874,23 +875,20 @@ export function CommandCenter() {
|
||||
: {}),
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => 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}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onKeyPress={keyPress}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<View style={[styles.bottomSheetHeader, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
) : null}
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef}
|
||||
initialValue={state.query}
|
||||
variant="bottom-sheet"
|
||||
onChangeText={state.setQuery}
|
||||
onSubmitEditing={submit}
|
||||
placeholder={
|
||||
state.scope === "files"
|
||||
? t("shell.commandCenter.filePlaceholder")
|
||||
: t("shell.commandCenter.placeholder")
|
||||
}
|
||||
style={[styles.input, styles.growingInput]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
<FileSearchLoadingIndicator
|
||||
loading={state.fileSearchLoading}
|
||||
label={t("shell.commandCenter.searchingFiles")}
|
||||
/>
|
||||
</View>
|
||||
{fileSearchError}
|
||||
<BottomSheetFlatList ref={bottomSheetListRef} {...commonListProps} />
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
if (!state.open) return null;
|
||||
return (
|
||||
<OverlayLayerProvider layer={isWeb ? modalLayer : 0}>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<Modal visible transparent animationType="fade" onRequestClose={state.close}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={state.close} />
|
||||
<View ref={setWebOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View ref={setOverlayScope} testID="command-center-panel" style={styles.panel}>
|
||||
<View style={[styles.header, styles.searchRow]} testID="command-center-header">
|
||||
{state.scope === "files" ? (
|
||||
<ScopeChip label={t("shell.commandCenter.files")} onRemove={state.clearScope} />
|
||||
|
||||
@@ -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<Href>(() => 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<CommandCenterContribution[]>(() => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("resolveWorkspaceCommandCenterShortcuts", () => {
|
||||
expect(
|
||||
resolveWorkspaceCommandCenterShortcuts({
|
||||
overrides: {},
|
||||
platform: { isMac: true, isDesktop: true },
|
||||
platform: { isMac: true, usesDesktopBindings: true },
|
||||
}).newAgent,
|
||||
).toEqual([["mod", "shift", "A"]]);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -790,13 +789,7 @@ export function AdaptiveModalSheet({
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
style={desktopCardStyle}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
tabIndex={-1}
|
||||
>
|
||||
<View ref={setOverlayScope} style={desktopCardStyle} role="dialog" aria-modal tabIndex={-1}>
|
||||
{cardInner}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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) {
|
||||
<View style={styles.overlay} testID="add-project-flow">
|
||||
<Pressable style={styles.backdrop} onPress={onClose} testID="add-project-flow-backdrop" />
|
||||
<View
|
||||
ref={setWebOverlayScope}
|
||||
ref={setOverlayScope}
|
||||
style={styles.panel}
|
||||
testID={`add-project-flow-page-${page.kind}`}
|
||||
accessibilityLabel={`Add project: ${page.kind}`}
|
||||
@@ -892,29 +878,11 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{page.kind === "method" && isNative ? (
|
||||
// Native hardware-keyboard events need a focused responder even without a visible field.
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
onKeyPress={handleNativeKeyPress}
|
||||
onSubmitEditing={submitActive}
|
||||
showSoftInputOnFocus={false}
|
||||
caretHidden
|
||||
contextMenuHidden
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
pointerEvents="none"
|
||||
style={styles.keyboardCapture}
|
||||
testID="add-project-flow-keyboard-capture"
|
||||
/>
|
||||
) : null}
|
||||
{page.kind !== "method" ? (
|
||||
<ThemedTextInput
|
||||
ref={inputRef}
|
||||
initialValue={pageInput(page)}
|
||||
onChangeText={handleInputChange}
|
||||
onKeyPress={isWeb ? undefined : handleNativeKeyPress}
|
||||
onSubmitEditing={isWeb ? undefined : submitActive}
|
||||
placeholder={pagePlaceholder(page)}
|
||||
style={styles.input}
|
||||
@@ -996,7 +964,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
</Modal>
|
||||
);
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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). */}
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<View ref={setOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { usesDesktopBindings } from "@/keyboard";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -24,14 +24,13 @@ export function KeyboardShortcutsDialog() {
|
||||
|
||||
const shortcutOs = getShortcutOs();
|
||||
const isMac = shortcutOs === "mac";
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
// Effective bindings, so a shortcut the user unassigned lists no keys here
|
||||
// instead of advertising a default that no longer fires.
|
||||
const bindings = useMemo(() => 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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -50,13 +50,16 @@ const SidebarModelContext = createContext<SidebarModel | null>(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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<React.SetStateAction<number>>;
|
||||
@@ -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}
|
||||
>
|
||||
<ComboboxSheetFrame footer={props.footer}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</ComboboxSheetFrame>
|
||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||
<ComboboxSheetFrame footer={props.footer}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</ComboboxSheetFrame>
|
||||
</OverlayLayerProvider>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof useFloating>["refs"];
|
||||
shouldUseDesktopFade: boolean;
|
||||
desktopFrameStyle: StyleProp<ViewStyle>;
|
||||
@@ -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 (
|
||||
<MobileComboboxBody
|
||||
overlayLayer={floatingLayer}
|
||||
isOpen={isOpen}
|
||||
snapPoints={snapPoints}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
@@ -1628,6 +1623,7 @@ export function Combobox({
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
handleDesktopKey={handleDesktopKey}
|
||||
setOverlayScope={setOverlayScope}
|
||||
refs={refs}
|
||||
shouldUseDesktopFade={shouldUseDesktopFade}
|
||||
desktopFrameStyle={desktopFrameStyle}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useOverlayRegistration, type KeyboardInputEvent } from "@/keyboard";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -14,11 +15,7 @@ import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "rea
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
useOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "@/lib/overlay-root";
|
||||
import { OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import {
|
||||
measureFloatingPanelPortalHost,
|
||||
@@ -127,7 +124,7 @@ function WebHoverCard({
|
||||
}, [clearGraceTimer, disabled]);
|
||||
|
||||
const keyPressed = useCallback(
|
||||
(event: KeyboardEvent): boolean => {
|
||||
(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,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { KeyboardInputEvent } from "@/keyboard";
|
||||
export function navigateMenuKeyboard(_event: KeyboardInputEvent): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { KeyboardInputEvent } from "@/keyboard";
|
||||
export function navigateMenuKeyboard(event: KeyboardInputEvent): boolean {
|
||||
const target = document.activeElement;
|
||||
const surface = target?.closest<HTMLElement>('[data-menu-surface="true"]');
|
||||
if (!surface) return false;
|
||||
const items = Array.from(
|
||||
surface.querySelectorAll<HTMLElement>(
|
||||
'[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;
|
||||
}
|
||||
@@ -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<HTMLElement>('[data-menu-surface="true"]');
|
||||
if (!surface) return false;
|
||||
const items = Array.from(
|
||||
surface.querySelectorAll<HTMLElement>(
|
||||
'[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,
|
||||
|
||||
@@ -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<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement | null {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const shortcutsAvailable = useHardwareKeyboardPresent();
|
||||
const displayChord = normalizeDisplayChord(chord, keys);
|
||||
const shortcutOs = getShortcutOs();
|
||||
|
||||
|
||||
@@ -125,6 +125,20 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps keyboard navigation populated when the sidebar starts hidden", async () => {
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<SidebarModelProvider active={false} keyboardNavigationActive>
|
||||
<WorkspaceShortcutTargetsSubscriber enabled />
|
||||
</SidebarModelProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
|
||||
@@ -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(<Harness active />));
|
||||
expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(true);
|
||||
expect(exits).toBe(1);
|
||||
flushSync(() => root.render(<Harness active={false} />));
|
||||
expect(dispatchTopOverlayKeyDown(new KeyboardEvent("keydown", { key: "Escape" }))).toBe(false);
|
||||
expect(exits).toBe(1);
|
||||
} finally {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
+1
-5
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export function useComposerHardwareBackExit(_active: boolean, _onExit: () => void): void {}
|
||||
@@ -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<MessageInputRef, MessageInputProps>(
|
||||
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;
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ChordState>({
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
});
|
||||
const openProjectPickerAction = useOpenAddProject();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(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<ShortcutCallbackName, (() => 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<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[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,
|
||||
]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<EditingTextInputHandle>(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 = (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={close} />
|
||||
<View ref={setWebOverlayScope} style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{request.title}</Text>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
initialValue={query}
|
||||
onChangeText={handleQueryChange}
|
||||
placeholder="Search hosts..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
testID="host-chooser-search"
|
||||
/>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{options.length === 0 ? <Text style={styles.emptyText}>No matching hosts</Text> : null}
|
||||
{options.map((host, index) => (
|
||||
<HostChooserRow
|
||||
key={host.serverId}
|
||||
host={host}
|
||||
active={index === activeOptionIndex}
|
||||
onChooseHost={chooseHost}
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={close} />
|
||||
<View ref={setOverlayScope} style={styles.panel}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{request.title}</Text>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
initialValue={query}
|
||||
onChangeText={handleQueryChange}
|
||||
placeholder="Search hosts..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
testID="host-chooser-search"
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.results}
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No matching hosts</Text>
|
||||
) : null}
|
||||
{options.map((host, index) => (
|
||||
<HostChooserRow
|
||||
key={host.serverId}
|
||||
host={host}
|
||||
active={index === activeOptionIndex}
|
||||
onChooseHost={chooseHost}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</OverlayLayerProvider>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,3 @@
|
||||
export const usesDesktopBindings = true;
|
||||
// Native composer Return retains its existing submit path in this feature.
|
||||
export const supportsConfigurableComposerSendKeys = false;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
export const usesDesktopBindings = isElectronRuntime();
|
||||
export const supportsConfigurableComposerSendKeys = true;
|
||||
@@ -0,0 +1,6 @@
|
||||
export function keyBelongsToDomPopover(_event: { key: string }): boolean {
|
||||
return false;
|
||||
}
|
||||
export function hasOpenDomPopover(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -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<KeyboardEvent, "key">): boolean {
|
||||
return event.key === "Escape" && hasOpenDomPopover();
|
||||
}
|
||||
@@ -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<symbol, KeyboardFocusSurface>();
|
||||
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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function hasFocusedTextInput(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
manageFocus?: boolean;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<OverlayEntry, WebOverlayEntry>();
|
||||
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<HTMLElement>(selector)).filter(
|
||||
(element) =>
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
!element.hasAttribute("hidden") &&
|
||||
element.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function focusFirstElement(scope: HTMLElement): void {
|
||||
const firstMenuItem = scope.querySelector<HTMLElement>(
|
||||
'[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<HTMLElement | null>(null);
|
||||
const keysRef = useRef(keys);
|
||||
keysRef.current = keys;
|
||||
const layerRef = useRef(layer);
|
||||
const keyHandlerRef = useRef(onKeyDown);
|
||||
const capturedRestoreFocusRef = useRef<HTMLElement | null>(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;
|
||||
}
|
||||
@@ -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"], []]);
|
||||
});
|
||||
});
|
||||
@@ -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<OverlayEntry | undefined>(
|
||||
(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();
|
||||
@@ -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<KeyboardShortcutContext, "focusScope">;
|
||||
getBindings(): readonly ParsedShortcutBinding[];
|
||||
isCapturing(): boolean;
|
||||
dispatch(match: KeyboardShortcutMatch, input: KeyboardSourceInput): boolean;
|
||||
overlays: ReturnType<typeof createOverlayRegistry>;
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createHardwareKeyboardPresence } from "./hardware-presence";
|
||||
import { getHardwareKeyboardPresent, subscribeHardwareKeyboardPresence } from "./sources/hardware";
|
||||
export const hardwareKeyboardPresence = createHardwareKeyboardPresence({
|
||||
read: getHardwareKeyboardPresent,
|
||||
watch: subscribeHardwareKeyboardPresence,
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
+76
-13
@@ -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();
|
||||
});
|
||||
});
|
||||
+27
-4
@@ -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<string, BrowserShortcutPrefix>();
|
||||
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());
|
||||
};
|
||||
}
|
||||
@@ -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<HardwareKeyboardModule>("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();
|
||||
}
|
||||
@@ -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<HardwareKeyboardModule>("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();
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([]);
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { KeyboardFocusSurface } from "./focus-surfaces";
|
||||
export function useKeyboardFocusSurface(_surface: KeyboardFocusSurface, _focused: boolean): void {}
|
||||
@@ -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<string, string[]>;
|
||||
}
|
||||
@@ -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<typeof buildKeyboardShortcutHelpSections>, 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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<KeyboardEvent, "key" | "code" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">,
|
||||
): 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<KeyboardEvent, "key" | "code" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">,
|
||||
): string | null {
|
||||
if (isModifierKeyCode(event.code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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<ActiveWorkspaceSelection | null>(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<ShortcutCallbackName, (() => 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<typeof router.replace>[0]);
|
||||
return true;
|
||||
case "router-back":
|
||||
router.back();
|
||||
return true;
|
||||
case "router-push":
|
||||
router.push(action.route as Parameters<typeof router.push>[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]);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 ? (
|
||||
<div data-testid="scope" ref={setScope as RefCallback<HTMLDivElement>} tabIndex={-1}>
|
||||
<input data-testid="overlay-input" />
|
||||
@@ -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 <span data-testid="layer">{layer}</span>;
|
||||
}
|
||||
|
||||
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(<LayerHarness active={active} child={child} />));
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<WebOverlayEntry | undefined>((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<KeyboardEvent, "key">): 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<HTMLElement>(selector)).filter(
|
||||
(element) =>
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
!element.hasAttribute("hidden") &&
|
||||
element.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function focusFirstElement(scope: HTMLElement): void {
|
||||
const firstMenuItem = scope.querySelector<HTMLElement>(
|
||||
'[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<unknown>;
|
||||
/** 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<HTMLElement | null>(null);
|
||||
const layerRef = useRef(layer);
|
||||
const keyHandlerRef = useRef(onKeyDown);
|
||||
const capturedRestoreFocusRef = useRef<HTMLElement | null>(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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
|
||||
const [heldModifiers, setHeldModifiers] = useState<string | null>(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 (
|
||||
<SettingsSection title={t("settings.sections.shortcuts")}>
|
||||
<View style={[settingsStyles.card, styles.mobileCard]}>
|
||||
<Text style={styles.mobileText}>{t("settings.shortcuts.unavailableOnMobile")}</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
if (!hardwareKeyboardPresent) return null;
|
||||
|
||||
const resetAllButton = hasOverrides ? (
|
||||
<Button variant="ghost" onPress={handleResetAll}>
|
||||
@@ -463,7 +452,7 @@ export function KeyboardShortcutsSection() {
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
{section.rows.map(function (row, index) {
|
||||
const platform = { isMac, isDesktop: isDesktopApp };
|
||||
const platform = { isMac, usesDesktopBindings };
|
||||
const bindingId = getBindingIdForAction(row.id, platform);
|
||||
const displayChord = resolveShortcutKeysForAction(row.id, overrides, platform);
|
||||
// `in`, not a truthiness check: an unassigned shortcut stores
|
||||
@@ -556,11 +545,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
mobileCard: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
mobileText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 { isWeb } from "@/constants/platform";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { hasActiveOverlay } from "@/keyboard";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator-contract";
|
||||
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
|
||||
@@ -62,7 +62,7 @@ export const TerminalFind = forwardRef<
|
||||
useEffect(() => {
|
||||
if (!isWeb || !active) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
if (event.defaultPrevented || hasActiveOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (isFindShortcut(event, findShortcutPlatform()) && terminal.current?.find) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -36,11 +36,13 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "compile-usage-components",
|
||||
name: "compile-react-components",
|
||||
enforce: "pre",
|
||||
transform(source, id) {
|
||||
// These labels must react to time and language in the compiled app too.
|
||||
if (!id.includes("/src/usage/") || !id.endsWith(".tsx") || id.includes(".test.")) return;
|
||||
// Exercise compiler-sensitive labels and overlay lifetime in the same form as Expo.
|
||||
const usageComponent = id.includes("/src/usage/") && id.endsWith(".tsx");
|
||||
const overlayLayer = id.endsWith("/src/lib/overlay-root.ts");
|
||||
if ((!usageComponent && !overlayLayer) || id.includes(".test.")) return;
|
||||
const result = transformSync(source, {
|
||||
filename: id,
|
||||
babelrc: false,
|
||||
|
||||
Reference in New Issue
Block a user