/ keybindings / match.ts
match.ts
  1  import type { Key } from '../ink.js'
  2  import type { ParsedBinding, ParsedKeystroke } from './types.js'
  3  
  4  /**
  5   * Modifier keys from Ink's Key type that we care about for matching.
  6   * Note: `fn` from Key is intentionally excluded as it's rarely used and
  7   * not commonly configurable in terminal applications.
  8   */
  9  type InkModifiers = Pick<Key, 'ctrl' | 'shift' | 'meta' | 'super'>
 10  
 11  /**
 12   * Extract modifiers from an Ink Key object.
 13   * This function ensures we're explicitly extracting the modifiers we care about.
 14   */
 15  function getInkModifiers(key: Key): InkModifiers {
 16    return {
 17      ctrl: key.ctrl,
 18      shift: key.shift,
 19      meta: key.meta,
 20      super: key.super,
 21    }
 22  }
 23  
 24  /**
 25   * Extract the normalized key name from Ink's Key + input.
 26   * Maps Ink's boolean flags (key.escape, key.return, etc.) to string names
 27   * that match our ParsedKeystroke.key format.
 28   */
 29  export function getKeyName(input: string, key: Key): string | null {
 30    if (key.escape) return 'escape'
 31    if (key.return) return 'enter'
 32    if (key.tab) return 'tab'
 33    if (key.backspace) return 'backspace'
 34    if (key.delete) return 'delete'
 35    if (key.upArrow) return 'up'
 36    if (key.downArrow) return 'down'
 37    if (key.leftArrow) return 'left'
 38    if (key.rightArrow) return 'right'
 39    if (key.pageUp) return 'pageup'
 40    if (key.pageDown) return 'pagedown'
 41    if (key.wheelUp) return 'wheelup'
 42    if (key.wheelDown) return 'wheeldown'
 43    if (key.home) return 'home'
 44    if (key.end) return 'end'
 45    if (input.length === 1) return input.toLowerCase()
 46    return null
 47  }
 48  
 49  /**
 50   * Check if all modifiers match between Ink Key and ParsedKeystroke.
 51   *
 52   * Alt and Meta: Ink historically set `key.meta` for Alt/Option. A `meta`
 53   * modifier in config is treated as an alias for `alt` — both match when
 54   * `key.meta` is true.
 55   *
 56   * Super (Cmd/Win): distinct from alt/meta. Only arrives via the kitty
 57   * keyboard protocol on supporting terminals. A `cmd`/`super` binding will
 58   * simply never fire on terminals that don't send it.
 59   */
 60  function modifiersMatch(
 61    inkMods: InkModifiers,
 62    target: ParsedKeystroke,
 63  ): boolean {
 64    // Check ctrl modifier
 65    if (inkMods.ctrl !== target.ctrl) return false
 66  
 67    // Check shift modifier
 68    if (inkMods.shift !== target.shift) return false
 69  
 70    // Alt and meta both map to key.meta in Ink (terminal limitation)
 71    // So we check if EITHER alt OR meta is required in target
 72    const targetNeedsMeta = target.alt || target.meta
 73    if (inkMods.meta !== targetNeedsMeta) return false
 74  
 75    // Super (cmd/win) is a distinct modifier from alt/meta
 76    if (inkMods.super !== target.super) return false
 77  
 78    return true
 79  }
 80  
 81  /**
 82   * Check if a ParsedKeystroke matches the given Ink input + Key.
 83   *
 84   * The display text will show platform-appropriate names (opt on macOS, alt elsewhere).
 85   */
 86  export function matchesKeystroke(
 87    input: string,
 88    key: Key,
 89    target: ParsedKeystroke,
 90  ): boolean {
 91    const keyName = getKeyName(input, key)
 92    if (keyName !== target.key) return false
 93  
 94    const inkMods = getInkModifiers(key)
 95  
 96    // QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
 97    // This is a legacy behavior from how escape sequences work in terminals.
 98    // We need to ignore the meta modifier when matching the escape key itself,
 99    // otherwise bindings like "escape" (without modifiers) would never match.
100    if (key.escape) {
101      return modifiersMatch({ ...inkMods, meta: false }, target)
102    }
103  
104    return modifiersMatch(inkMods, target)
105  }
106  
107  /**
108   * Check if Ink's Key + input matches a parsed binding's first keystroke.
109   * For single-keystroke bindings only (Phase 1).
110   */
111  export function matchesBinding(
112    input: string,
113    key: Key,
114    binding: ParsedBinding,
115  ): boolean {
116    if (binding.chord.length !== 1) return false
117    const keystroke = binding.chord[0]
118    if (!keystroke) return false
119    return matchesKeystroke(input, key, keystroke)
120  }