eigent/electron/main/commands/accelerators.ts
Douglas Lai 8f4f575db9
feat: add native menus, shortcuts, and window chrome (#1858)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-21 20:39:55 +08:00

206 lines
6.3 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
export type DesktopMenuPlatform = 'darwin' | 'win32' | 'linux';
export interface InstalledAccelerator {
accelerator: string;
command: string;
normalized: string;
}
const MODIFIER_ORDER = ['Command', 'Control', 'Alt', 'Shift', 'Super'] as const;
const RENDERER_RESERVED_KEYS = ['K', 'S'] as const;
/**
* Keys the embedded xterm terminal must receive verbatim on Windows and Linux,
* where the app modifier is Ctrl and these are standard readline bindings
* (Ctrl+N next-history, Ctrl+B backward-char, Ctrl+W backward-kill-word).
* Their menu items keep the conventional label but must not register the
* accelerator, exactly like the Edit roles; the renderer dispatches them
* instead and lets the terminal win.
* macOS is unaffected: there the app modifier is Command, which the shell
* never claims.
*/
const TERMINAL_OWNED_NON_MAC_KEYS = ['N', 'B', 'W'] as const;
/** True when the renderer, not the native menu, must own this accelerator. */
export function isTerminalOwnedAccelerator(
key: string,
platform: DesktopMenuPlatform
): boolean {
if (platform === 'darwin') return false;
return (TERMINAL_OWNED_NON_MAC_KEYS as readonly string[]).includes(
key.toUpperCase()
);
}
/**
* Renderer-owned shortcuts that a native menu must not register. Copy and the
* other edit accelerators are handled separately: Windows/Linux display them
* in the menu with `registerAccelerator: false`, while macOS requires native
* Edit accelerators for standard text editing.
*/
export function getReservedRendererAccelerators(
platform: DesktopMenuPlatform
): string[] {
const modifier = platform === 'darwin' ? 'Command' : 'Control';
return RENDERER_RESERVED_KEYS.map((key) => `${modifier}+${key}`);
}
function normalizeModifier(
token: string,
platform: DesktopMenuPlatform
): (typeof MODIFIER_ORDER)[number] | null {
switch (token.toLowerCase()) {
case 'cmd':
case 'command':
case 'meta':
return 'Command';
case 'ctrl':
case 'control':
return 'Control';
case 'cmdorctrl':
case 'commandorcontrol':
return platform === 'darwin' ? 'Command' : 'Control';
case 'alt':
case 'option':
return 'Alt';
case 'shift':
return 'Shift';
case 'super':
return 'Super';
default:
return null;
}
}
function normalizeKey(token: string): string {
if (token.length === 1 && /[a-z]/i.test(token)) return token.toUpperCase();
if (/^f\d+$/i.test(token)) return token.toUpperCase();
switch (token.toLowerCase()) {
case 'plus':
return 'Plus';
case 'space':
return 'Space';
case 'tab':
return 'Tab';
case 'enter':
case 'return':
return 'Enter';
case 'escape':
case 'esc':
return 'Escape';
default:
return token;
}
}
/** Resolve aliases and modifier ordering so equivalent accelerators compare. */
export function normalizeAccelerator(
accelerator: string,
platform: DesktopMenuPlatform
): string {
const modifiers = new Set<(typeof MODIFIER_ORDER)[number]>();
const keys: string[] = [];
for (const rawToken of accelerator.split('+')) {
const token = rawToken.trim();
if (!token) continue;
const modifier = normalizeModifier(token, platform);
if (modifier) modifiers.add(modifier);
else keys.push(normalizeKey(token));
}
return [
...MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)),
...keys,
].join('+');
}
function submenuItems(
item: Electron.MenuItemConstructorOptions
): Electron.MenuItemConstructorOptions[] {
return Array.isArray(item.submenu) ? item.submenu : [];
}
/** Collect accelerators that Electron will actually register for this menu. */
export function collectInstalledAccelerators(
template: Electron.MenuItemConstructorOptions[],
platform: DesktopMenuPlatform
): InstalledAccelerator[] {
const installed: InstalledAccelerator[] = [];
const visit = (
items: Electron.MenuItemConstructorOptions[],
parentPath: string[]
) => {
for (const item of items) {
const command =
item.id ||
[...parentPath, item.label || item.role || 'menu-item'].join('/');
if (
item.accelerator &&
!(platform !== 'darwin' && item.registerAccelerator === false)
) {
installed.push({
accelerator: item.accelerator,
command,
normalized: normalizeAccelerator(item.accelerator, platform),
});
}
const children = submenuItems(item);
if (children.length > 0) {
visit(children, [...parentPath, item.label || item.role || 'menu']);
}
}
};
visit(template, []);
return installed;
}
/** Fail fast before Electron installs an ambiguous or renderer-owned shortcut. */
export function assertUniqueInstalledAccelerators(
template: Electron.MenuItemConstructorOptions[],
platform: DesktopMenuPlatform,
reservedAccelerators = getReservedRendererAccelerators(platform)
): void {
const reserved = new Set(
reservedAccelerators.map((accelerator) =>
normalizeAccelerator(accelerator, platform)
)
);
const seen = new Map<string, InstalledAccelerator>();
for (const installed of collectInstalledAccelerators(template, platform)) {
if (reserved.has(installed.normalized)) {
throw new Error(
`Native menu command "${installed.command}" conflicts with renderer-reserved accelerator ${installed.accelerator}`
);
}
const existing = seen.get(installed.normalized);
if (existing) {
throw new Error(
`Native menu accelerator ${installed.accelerator} is assigned to both "${existing.command}" and "${installed.command}"`
);
}
seen.set(installed.normalized, installed);
}
}