244 lines
7.5 KiB
TypeScript
244 lines
7.5 KiB
TypeScript
import { type KeyId, matchesKey } from "./keys.js";
|
|
|
|
/**
|
|
* Global keybinding registry.
|
|
* Downstream packages can add keybindings via declaration merging.
|
|
*/
|
|
export interface Keybindings {
|
|
// Editor navigation and editing
|
|
"tui.editor.cursorUp": true;
|
|
"tui.editor.cursorDown": true;
|
|
"tui.editor.cursorLeft": true;
|
|
"tui.editor.cursorRight": true;
|
|
"tui.editor.cursorWordLeft": true;
|
|
"tui.editor.cursorWordRight": true;
|
|
"tui.editor.cursorLineStart": true;
|
|
"tui.editor.cursorLineEnd": true;
|
|
"tui.editor.jumpForward": true;
|
|
"tui.editor.jumpBackward": true;
|
|
"tui.editor.pageUp": true;
|
|
"tui.editor.pageDown": true;
|
|
"tui.editor.deleteCharBackward": true;
|
|
"tui.editor.deleteCharForward": true;
|
|
"tui.editor.deleteWordBackward": true;
|
|
"tui.editor.deleteWordForward": true;
|
|
"tui.editor.deleteToLineStart": true;
|
|
"tui.editor.deleteToLineEnd": true;
|
|
"tui.editor.yank": true;
|
|
"tui.editor.yankPop": true;
|
|
"tui.editor.undo": true;
|
|
// Generic input actions
|
|
"tui.input.newLine": true;
|
|
"tui.input.submit": true;
|
|
"tui.input.tab": true;
|
|
"tui.input.copy": true;
|
|
// Generic selection actions
|
|
"tui.select.up": true;
|
|
"tui.select.down": true;
|
|
"tui.select.pageUp": true;
|
|
"tui.select.pageDown": true;
|
|
"tui.select.confirm": true;
|
|
"tui.select.cancel": true;
|
|
}
|
|
|
|
export type Keybinding = keyof Keybindings;
|
|
|
|
export interface KeybindingDefinition {
|
|
defaultKeys: KeyId | KeyId[];
|
|
description?: string;
|
|
}
|
|
|
|
export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
|
|
export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
|
|
|
|
export const TUI_KEYBINDINGS = {
|
|
"tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
|
|
"tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
|
|
"tui.editor.cursorLeft": {
|
|
defaultKeys: ["left", "ctrl+b"],
|
|
description: "Move cursor left",
|
|
},
|
|
"tui.editor.cursorRight": {
|
|
defaultKeys: ["right", "ctrl+f"],
|
|
description: "Move cursor right",
|
|
},
|
|
"tui.editor.cursorWordLeft": {
|
|
defaultKeys: ["alt+left", "ctrl+left", "alt+b"],
|
|
description: "Move cursor word left",
|
|
},
|
|
"tui.editor.cursorWordRight": {
|
|
defaultKeys: ["alt+right", "ctrl+right", "alt+f"],
|
|
description: "Move cursor word right",
|
|
},
|
|
"tui.editor.cursorLineStart": {
|
|
defaultKeys: ["home", "ctrl+a"],
|
|
description: "Move to line start",
|
|
},
|
|
"tui.editor.cursorLineEnd": {
|
|
defaultKeys: ["end", "ctrl+e"],
|
|
description: "Move to line end",
|
|
},
|
|
"tui.editor.jumpForward": {
|
|
defaultKeys: "ctrl+]",
|
|
description: "Jump forward to character",
|
|
},
|
|
"tui.editor.jumpBackward": {
|
|
defaultKeys: "ctrl+alt+]",
|
|
description: "Jump backward to character",
|
|
},
|
|
"tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" },
|
|
"tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" },
|
|
"tui.editor.deleteCharBackward": {
|
|
defaultKeys: "backspace",
|
|
description: "Delete character backward",
|
|
},
|
|
"tui.editor.deleteCharForward": {
|
|
defaultKeys: ["delete", "ctrl+d"],
|
|
description: "Delete character forward",
|
|
},
|
|
"tui.editor.deleteWordBackward": {
|
|
defaultKeys: ["ctrl+w", "alt+backspace"],
|
|
description: "Delete word backward",
|
|
},
|
|
"tui.editor.deleteWordForward": {
|
|
defaultKeys: ["alt+d", "alt+delete"],
|
|
description: "Delete word forward",
|
|
},
|
|
"tui.editor.deleteToLineStart": {
|
|
defaultKeys: "ctrl+u",
|
|
description: "Delete to line start",
|
|
},
|
|
"tui.editor.deleteToLineEnd": {
|
|
defaultKeys: "ctrl+k",
|
|
description: "Delete to line end",
|
|
},
|
|
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
|
|
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
|
|
"tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" },
|
|
"tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" },
|
|
"tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
|
|
"tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
|
|
"tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
|
|
"tui.select.up": { defaultKeys: "up", description: "Move selection up" },
|
|
"tui.select.down": { defaultKeys: "down", description: "Move selection down" },
|
|
"tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" },
|
|
"tui.select.pageDown": {
|
|
defaultKeys: "pageDown",
|
|
description: "Selection page down",
|
|
},
|
|
"tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" },
|
|
"tui.select.cancel": {
|
|
defaultKeys: ["escape", "ctrl+c"],
|
|
description: "Cancel selection",
|
|
},
|
|
} as const satisfies KeybindingDefinitions;
|
|
|
|
export interface KeybindingConflict {
|
|
key: KeyId;
|
|
keybindings: string[];
|
|
}
|
|
|
|
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
|
|
if (keys === undefined) return [];
|
|
const keyList = Array.isArray(keys) ? keys : [keys];
|
|
const seen = new Set<KeyId>();
|
|
const result: KeyId[] = [];
|
|
for (const key of keyList) {
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
result.push(key);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export class KeybindingsManager {
|
|
private definitions: KeybindingDefinitions;
|
|
private userBindings: KeybindingsConfig;
|
|
private keysById = new Map<Keybinding, KeyId[]>();
|
|
private conflicts: KeybindingConflict[] = [];
|
|
|
|
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
|
|
this.definitions = definitions;
|
|
this.userBindings = userBindings;
|
|
this.rebuild();
|
|
}
|
|
|
|
private rebuild(): void {
|
|
this.keysById.clear();
|
|
this.conflicts = [];
|
|
|
|
const userClaims = new Map<KeyId, Set<Keybinding>>();
|
|
for (const [keybinding, keys] of Object.entries(this.userBindings)) {
|
|
if (!(keybinding in this.definitions)) continue;
|
|
for (const key of normalizeKeys(keys)) {
|
|
const claimants = userClaims.get(key) ?? new Set<Keybinding>();
|
|
claimants.add(keybinding as Keybinding);
|
|
userClaims.set(key, claimants);
|
|
}
|
|
}
|
|
|
|
for (const [key, keybindings] of userClaims) {
|
|
if (keybindings.size > 1) {
|
|
this.conflicts.push({ key, keybindings: [...keybindings] });
|
|
}
|
|
}
|
|
|
|
for (const [id, definition] of Object.entries(this.definitions)) {
|
|
const userKeys = this.userBindings[id];
|
|
const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
|
|
this.keysById.set(id as Keybinding, keys);
|
|
}
|
|
}
|
|
|
|
matches(data: string, keybinding: Keybinding): boolean {
|
|
const keys = this.keysById.get(keybinding) ?? [];
|
|
for (const key of keys) {
|
|
if (matchesKey(data, key)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
getKeys(keybinding: Keybinding): KeyId[] {
|
|
return [...(this.keysById.get(keybinding) ?? [])];
|
|
}
|
|
|
|
getDefinition(keybinding: Keybinding): KeybindingDefinition {
|
|
return this.definitions[keybinding];
|
|
}
|
|
|
|
getConflicts(): KeybindingConflict[] {
|
|
return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] }));
|
|
}
|
|
|
|
setUserBindings(userBindings: KeybindingsConfig): void {
|
|
this.userBindings = userBindings;
|
|
this.rebuild();
|
|
}
|
|
|
|
getUserBindings(): KeybindingsConfig {
|
|
return { ...this.userBindings };
|
|
}
|
|
|
|
getResolvedBindings(): KeybindingsConfig {
|
|
const resolved: KeybindingsConfig = {};
|
|
for (const id of Object.keys(this.definitions)) {
|
|
const keys = this.keysById.get(id as Keybinding) ?? [];
|
|
resolved[id] = keys.length === 1 ? keys[0]! : [...keys];
|
|
}
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
let globalKeybindings: KeybindingsManager | null = null;
|
|
|
|
export function setKeybindings(keybindings: KeybindingsManager): void {
|
|
globalKeybindings = keybindings;
|
|
}
|
|
|
|
export function getKeybindings(): KeybindingsManager {
|
|
if (!globalKeybindings) {
|
|
globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
|
}
|
|
return globalKeybindings;
|
|
}
|