diff --git a/.changeset/complete-synthetic-key-events.md b/.changeset/complete-synthetic-key-events.md new file mode 100644 index 000000000..e9c53bfb0 --- /dev/null +++ b/.changeset/complete-synthetic-key-events.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Provide complete key event data to command matchers and programmatically invoked handlers. diff --git a/src/lib/commandKeys.test.ts b/src/lib/commandKeys.test.ts index 121d849f0..54c451581 100644 --- a/src/lib/commandKeys.test.ts +++ b/src/lib/commandKeys.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { matchesKeyChord, parseKeyChord, synthesizeKeyEvent, toKeyChordList } from "./commandKeys"; +import { KeyEvent } from "@opentui/core"; +import { matchesKeyChord, parseKeyChord, toKeyChordList } from "./commandKeys"; +import { synthesizeKeyEvent } from "../ui/lib/syntheticKeyEvent"; /** * The internal-only pieces of chord handling. @@ -23,6 +25,25 @@ describe("synthesizeKeyEvent", () => { expect(matchesKeyChord(parsed(chord), synthesizeKeyEvent(parsed(chord)))).toBe(true); } }); + + test("returns a complete key event", () => { + const event = synthesizeKeyEvent(parsed("ctrl+r")); + + expect(event).toBeInstanceOf(KeyEvent); + expect({ eventType: event.eventType, source: event.source }).toEqual({ + eventType: "press", + source: "raw", + }); + event.preventDefault(); + event.stopPropagation(); + expect({ + defaultPrevented: event.defaultPrevented, + propagationStopped: event.propagationStopped, + }).toEqual({ + defaultPrevented: true, + propagationStopped: true, + }); + }); }); describe("toKeyChordList", () => { diff --git a/src/lib/commandKeys.ts b/src/lib/commandKeys.ts index 65c9143f7..f9de7322f 100644 --- a/src/lib/commandKeys.ts +++ b/src/lib/commandKeys.ts @@ -20,39 +20,6 @@ import { matchesKeyChord, parseKeyChord, type ParsedKeyChord } from "../extensio export { matchesKeyChord, parseKeyChord } from "../extension-api/keys"; export type { ParsedKeyChord } from "../extension-api/keys"; -/** - * Build a synthetic key event that would satisfy the parsed chord. - * - * This exists for conflict detection: a command may match with a predicate - * rather than chords, so the only way to ask "would this chord collide with an - * existing binding?" is to synthesize the event the chord describes and run it - * through every matcher. - */ -export function synthesizeKeyEvent(parsed: ParsedKeyChord): KeyEvent { - // Chord bases are either one literal character or a validated named key, so - // length alone separates the two without re-consulting the named-key table. - const isNamed = parsed.base.length > 1; - const isLetter = /^[a-z]$/.test(parsed.base); - const sequence = isNamed - ? "" - : parsed.shift && isLetter - ? parsed.base.toUpperCase() - : parsed.base; - - return { - name: isNamed || isLetter ? parsed.base : sequence, - sequence, - raw: sequence, - ctrl: parsed.ctrl, - meta: parsed.meta, - option: parsed.option, - shift: parsed.shift, - number: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as unknown as KeyEvent; -} - /** * Normalize one declared binding into the list of chords it names. * diff --git a/src/ui/lib/appCommands.ts b/src/ui/lib/appCommands.ts index 036d8f7bb..5590e0f3b 100644 --- a/src/ui/lib/appCommands.ts +++ b/src/ui/lib/appCommands.ts @@ -8,12 +8,9 @@ import { import type { ReviewSelectionScope } from "../../core/review/navigation"; import type { CursorLine, LayoutMode } from "../../core/types"; import type { ExtensionCommandExecutionOptions } from "../../extension-api/types"; -import { - matchesAnyKeyChord, - parseKeyChordOrUndefined, - synthesizeKeyEvent, -} from "../../lib/commandKeys"; +import { matchesAnyKeyChord, parseKeyChordOrUndefined } from "../../lib/commandKeys"; import { formatKeyChord, type CommandKeyDefaults } from "./keymap"; +import { synthesizeKeyEvent } from "./syntheticKeyEvent"; type ScrollUnit = "step" | "viewport" | "content" | "half"; diff --git a/src/ui/lib/extensionCommands.test.ts b/src/ui/lib/extensionCommands.test.ts index bca467243..4f3caef36 100644 --- a/src/ui/lib/extensionCommands.test.ts +++ b/src/ui/lib/extensionCommands.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { RegisteredCommand } from "../../extensions/types"; -import { synthesizeKeyEvent, parseKeyChord } from "../../lib/commandKeys"; +import { parseKeyChord } from "../../lib/commandKeys"; +import { synthesizeKeyEvent } from "./syntheticKeyEvent"; import { builtinCommandMatchProbes, dispatchAppCommand } from "./appCommands"; import { buildExtensionAppCommands } from "./extensionCommands"; diff --git a/src/ui/lib/extensionCommands.ts b/src/ui/lib/extensionCommands.ts index 2208c532e..0c0306ade 100644 --- a/src/ui/lib/extensionCommands.ts +++ b/src/ui/lib/extensionCommands.ts @@ -1,13 +1,9 @@ import type { KeyEvent } from "@opentui/core"; import type { RegisteredCommand } from "../../extensions/types"; -import { - matchesKeyChord, - parseKeyChordOrUndefined, - synthesizeKeyEvent, - toKeyChordList, -} from "../../lib/commandKeys"; +import { matchesKeyChord, parseKeyChordOrUndefined, toKeyChordList } from "../../lib/commandKeys"; import type { AppCommand, ResolvedCommandKeys } from "./appCommands"; import { formatKeyChord } from "./keymap"; +import { synthesizeKeyEvent } from "./syntheticKeyEvent"; /** One extension binding refused because its chord is already taken. */ export interface ExtensionCommandConflict { diff --git a/src/ui/lib/syntheticKeyEvent.ts b/src/ui/lib/syntheticKeyEvent.ts new file mode 100644 index 000000000..dee75af75 --- /dev/null +++ b/src/ui/lib/syntheticKeyEvent.ts @@ -0,0 +1,26 @@ +import { KeyEvent } from "@opentui/core"; +import type { ParsedKeyChord } from "../../lib/commandKeys"; + +/** Build a synthetic key event for interactive command conflict detection. */ +export function synthesizeKeyEvent(parsed: ParsedKeyChord): KeyEvent { + const isNamed = parsed.base.length > 1; + const isLetter = /^[a-z]$/.test(parsed.base); + const sequence = isNamed + ? "" + : parsed.shift && isLetter + ? parsed.base.toUpperCase() + : parsed.base; + + return new KeyEvent({ + name: isNamed || isLetter ? parsed.base : sequence, + sequence, + raw: sequence, + ctrl: parsed.ctrl, + meta: parsed.meta, + option: parsed.option, + shift: parsed.shift, + number: false, + eventType: "press", + source: "raw", + }); +}