init: add source code from src.zip
This commit is contained in:
244
src/keybindings/resolver.ts
Normal file
244
src/keybindings/resolver.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Key } from '../ink.js'
|
||||
import { getKeyName, matchesBinding } from './match.js'
|
||||
import { chordToString } from './parser.js'
|
||||
import type {
|
||||
KeybindingContextName,
|
||||
ParsedBinding,
|
||||
ParsedKeystroke,
|
||||
} from './types.js'
|
||||
|
||||
export type ResolveResult =
|
||||
| { type: 'match'; action: string }
|
||||
| { type: 'none' }
|
||||
| { type: 'unbound' }
|
||||
|
||||
export type ChordResolveResult =
|
||||
| { type: 'match'; action: string }
|
||||
| { type: 'none' }
|
||||
| { type: 'unbound' }
|
||||
| { type: 'chord_started'; pending: ParsedKeystroke[] }
|
||||
| { type: 'chord_cancelled' }
|
||||
|
||||
/**
|
||||
* Resolve a key input to an action.
|
||||
* Pure function - no state, no side effects, just matching logic.
|
||||
*
|
||||
* @param input - The character input from Ink
|
||||
* @param key - The Key object from Ink with modifier flags
|
||||
* @param activeContexts - Array of currently active contexts (e.g., ['Chat', 'Global'])
|
||||
* @param bindings - All parsed bindings to search through
|
||||
* @returns The resolution result
|
||||
*/
|
||||
export function resolveKey(
|
||||
input: string,
|
||||
key: Key,
|
||||
activeContexts: KeybindingContextName[],
|
||||
bindings: ParsedBinding[],
|
||||
): ResolveResult {
|
||||
// Find matching bindings (last one wins for user overrides)
|
||||
let match: ParsedBinding | undefined
|
||||
const ctxSet = new Set(activeContexts)
|
||||
|
||||
for (const binding of bindings) {
|
||||
// Phase 1: Only single-keystroke bindings
|
||||
if (binding.chord.length !== 1) continue
|
||||
if (!ctxSet.has(binding.context)) continue
|
||||
|
||||
if (matchesBinding(input, key, binding)) {
|
||||
match = binding
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
return { type: 'none' }
|
||||
}
|
||||
|
||||
if (match.action === null) {
|
||||
return { type: 'unbound' }
|
||||
}
|
||||
|
||||
return { type: 'match', action: match.action }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display text for an action from bindings (e.g., "ctrl+t" for "app:toggleTodos").
|
||||
* Searches in reverse order so user overrides take precedence.
|
||||
*/
|
||||
export function getBindingDisplayText(
|
||||
action: string,
|
||||
context: KeybindingContextName,
|
||||
bindings: ParsedBinding[],
|
||||
): string | undefined {
|
||||
// Find the last binding for this action in this context
|
||||
const binding = bindings.findLast(
|
||||
b => b.action === action && b.context === context,
|
||||
)
|
||||
return binding ? chordToString(binding.chord) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ParsedKeystroke from Ink's input/key.
|
||||
*/
|
||||
function buildKeystroke(input: string, key: Key): ParsedKeystroke | null {
|
||||
const keyName = getKeyName(input, key)
|
||||
if (!keyName) return null
|
||||
|
||||
// QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
|
||||
// This is legacy terminal behavior - we should NOT record this as a modifier
|
||||
// for the escape key itself, otherwise chord matching will fail.
|
||||
const effectiveMeta = key.escape ? false : key.meta
|
||||
|
||||
return {
|
||||
key: keyName,
|
||||
ctrl: key.ctrl,
|
||||
alt: effectiveMeta,
|
||||
shift: key.shift,
|
||||
meta: effectiveMeta,
|
||||
super: key.super,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two ParsedKeystrokes for equality. Collapses alt/meta into
|
||||
* one logical modifier — legacy terminals can't distinguish them (see
|
||||
* match.ts modifiersMatch), so "alt+k" and "meta+k" are the same key.
|
||||
* Super (cmd/win) is distinct — only arrives via kitty keyboard protocol.
|
||||
*/
|
||||
export function keystrokesEqual(
|
||||
a: ParsedKeystroke,
|
||||
b: ParsedKeystroke,
|
||||
): boolean {
|
||||
return (
|
||||
a.key === b.key &&
|
||||
a.ctrl === b.ctrl &&
|
||||
a.shift === b.shift &&
|
||||
(a.alt || a.meta) === (b.alt || b.meta) &&
|
||||
a.super === b.super
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a chord prefix matches the beginning of a binding's chord.
|
||||
*/
|
||||
function chordPrefixMatches(
|
||||
prefix: ParsedKeystroke[],
|
||||
binding: ParsedBinding,
|
||||
): boolean {
|
||||
if (prefix.length >= binding.chord.length) return false
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
const prefixKey = prefix[i]
|
||||
const bindingKey = binding.chord[i]
|
||||
if (!prefixKey || !bindingKey) return false
|
||||
if (!keystrokesEqual(prefixKey, bindingKey)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a full chord matches a binding's chord.
|
||||
*/
|
||||
function chordExactlyMatches(
|
||||
chord: ParsedKeystroke[],
|
||||
binding: ParsedBinding,
|
||||
): boolean {
|
||||
if (chord.length !== binding.chord.length) return false
|
||||
for (let i = 0; i < chord.length; i++) {
|
||||
const chordKey = chord[i]
|
||||
const bindingKey = binding.chord[i]
|
||||
if (!chordKey || !bindingKey) return false
|
||||
if (!keystrokesEqual(chordKey, bindingKey)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a key with chord state support.
|
||||
*
|
||||
* This function handles multi-keystroke chord bindings like "ctrl+k ctrl+s".
|
||||
*
|
||||
* @param input - The character input from Ink
|
||||
* @param key - The Key object from Ink with modifier flags
|
||||
* @param activeContexts - Array of currently active contexts
|
||||
* @param bindings - All parsed bindings
|
||||
* @param pending - Current chord state (null if not in a chord)
|
||||
* @returns Resolution result with chord state
|
||||
*/
|
||||
export function resolveKeyWithChordState(
|
||||
input: string,
|
||||
key: Key,
|
||||
activeContexts: KeybindingContextName[],
|
||||
bindings: ParsedBinding[],
|
||||
pending: ParsedKeystroke[] | null,
|
||||
): ChordResolveResult {
|
||||
// Cancel chord on escape
|
||||
if (key.escape && pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
|
||||
// Build current keystroke
|
||||
const currentKeystroke = buildKeystroke(input, key)
|
||||
if (!currentKeystroke) {
|
||||
if (pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
return { type: 'none' }
|
||||
}
|
||||
|
||||
// Build the full chord sequence to test
|
||||
const testChord = pending
|
||||
? [...pending, currentKeystroke]
|
||||
: [currentKeystroke]
|
||||
|
||||
// Filter bindings by active contexts (Set lookup: O(n) instead of O(n·m))
|
||||
const ctxSet = new Set(activeContexts)
|
||||
const contextBindings = bindings.filter(b => ctxSet.has(b.context))
|
||||
|
||||
// Check if this could be a prefix for longer chords. Group by chord
|
||||
// string so a later null-override shadows the default it unbinds —
|
||||
// otherwise null-unbinding `ctrl+x ctrl+k` still makes `ctrl+x` enter
|
||||
// chord-wait and the single-key binding on the prefix never fires.
|
||||
const chordWinners = new Map<string, string | null>()
|
||||
for (const binding of contextBindings) {
|
||||
if (
|
||||
binding.chord.length > testChord.length &&
|
||||
chordPrefixMatches(testChord, binding)
|
||||
) {
|
||||
chordWinners.set(chordToString(binding.chord), binding.action)
|
||||
}
|
||||
}
|
||||
let hasLongerChords = false
|
||||
for (const action of chordWinners.values()) {
|
||||
if (action !== null) {
|
||||
hasLongerChords = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If this keystroke could start a longer chord, prefer that
|
||||
// (even if there's an exact single-key match)
|
||||
if (hasLongerChords) {
|
||||
return { type: 'chord_started', pending: testChord }
|
||||
}
|
||||
|
||||
// Check for exact matches (last one wins)
|
||||
let exactMatch: ParsedBinding | undefined
|
||||
for (const binding of contextBindings) {
|
||||
if (chordExactlyMatches(testChord, binding)) {
|
||||
exactMatch = binding
|
||||
}
|
||||
}
|
||||
|
||||
if (exactMatch) {
|
||||
if (exactMatch.action === null) {
|
||||
return { type: 'unbound' }
|
||||
}
|
||||
return { type: 'match', action: exactMatch.action }
|
||||
}
|
||||
|
||||
// No match and no potential longer chords
|
||||
if (pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
|
||||
return { type: 'none' }
|
||||
}
|
||||
Reference in New Issue
Block a user