Files
pi-ask/src/constants/keymaps.ts
T

718 lines
19 KiB
TypeScript

import { matchesKey } from "@mariozechner/pi-tui";
import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts";
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
const LETTER_PATTERN = /^[a-z]$/;
const DIGIT_PATTERN = /^[0-9]$/;
const SYMBOL_PATTERN = /^[`\-=[\]\\;'.,/!@#$%^&*()_+|~{}:<>?]$/;
const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/;
const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
const KNOWN_SPECIAL_KEYS = new Set([
"esc",
"enter",
"tab",
"space",
"backspace",
"delete",
"insert",
"clear",
"home",
"end",
"pageUp",
"pageDown",
"up",
"down",
"left",
"right",
"f1",
"f2",
"f3",
"f4",
"f5",
"f6",
"f7",
"f8",
"f9",
"f10",
"f11",
"f12",
] as const);
const RESERVED_BINDINGS = new Set([
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
]);
const CONTEXT_LABELS: Record<AskKeymapContext, string> = {
global: "Global",
main: "Main flow",
editor: "Answer editor",
noteEditor: "Note editor",
settingsModal: "Settings modal",
};
type KeyId = Parameters<typeof matchesKey>[1];
type InputKey = KeyId | (string & {});
type ModifierName = (typeof MODIFIER_ORDER)[number];
export type AskKeymapContext = keyof AskConfigKeymaps;
export type AskKeymapAction<C extends AskKeymapContext = AskKeymapContext> =
keyof AskConfigKeymaps[C] & string;
export type AskKeyBindingKind = "command" | "affordance";
export type AskKeyBindingId =
| `${AskKeymapContext}.${string}`
| "numberShortcut"
| "fileReference";
export interface AskKeyBinding {
contexts: readonly string[];
description: string;
id: AskKeyBindingId;
keys: readonly InputKey[];
kind: AskKeyBindingKind;
label: string;
}
export type FooterKeymapContext =
| "default"
| "multi"
| "submit"
| "input"
| "note";
export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
global: {
dismiss: ["ctrl+c"],
settings: ["?"],
},
main: {
confirm: ["enter"],
cancel: ["esc"],
toggle: ["space"],
nextTab: ["tab", "right"],
previousTab: ["shift+tab", "left"],
nextOption: ["down"],
previousOption: ["up"],
optionNote: ["n"],
questionNote: ["shift+n"],
},
editor: {
submit: ["enter"],
close: ["esc"],
nextTabWhenEmpty: ["tab", "right"],
previousTabWhenEmpty: ["shift+tab", "left"],
nextOptionWhenEmpty: ["down"],
previousOptionWhenEmpty: ["up"],
},
noteEditor: {
save: ["enter"],
close: ["esc"],
nextTabWhenEmpty: ["tab", "right"],
previousTabWhenEmpty: ["shift+tab", "left"],
nextOptionWhenEmpty: ["down"],
previousOptionWhenEmpty: ["up"],
},
settingsModal: {
close: ["esc", "ctrl+c", "?"],
nextOption: ["down"],
previousOption: ["up"],
toggle: ["enter", "space"],
},
};
const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
global: {
dismiss: "Dismiss the active ask surface",
settings: "Open ask settings",
},
main: {
confirm: "Confirm selection, continue, or submit",
cancel: "Cancel flow",
toggle: "Toggle selected option",
nextTab: "Switch to next tab",
previousTab: "Switch to previous tab",
nextOption: "Move to next option/action",
previousOption: "Move to previous option/action",
optionNote: "Edit selected option note",
questionNote: "Edit question note",
},
editor: {
submit: "Submit custom answer",
close: "Close editor and preserve draft",
nextTabWhenEmpty: "Switch to next tab when editor is empty",
previousTabWhenEmpty: "Switch to previous tab when editor is empty",
nextOptionWhenEmpty: "Move to next option when editor is empty",
previousOptionWhenEmpty: "Move to previous option when editor is empty",
},
noteEditor: {
save: "Save note",
close: "Close note editor",
nextTabWhenEmpty: "Switch to next tab when editor is empty",
previousTabWhenEmpty: "Switch to previous tab when editor is empty",
nextOptionWhenEmpty: "Move to next option when editor is empty",
previousOptionWhenEmpty: "Move to previous option when editor is empty",
},
settingsModal: {
close: "Close settings",
nextOption: "Move to next setting",
previousOption: "Move to previous setting",
toggle: "Toggle selected setting",
},
};
const footerHint = (
binding: AskKeyBinding,
action: string,
label = binding.label
) => `${label} ${action}`;
export function formatKeybindingLabel(key: string): string {
if (key === "up") {
return "↑";
}
if (key === "down") {
return "↓";
}
if (key === "left") {
return "←";
}
if (key === "right") {
return "→";
}
return key
.split("+")
.map((part) => {
if (part.length <= 1) {
return part.toUpperCase();
}
if (part === "pageUp" || part === "pageDown") {
return part;
}
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join("+");
}
export function formatBindingLabel(keys: readonly string[]): string {
return keys.map(formatKeybindingLabel).join(" / ");
}
export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
return binding.keys.some((key) => {
if (key.length === 1) {
return data === key;
}
return matchesKey(data, key as KeyId);
});
}
export function matchesDigitShortcut(data: string): number | null {
return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null;
}
export function getAskKeyBindings(
config: AskConfig
): Record<string, AskKeyBinding> {
const entries: [string, AskKeyBinding][] = [];
for (const context of Object.keys(config.keymaps) as AskKeymapContext[]) {
const actions = config.keymaps[context] as Record<
string,
readonly string[]
>;
for (const [action, keys] of Object.entries(actions)) {
const id = `${context}.${action}` as AskKeyBindingId;
entries.push([
id,
{
contexts: [CONTEXT_LABELS[context]],
description: DESCRIPTIONS[context][action] ?? action,
id,
keys,
kind: "command",
label: formatBindingLabel(keys),
},
]);
}
}
entries.push([
"numberShortcut",
{
contexts: ["Main flow", "Submit tab"],
description: "Quick-select option or submit action",
id: "numberShortcut",
keys: ["1..9"],
kind: "command",
label: "1..9",
},
]);
entries.push([
"fileReference",
{
contexts: ["Editors"],
description: "Reference files while typing answers or notes",
id: "fileReference",
keys: ["@"],
kind: "affordance",
label: "@",
},
]);
return Object.fromEntries(entries);
}
export function getAskContextBindings<C extends AskKeymapContext>(
config: AskConfig,
context: C
): Record<AskKeymapAction<C>, AskKeyBinding> {
const bindings = getAskKeyBindings(config);
const actions = config.keymaps[context] as Record<string, readonly string[]>;
return Object.fromEntries(
Object.keys(actions).map((action) => [
action,
bindings[`${context}.${action}`],
])
) as Record<AskKeymapAction<C>, AskKeyBinding>;
}
export function getGlobalBindings(
config: AskConfig
): Record<AskKeymapAction<"global">, AskKeyBinding> {
return getAskContextBindings(config, "global");
}
export function getAskKeymaps(config: AskConfig): {
customizable: readonly AskKeyBinding[];
fixed: readonly AskKeyBinding[];
} {
const bindings = getAskKeyBindings(config);
return {
customizable: Object.keys(bindings)
.filter((id) => id !== "numberShortcut" && id !== "fileReference")
.map((id) => bindings[id]),
fixed: [bindings.numberShortcut, bindings.fileReference],
};
}
export function renderFooterKeymaps(
config: AskConfig,
context: FooterKeymapContext
): string {
const global = getGlobalBindings(config);
const main = getAskContextBindings(config, "main");
const editor = getAskContextBindings(config, "editor");
const noteEditor = getAskContextBindings(config, "noteEditor");
const bindings = getAskKeyBindings(config);
const optionNavigationLabel =
`${main.previousOption.label}${main.nextOption.label}`.replaceAll(
" / ",
""
);
const tabNavigationLabel = "⇆";
const noteNavigationLabel = `${main.optionNote.label}/${main.questionNote.label}`;
const hintsByContext: Record<FooterKeymapContext, readonly string[]> = {
input: [
footerHint(editor.submit, "submit"),
footerHint(editor.close, "close"),
footerHint(global.settings, "settings"),
],
note: [
footerHint(noteEditor.save, "save"),
footerHint(noteEditor.close, "close"),
footerHint(global.settings, "settings"),
],
submit: [
footerHint(bindings.numberShortcut, "hotkeys"),
footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(main.confirm, "confirm"),
footerHint(main.cancel, "cancel"),
footerHint(global.settings, "settings"),
],
multi: [
footerHint(main.nextTab, "tab", tabNavigationLabel),
footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(main.toggle, "toggle"),
footerHint(main.confirm, "continue"),
footerHint(main.optionNote, "note", noteNavigationLabel),
footerHint(main.cancel, "dismiss"),
footerHint(global.settings, "settings"),
],
default: [
footerHint(main.nextTab, "tab", tabNavigationLabel),
footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(main.confirm, "confirm"),
footerHint(main.optionNote, "note", noteNavigationLabel),
footerHint(main.cancel, "dismiss"),
footerHint(global.settings, "settings"),
],
};
return ` ${hintsByContext[context].join(" · ")}`;
}
export function renderSettingsFooterKeymaps(config: AskConfig): string {
const bindings = getAskContextBindings(config, "settingsModal");
return `${bindings.toggle.label} to change · ${bindings.close.label} to close`;
}
export function normalizeConfiguredKeymaps(
keymaps: unknown
): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } {
const candidate = normalizeLegacyFlatKeymaps(keymaps) ?? keymaps;
if (!candidate) {
return { ok: true, keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS) };
}
if (!(candidate && typeof candidate === "object")) {
return { ok: false, error: "Keymaps must be an object." };
}
const normalized = {} as AskConfigKeymaps;
for (const context of Object.keys(
DEFAULT_ASK_KEYMAPS
) as AskKeymapContext[]) {
const rawContext = (candidate as Record<string, unknown>)[context];
if (!(rawContext && typeof rawContext === "object")) {
return { ok: false, error: `Missing keymap context ${context}.` };
}
const contextResult = normalizeContextKeymaps(
context,
rawContext as Record<string, unknown>
);
if (!contextResult.ok) {
return contextResult;
}
normalized[context] = contextResult.keymaps as never;
}
const globalDuplicates = validateCrossContextConflicts(normalized);
if (globalDuplicates) {
return { ok: false, error: globalDuplicates };
}
return { ok: true, keymaps: normalized };
}
function normalizeContextKeymaps(
context: AskKeymapContext,
rawContext: Record<string, unknown>
):
| { ok: true; keymaps: Record<string, string[]> }
| { ok: false; error: string } {
const defaults = DEFAULT_ASK_KEYMAPS[context] as Record<
string,
readonly string[]
>;
const normalized: Record<string, string[]> = {};
for (const action of Object.keys(defaults)) {
const rawValue = rawContext[action];
if (rawValue === undefined) {
return { ok: false, error: `Missing keymap for ${context}.${action}.` };
}
const parsed = normalizeBindingList(rawValue);
if (!parsed.ok) {
return {
ok: false,
error: `Invalid keymap for ${context}.${action}: ${parsed.error}`,
};
}
normalized[action] = parsed.keys;
}
const duplicateError = validateContextBindings(context, normalized);
return duplicateError
? { ok: false, error: duplicateError }
: { ok: true, keymaps: normalized };
}
function normalizeBindingList(
rawValue: unknown
): { ok: true; keys: string[] } | { ok: false; error: string } {
const rawKeys = typeof rawValue === "string" ? [rawValue] : rawValue;
if (!Array.isArray(rawKeys)) {
return { ok: false, error: "binding must be a string or array of strings" };
}
if (rawKeys.length === 0) {
return { ok: false, error: "empty binding list" };
}
const keys: string[] = [];
for (const rawKey of rawKeys) {
if (typeof rawKey !== "string") {
return { ok: false, error: "binding list must contain only strings" };
}
const parsed = normalizeKeyId(rawKey);
if (!parsed.ok) {
return parsed;
}
if (!keys.includes(parsed.keyId)) {
keys.push(parsed.keyId);
}
}
return { ok: true, keys };
}
function validateContextBindings(
context: AskKeymapContext,
bindings: Record<string, readonly string[]>
): string | undefined {
const seen = new Map<string, string>();
for (const { action, key } of flattenBindings(bindings)) {
if (RESERVED_BINDINGS.has(key)) {
return `Reserved keymap for ${context}.${action}: ${formatKeybindingLabel(key)}.`;
}
const existing = seen.get(key);
if (existing) {
return `Duplicate keymap ${formatKeybindingLabel(key)} for ${context}.${existing} and ${context}.${action}.`;
}
seen.set(key, action);
}
return;
}
function validateCrossContextConflicts(
keymaps: AskConfigKeymaps
): string | undefined {
const globalSeen = new Map(
flattenBindings(keymaps.global).map(({ action, key }) => [
key,
`global.${action}`,
])
);
for (const { context, action, key } of flattenNonGlobalBindings(keymaps)) {
const existing = globalSeen.get(key);
if (existing) {
return `Duplicate global keymap ${formatKeybindingLabel(key)} for ${existing} and ${context}.${action}.`;
}
}
return;
}
function flattenNonGlobalBindings(keymaps: AskConfigKeymaps): Array<{
action: string;
context: AskKeymapContext;
key: string;
}> {
return (Object.keys(keymaps) as AskKeymapContext[])
.filter((context) => context !== "global" && context !== "settingsModal")
.flatMap((context) =>
flattenBindings(
keymaps[context] as Record<string, readonly string[]>
).map((binding) => ({ ...binding, context }))
);
}
function flattenBindings(
bindings: Record<string, readonly string[]>
): Array<{ action: string; key: string }> {
return Object.entries(bindings).flatMap(([action, keys]) =>
keys.map((key) => ({ action, key }))
);
}
export function normalizeLegacyFlatKeymaps(
keymaps: unknown
): AskConfigKeymaps | undefined {
if (!(keymaps && typeof keymaps === "object")) {
return;
}
if ("main" in keymaps || "global" in keymaps) {
return;
}
const raw = keymaps as Record<string, unknown>;
const defaults = cloneKeymaps(DEFAULT_ASK_KEYMAPS);
return {
...defaults,
global: {
...defaults.global,
dismiss: coerceLegacyBinding(raw.dismiss, defaults.global.dismiss),
},
main: {
...defaults.main,
confirm: coerceLegacyBinding(raw.confirm, defaults.main.confirm),
cancel: coerceLegacyBinding(raw.cancel, defaults.main.cancel),
toggle: coerceLegacyBinding(raw.toggle, defaults.main.toggle),
optionNote: coerceLegacyBinding(raw.optionNote, defaults.main.optionNote),
questionNote: coerceLegacyBinding(
raw.questionNote,
defaults.main.questionNote
),
},
editor: {
...defaults.editor,
submit: coerceLegacyBinding(raw.confirm, defaults.editor.submit),
close: coerceLegacyBinding(raw.cancel, defaults.editor.close),
},
noteEditor: {
...defaults.noteEditor,
save: coerceLegacyBinding(raw.confirm, defaults.noteEditor.save),
close: coerceLegacyBinding(raw.cancel, defaults.noteEditor.close),
},
};
}
function coerceLegacyBinding(value: unknown, fallback: string[]): string[] {
return typeof value === "string" ? [value] : fallback;
}
function normalizeKeyId(
rawKey: string
): { ok: true; keyId: string } | { ok: false; error: string } {
const normalizedInput = rawKey.trim();
if (!normalizedInput) {
return { ok: false, error: "empty binding" };
}
const parts = normalizedInput
.toLowerCase()
.split("+")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) {
return { ok: false, error: "empty binding" };
}
const basePart = parts.at(-1);
if (!basePart) {
return { ok: false, error: "missing key" };
}
const modifiers = new Set<ModifierName>();
for (const part of parts.slice(0, -1)) {
const modifier = normalizeModifierName(part);
if (!modifier) {
return { ok: false, error: `unsupported modifier ${part}` };
}
modifiers.add(modifier);
}
const baseKey = normalizeBaseKey(basePart);
if (!baseKey) {
return { ok: false, error: `unsupported key ${basePart}` };
}
const modifierPrefix = MODIFIER_ORDER.filter((modifier) =>
modifiers.has(modifier)
);
return { ok: true, keyId: [...modifierPrefix, baseKey].join("+") };
}
function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
return {
global: {
dismiss: [...keymaps.global.dismiss],
settings: [...keymaps.global.settings],
},
main: {
confirm: [...keymaps.main.confirm],
cancel: [...keymaps.main.cancel],
toggle: [...keymaps.main.toggle],
nextTab: [...keymaps.main.nextTab],
previousTab: [...keymaps.main.previousTab],
nextOption: [...keymaps.main.nextOption],
previousOption: [...keymaps.main.previousOption],
optionNote: [...keymaps.main.optionNote],
questionNote: [...keymaps.main.questionNote],
},
editor: {
submit: [...keymaps.editor.submit],
close: [...keymaps.editor.close],
nextTabWhenEmpty: [...keymaps.editor.nextTabWhenEmpty],
previousTabWhenEmpty: [...keymaps.editor.previousTabWhenEmpty],
nextOptionWhenEmpty: [...keymaps.editor.nextOptionWhenEmpty],
previousOptionWhenEmpty: [...keymaps.editor.previousOptionWhenEmpty],
},
noteEditor: {
save: [...keymaps.noteEditor.save],
close: [...keymaps.noteEditor.close],
nextTabWhenEmpty: [...keymaps.noteEditor.nextTabWhenEmpty],
previousTabWhenEmpty: [...keymaps.noteEditor.previousTabWhenEmpty],
nextOptionWhenEmpty: [...keymaps.noteEditor.nextOptionWhenEmpty],
previousOptionWhenEmpty: [...keymaps.noteEditor.previousOptionWhenEmpty],
},
settingsModal: {
close: [...keymaps.settingsModal.close],
nextOption: [...keymaps.settingsModal.nextOption],
previousOption: [...keymaps.settingsModal.previousOption],
toggle: [...keymaps.settingsModal.toggle],
},
};
}
function normalizeModifierName(part: string): ModifierName | undefined {
switch (part) {
case "ctrl":
case "control":
case "ctl":
return "ctrl";
case "shift":
return "shift";
case "alt":
case "option":
return "alt";
case "super":
case "cmd":
case "command":
case "meta":
case "win":
case "windows":
return "super";
default:
return;
}
}
function normalizeBaseKey(part: string): string | undefined {
const aliased = normalizeBaseKeyAlias(part);
if (
LETTER_PATTERN.test(aliased) ||
DIGIT_PATTERN.test(aliased) ||
SYMBOL_PATTERN.test(aliased)
) {
return aliased;
}
if (FUNCTION_KEY_PATTERN.test(aliased)) {
return aliased;
}
if (
KNOWN_SPECIAL_KEYS.has(
aliased as typeof KNOWN_SPECIAL_KEYS extends Set<infer T> ? T : never
)
) {
return aliased;
}
return;
}
function normalizeBaseKeyAlias(part: string): string {
switch (part) {
case "escape":
return "esc";
case "return":
return "enter";
case "spacebar":
return "space";
case "del":
return "delete";
case "ins":
return "insert";
case "pgup":
case "pageup":
return "pageUp";
case "pgdown":
case "pagedown":
return "pageDown";
case "arrowup":
return "up";
case "arrowdown":
return "down";
case "arrowleft":
return "left";
case "arrowright":
return "right";
default:
return part;
}
}