-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Allow Command-Escape keybindings #4307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { nativeKeybindingCaptureInput } from "./NativeKeybindingCapture.ts"; | ||
|
|
||
| describe("nativeKeybindingCaptureInput", () => { | ||
| it("forwards Command-Escape with its modifiers", () => { | ||
| expect( | ||
| nativeKeybindingCaptureInput({ | ||
| type: "keyDown", | ||
| key: "Escape", | ||
| meta: true, | ||
| control: false, | ||
| alt: false, | ||
| shift: true, | ||
| }), | ||
| ).toEqual({ | ||
| key: "Escape", | ||
| metaKey: true, | ||
| ctrlKey: false, | ||
| altKey: false, | ||
| shiftKey: true, | ||
| }); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ["bare Escape", { type: "keyDown", key: "Escape", meta: false }], | ||
| ["Command keyup", { type: "keyUp", key: "Escape", meta: true }], | ||
| ["another Command shortcut", { type: "keyDown", key: "k", meta: true }], | ||
| ])("ignores %s", (_name, input) => { | ||
| expect( | ||
| nativeKeybindingCaptureInput({ | ||
| control: false, | ||
| alt: false, | ||
| shift: false, | ||
| ...input, | ||
| }), | ||
| ).toBeNull(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import type { Input } from "electron"; | ||
|
|
||
| export const NATIVE_KEYBINDING_CAPTURE_CHANNEL = "desktop:native-keybinding-capture"; | ||
|
|
||
| export interface NativeKeybindingCaptureInput { | ||
| readonly key: "Escape"; | ||
| readonly metaKey: true; | ||
| readonly ctrlKey: boolean; | ||
| readonly altKey: boolean; | ||
| readonly shiftKey: boolean; | ||
| } | ||
|
|
||
| export function nativeKeybindingCaptureInput( | ||
| input: Pick<Input, "type" | "key" | "meta" | "control" | "alt" | "shift">, | ||
| ): NativeKeybindingCaptureInput | null { | ||
| const key = input.key.toLowerCase(); | ||
| if (input.type !== "keyDown" || (key !== "escape" && key !== "esc") || !input.meta) { | ||
| return null; | ||
| } | ||
|
|
||
| return { | ||
| key: "Escape", | ||
| metaKey: true, | ||
| ctrlKey: input.control, | ||
| altKey: input.alt, | ||
| shiftKey: input.shift, | ||
| }; | ||
| } | ||
|
|
||
| export function dispatchNativeKeybindingCaptureInput(input: unknown): void { | ||
| if ( | ||
| typeof input !== "object" || | ||
| input === null || | ||
| !("key" in input) || | ||
| input.key !== "Escape" || | ||
| !("metaKey" in input) || | ||
| input.metaKey !== true || | ||
| !("ctrlKey" in input) || | ||
| typeof input.ctrlKey !== "boolean" || | ||
| !("altKey" in input) || | ||
| typeof input.altKey !== "boolean" || | ||
| !("shiftKey" in input) || | ||
| typeof input.shiftKey !== "boolean" | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| const target = document.activeElement ?? document; | ||
|
|
||
| target.dispatchEvent( | ||
| new KeyboardEvent("keydown", { | ||
| key: input.key, | ||
| code: "Escape", | ||
| metaKey: input.metaKey, | ||
| ctrlKey: input.ctrlKey, | ||
| altKey: input.altKey, | ||
| shiftKey: input.shiftKey, | ||
| bubbles: true, | ||
| cancelable: true, | ||
| }), | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,10 @@ import * as Scope from "effect/Scope"; | |
| import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
|
|
||
| import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | ||
| import { | ||
| nativeKeybindingCaptureInput, | ||
| NATIVE_KEYBINDING_CAPTURE_CHANNEL, | ||
| } from "../keybindings/NativeKeybindingCapture.ts"; | ||
| import * as BrowserSession from "./BrowserSession.ts"; | ||
| import { | ||
| ANNOTATION_CAPTURED_CHANNEL, | ||
|
|
@@ -1229,6 +1233,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | |
| }); | ||
| }); | ||
| const beforeInput = (event: Electron.Event, input: Electron.Input): void => { | ||
| const captureInput = nativeKeybindingCaptureInput(input); | ||
| if (captureInput) { | ||
| event.preventDefault(); | ||
| wc.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a preview webview owns focus, this sends the synthetic Escape keydown into the guest page, but T3 app shortcuts are resolved by the parent renderer's Useful? React with 👍 / 👎. |
||
| } | ||
| runFork(forwardShortcut(event, input)); | ||
| }; | ||
| yield* Scope.addFinalizer( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,13 +18,26 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; | |
| import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; | ||
| import * as PreviewManager from "../preview/Manager.ts"; | ||
| import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; | ||
| import { | ||
| type NativeKeybindingCaptureInput, | ||
| nativeKeybindingCaptureInput, | ||
| NATIVE_KEYBINDING_CAPTURE_CHANNEL, | ||
| } from "../keybindings/NativeKeybindingCapture.ts"; | ||
|
|
||
| const TITLEBAR_HEIGHT = 40; | ||
| const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | ||
| const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | ||
| const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; | ||
| const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; | ||
| const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; | ||
| const MACOS_MOD_ESCAPE_ACCELERATOR = "Command+Escape"; | ||
| const MACOS_MOD_ESCAPE_INPUT: NativeKeybindingCaptureInput = { | ||
| key: "Escape", | ||
| metaKey: true, | ||
| ctrlKey: false, | ||
| altKey: false, | ||
| shiftKey: false, | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. macOS shortcut drops modifier keysMedium Severity On macOS, the global shortcut for Command+Escape always sends a fixed input payload that incorrectly sets Reviewed by Cursor Bugbot for commit 70f663e. Configure here. |
||
| const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ | ||
| -2, // ERR_FAILED | ||
| -7, // ERR_TIMED_OUT | ||
|
|
@@ -341,6 +354,36 @@ export const make = Effect.gen(function* () { | |
| if (environment.platform === "darwin") { | ||
| window.setAutoHideCursor(false); | ||
| } | ||
| let unregisterNativeModEscape = () => {}; | ||
| if (environment.platform === "darwin") { | ||
| const registerNativeModEscape = () => { | ||
| if (Electron.globalShortcut.isRegistered(MACOS_MOD_ESCAPE_ACCELERATOR)) { | ||
| return; | ||
| } | ||
| const registered = Electron.globalShortcut.register(MACOS_MOD_ESCAPE_ACCELERATOR, () => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On macOS this registers only the exact Useful? React with 👍 / 👎. |
||
| const focusedWebContents = Electron.webContents.getFocusedWebContents(); | ||
| if (focusedWebContents && !focusedWebContents.isDestroyed()) { | ||
| focusedWebContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, MACOS_MOD_ESCAPE_INPUT); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cmd+Escape triggers bare Escape handlersMedium Severity While the main window is focused on macOS, every physical Command+Escape chord registers a global shortcut and forwards a synthetic Additional Locations (1)Reviewed by Cursor Bugbot for commit 70f663e. Configure here. |
||
| }); | ||
| if (!registered) { | ||
| void runPromise(logWindowWarning("failed to register Command-Escape shortcut")); | ||
| } | ||
| }; | ||
| unregisterNativeModEscape = () => { | ||
| Electron.globalShortcut.unregister(MACOS_MOD_ESCAPE_ACCELERATOR); | ||
| }; | ||
| window.on("focus", registerNativeModEscape); | ||
| window.on("blur", unregisterNativeModEscape); | ||
| } else { | ||
| window.webContents.on("before-input-event", (event, input) => { | ||
| const captureInput = nativeKeybindingCaptureInput(input); | ||
| if (captureInput) { | ||
| event.preventDefault(); | ||
| window.webContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput); | ||
| } | ||
| }); | ||
| } | ||
| let boundsPersistFiber: Fiber.Fiber<void, never> | undefined; | ||
| let pendingBoundsPersistFiber: Fiber.Fiber<void, never> | undefined; | ||
| let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; | ||
|
|
@@ -644,6 +687,7 @@ export const make = Effect.gen(function* () { | |
| } | ||
|
|
||
| window.on("closed", () => { | ||
| unregisterNativeModEscape(); | ||
| clearDevelopmentLoadRetry(); | ||
| clearBoundsPersist(); | ||
| void runPromise(electronWindow.clearMain(Option.some(window))); | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On non-Mac platforms
modis derived fromctrlKeyinkeybindingFromKeyboardEvent, but both desktopbefore-input-eventpaths call this helper, which only acceptsinput.meta. In the Windows/Linux scenario whereCtrl+Escapeneeds the native bridge instead of a normal renderer keydown, the helper returnsnull, while the only synthesized chord would beMeta+Escapeand record/run asmeta+escrather than the newly allowedmod+esc; pass the platform or acceptcontrolfor non-Darwin callers.Useful? React with 👍 / 👎.