Skip to content
Source
Sign inSign up
Open History to see the last commit

keybindings.ts

502 lines15.1 KB

1import {
2 type KeybindingCommand,
3 type KeybindingShortcut,
4 type KeybindingWhenNode,
5 type ResolvedKeybindingRule,
6 MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
7 type ResolvedKeybindingsConfig,
8 THREAD_JUMP_KEYBINDING_COMMANDS,
9 type ModelPickerJumpKeybindingCommand,
10 type ThreadJumpKeybindingCommand,
11} from "@t3tools/contracts";
12import {
13 isMacPlatform,
14 matchesKeybindingShortcut,
15 matchesKeybindingShortcutModifiers,
16 normalizeEventKey,
17 resolveEventKeys,
18 type ShortcutEventLike as SharedShortcutEventLike,
19 type ShortcutModifierStateLike,
20} from "@t3tools/shared/keybindings";
21import { isElectron } from "./env";
22import { isEditableFocused } from "./lib/editableFocus";
23import { projectScriptIdFromCommand } from "./projectScripts";
24​
25export type { ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
26export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
27​
28export interface ShortcutEventLike extends SharedShortcutEventLike {
29 target?: EventTarget | null;
31​
32export interface ShortcutMatchContext {
33 terminalFocus: boolean;
34 terminalOpen: boolean;
35 previewFocus: boolean;
36 previewOpen: boolean;
37 isWeb: boolean;
38 isDesktop: boolean;
39 editableFocus?: boolean;
40 [key: string]: boolean;
42​
43interface ShortcutMatchOptions {
44 platform?: string;
45 context?: Partial<ShortcutMatchContext>;
47​
48interface ResolvedShortcutLabelOptions extends ShortcutMatchOptions {
49 platform?: string;
51​
52const TERMINAL_WORD_BACKWARD = "\u001bb";
53const TERMINAL_WORD_FORWARD = "\u001bf";
54const TERMINAL_LINE_START = "\u0001";
55const TERMINAL_LINE_END = "\u0005";
56const TERMINAL_DELETE_TO_LINE_START = "\u0015";
57function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
58 return options?.platform ?? navigator.platform;
60​
61function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
62 return {
63 terminalFocus: false,
64 terminalOpen: false,
65 previewFocus: false,
66 previewOpen: false,
67 isWeb: !isElectron,
68 isDesktop: isElectron,
69 editableFocus: false,
70 ...options?.context,
71 };
73​
74function evaluateWhenNode(node: KeybindingWhenNode, context: ShortcutMatchContext): boolean {
75 switch (node.type) {
76 case "identifier":
77 if (node.name === "true") return true;
78 if (node.name === "false") return false;
79 return Boolean(context[node.name]);
80 case "not":
81 return !evaluateWhenNode(node.node, context);
82 case "and":
83 return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context);
84 case "or":
85 return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context);
86 }
88​
89function matchesWhenClause(
90 whenAst: KeybindingWhenNode | undefined,
91 context: ShortcutMatchContext,
92): boolean {
93 if (!whenAst) return true;
94 return evaluateWhenNode(whenAst, context);
96​
97export function shortcutConflictKey(
98 shortcut: KeybindingShortcut,
99 platform = navigator.platform,
100): string {
101 const useMetaForMod = isMacPlatform(platform);
102 const metaKey = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
103 const ctrlKey = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
104 return [
105 shortcut.key,
106 metaKey ? "meta" : "",
107 ctrlKey ? "ctrl" : "",
108 shortcut.shiftKey ? "shift" : "",
109 shortcut.altKey ? "alt" : "",
110 ].join("|");
112​
113function shortcutForContext(binding: ResolvedKeybindingRule, context: ShortcutMatchContext) {
114 const shortcut = binding.shortcut;
115 // Resolve persisted Mod+Enter against send preference without rewriting saved shortcuts.
116 if (
117 context.composerRequiresModifier &&
118 (binding.command === "composer.sendAlternate" ||
119 binding.command === "composer.sendBackground") &&
120 shortcut.key === "enter" &&
121 shortcut.modKey &&
122 !shortcut.metaKey &&
123 !shortcut.ctrlKey &&
124 !shortcut.altKey &&
125 !shortcut.shiftKey
126 ) {
127 return { ...shortcut, shiftKey: true };
128 }
129 return shortcut;
131​
132export function effectiveShortcutsForCommand(
133 keybindings: ResolvedKeybindingsConfig,
134 command: KeybindingCommand,
135 options?: ShortcutMatchOptions,
136): KeybindingShortcut[] {
137 const platform = resolvePlatform(options);
138 const context = resolveContext(options);
139 const claimedShortcuts = new Set<string>();
140 const effective: KeybindingShortcut[] = [];
141​
142 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
143 const binding = keybindings[index];
144 if (!binding) continue;
145 if (!matchesWhenClause(binding.whenAst, context)) continue;
146​
147 const shortcut = shortcutForContext(binding, context);
148 const conflictKey = shortcutConflictKey(shortcut, platform);
149 if (claimedShortcuts.has(conflictKey)) {
150 continue;
151 }
152​
153 claimedShortcuts.add(conflictKey);
154 if (binding.command === command) {
155 effective.push(shortcut);
156 }
157 }
158​
159 return effective;
161​
162function findEffectiveShortcutForCommand(
163 keybindings: ResolvedKeybindingsConfig,
164 command: KeybindingCommand,
165 options?: ShortcutMatchOptions,
166): KeybindingShortcut | null {
167 return effectiveShortcutsForCommand(keybindings, command, options)[0] ?? null;
169​
170function matchesCommandShortcut(
171 event: ShortcutEventLike,
172 keybindings: ResolvedKeybindingsConfig,
173 command: KeybindingCommand,
174 options?: ShortcutMatchOptions,
175): boolean {
176 return resolveShortcutCommand(event, keybindings, options) === command;
178​
179export function resolveShortcutCommand(
180 event: ShortcutEventLike,
181 keybindings: ResolvedKeybindingsConfig,
182 options?: ShortcutMatchOptions,
183): KeybindingCommand | null {
184 const platform = resolvePlatform(options);
185 const context = resolveContext(options);
186 if (options?.context?.editableFocus === undefined && typeof document !== "undefined") {
187 context.editableFocus = isEditableFocused(event.target ?? document.activeElement);
188 }
189​
190 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
191 const binding = keybindings[index];
192 if (!binding) continue;
193 if (!matchesWhenClause(binding.whenAst, context)) continue;
194 if (!matchesKeybindingShortcut(event, shortcutForContext(binding, context), platform)) continue;
195 return binding.command;
196 }
197 return null;
199​
200export function resolveChatShortcutCommand(
201 event: ShortcutEventLike,
202 primaryKeybindings: ResolvedKeybindingsConfig,
203 activeKeybindings: ResolvedKeybindingsConfig,
204 options?: ShortcutMatchOptions,
205): KeybindingCommand | null {
206 const primaryCommand = resolveShortcutCommand(event, primaryKeybindings, options);
207 if (primaryCommand !== null && projectScriptIdFromCommand(primaryCommand) === null) {
208 return primaryCommand;
209 }
210​
211 const activeCommand = resolveShortcutCommand(event, activeKeybindings, options);
212 return activeCommand !== null && projectScriptIdFromCommand(activeCommand) !== null
213 ? activeCommand
214 : null;
216​
217export function formatShortcutKeyLabel(key: string): string {
218 if (key === " ") return "Space";
219 if (key.length === 1) return key.toUpperCase();
220 if (key === "escape") return "Esc";
221 if (key === "arrowup") return "Up";
222 if (key === "arrowdown") return "Down";
223 if (key === "arrowleft") return "Left";
224 if (key === "arrowright") return "Right";
225 if (key === "pageup") return "PageUp";
226 if (key === "pagedown") return "PageDown";
227 return key.slice(0, 1).toUpperCase() + key.slice(1);
229​
230export function formatShortcutLabel(
231 shortcut: KeybindingShortcut,
232 platform = navigator.platform,
233): string {
234 const keyLabel = formatShortcutKeyLabel(shortcut.key);
235 const useMetaForMod = isMacPlatform(platform);
236 const showMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
237 const showCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
238 const showAlt = shortcut.altKey;
239 const showShift = shortcut.shiftKey;
240​
241 if (useMetaForMod) {
242 return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`;
243 }
244​
245 const parts: string[] = [];
246 if (showCtrl) parts.push("Ctrl");
247 if (showAlt) parts.push("Alt");
248 if (showShift) parts.push("Shift");
249 if (showMeta) parts.push("Meta");
250 parts.push(keyLabel);
251 return parts.join("+");
253​
254export function shortcutLabelForCommand(
255 keybindings: ResolvedKeybindingsConfig,
256 command: KeybindingCommand | null,
257 options?: string | ResolvedShortcutLabelOptions,
258): string | null {
259 if (command === null) return null;
260 const resolvedOptions =
261 typeof options === "string"
262 ? ({ platform: options } satisfies ResolvedShortcutLabelOptions)
263 : options;
264 const platform = resolvePlatform(resolvedOptions);
265 const shortcut = findEffectiveShortcutForCommand(keybindings, command, resolvedOptions);
266 return shortcut ? formatShortcutLabel(shortcut, platform) : null;
268​
269export function threadJumpCommandForIndex(index: number): ThreadJumpKeybindingCommand | null {
270 return THREAD_JUMP_KEYBINDING_COMMANDS[index] ?? null;
272​
273export function threadJumpIndexFromCommand(command: string): number | null {
274 const index = THREAD_JUMP_KEYBINDING_COMMANDS.indexOf(command as ThreadJumpKeybindingCommand);
275 return index === -1 ? null : index;
277​
278export function threadTraversalDirectionFromCommand(
279 command: string | null,
280): "previous" | "next" | null {
281 if (command === "thread.previous") return "previous";
282 if (command === "thread.next") return "next";
283 return null;
285​
286export function isThreadNavigationShortcut(
287 event: ShortcutEventLike,
288 keybindings: ResolvedKeybindingsConfig,
289 options?: ShortcutMatchOptions,
290): boolean {
291 const command = resolveShortcutCommand(event, keybindings, options);
292 return (
293 threadTraversalDirectionFromCommand(command) !== null ||
294 threadJumpIndexFromCommand(command ?? "") !== null
295 );
297​
298export function shouldShowThreadJumpHintsForModifiers(
299 modifiers: ShortcutModifierStateLike,
300 keybindings: ResolvedKeybindingsConfig,
301 options?: ShortcutMatchOptions,
302): boolean {
303 // Holding a modifier is routine in a shell (Ctrl+C, Ctrl+R), so don't flash hints over it.
304 if (resolveContext(options).terminalFocus) {
305 return false;
306 }
307​
308 const platform = resolvePlatform(options);
309​
310 for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) {
311 const shortcut = findEffectiveShortcutForCommand(keybindings, command, options);
312 if (!shortcut) continue;
313 if (matchesKeybindingShortcutModifiers(modifiers, shortcut, platform)) {
314 return true;
315 }
316 }
317​
318 return false;
320​
321export function modelPickerJumpCommandForIndex(
322 index: number,
323): ModelPickerJumpKeybindingCommand | null {
324 return MODEL_PICKER_JUMP_KEYBINDING_COMMANDS[index] ?? null;
326​
327export function modelPickerJumpIndexFromCommand(command: string): number | null {
328 const index = MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.indexOf(
329 command as ModelPickerJumpKeybindingCommand,
330 );
331 return index === -1 ? null : index;
333​
334export function isTerminalToggleShortcut(
335 event: ShortcutEventLike,
336 keybindings: ResolvedKeybindingsConfig,
337 options?: ShortcutMatchOptions,
338): boolean {
339 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
341​
342export function isTerminalSplitShortcut(
343 event: ShortcutEventLike,
344 keybindings: ResolvedKeybindingsConfig,
345 options?: ShortcutMatchOptions,
346): boolean {
347 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
349​
350export function isTerminalSplitVerticalShortcut(
351 event: ShortcutEventLike,
352 keybindings: ResolvedKeybindingsConfig,
353 options?: ShortcutMatchOptions,
354): boolean {
355 return matchesCommandShortcut(event, keybindings, "terminal.splitVertical", options);
357​
358export function isTerminalNewShortcut(
359 event: ShortcutEventLike,
360 keybindings: ResolvedKeybindingsConfig,
361 options?: ShortcutMatchOptions,
362): boolean {
363 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
365​
366export function isTerminalCloseShortcut(
367 event: ShortcutEventLike,
368 keybindings: ResolvedKeybindingsConfig,
369 options?: ShortcutMatchOptions,
370): boolean {
371 return matchesCommandShortcut(event, keybindings, "terminal.close", options);
373​
374export function isDiffToggleShortcut(
375 event: ShortcutEventLike,
376 keybindings: ResolvedKeybindingsConfig,
377 options?: ShortcutMatchOptions,
378): boolean {
379 return matchesCommandShortcut(event, keybindings, "diff.toggle", options);
381​
382export function isOpenFavoriteEditorShortcut(
383 event: ShortcutEventLike,
384 keybindings: ResolvedKeybindingsConfig,
385 options?: ShortcutMatchOptions,
386): boolean {
387 return (
388 event.repeat !== true &&
389 matchesCommandShortcut(event, keybindings, "editor.openFavorite", options)
390 );
392​
393/**
394 * Whether the keypress should bold text in the rich-text composer: Mod+B
395 * without extra modifiers, with text selected. Tiptap binds the same chord, so
396 * app shortcuts captured ahead of the editor yield only in that case. With no
397 * selection an app binding on the chord wins (the sidebar toggle by default).
398 */
399export function isRichTextBoldShortcut(
400 event: ShortcutEventLike,
401 selection: Pick<Selection, "isCollapsed"> | null,
402): boolean {
403 if (event.type !== undefined && event.type !== "keydown") {
404 return false;
405 }
406 return (
407 selection !== null &&
408 !selection.isCollapsed &&
409 resolveEventKeys(event).has("b") &&
410 (event.metaKey || event.ctrlKey) &&
411 !event.altKey &&
412 !event.shiftKey
413 );
415​
416/** Mod+B, Mod+I and Mod+E, the mark chords the rich-text composer binds. */
417export function isRichTextFormattingShortcut(event: ShortcutEventLike): boolean {
418 if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return false;
419 const keys = resolveEventKeys(event);
420 return keys.has("b") || keys.has("i") || keys.has("e");
422​
423export function isTerminalClearShortcut(
424 event: ShortcutEventLike,
425 platform = navigator.platform,
426): boolean {
427 if (event.type !== undefined && event.type !== "keydown") {
428 return false;
429 }
430​
431 const key = event.key.toLowerCase();
432​
433 if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
434 return true;
435 }
436​
437 return (
438 isMacPlatform(platform) &&
439 key === "k" &&
440 event.metaKey &&
441 !event.ctrlKey &&
442 !event.altKey &&
443 !event.shiftKey
444 );
446​
447export function terminalDeleteShortcutData(
448 event: ShortcutEventLike,
449 platform = navigator.platform,
450): string | null {
451 if (event.type !== undefined && event.type !== "keydown") {
452 return null;
453 }
454​
455 if (!isMacPlatform(platform)) {
456 return null;
457 }
458​
459 const key = normalizeEventKey(event.key);
460 if (key !== "backspace") {
461 return null;
462 }
463​
464 return event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey
465 ? TERMINAL_DELETE_TO_LINE_START
466 : null;
468​
469export function terminalNavigationShortcutData(
470 event: ShortcutEventLike,
471 platform = navigator.platform,
472): string | null {
473 if (event.type !== undefined && event.type !== "keydown") {
474 return null;
475 }
476​
477 if (event.shiftKey) return null;
478​
479 const key = normalizeEventKey(event.key);
480 if (key !== "arrowleft" && key !== "arrowright") {
481 return null;
482 }
483​
484 const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD;
485 const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END;
486​
487 if (isMacPlatform(platform)) {
488 if (event.altKey && !event.metaKey && !event.ctrlKey) {
489 return moveWord;
490 }
491 if (event.metaKey && !event.altKey && !event.ctrlKey) {
492 return moveLine;
493 }
494 return null;
495 }
496​
497 if (event.altKey && !event.metaKey && !event.ctrlKey) {
498 return moveWord;
499 }
500​
501 return null;