Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/complete-synthetic-key-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Provide complete key event data to command matchers and programmatically invoked handlers.
23 changes: 22 additions & 1 deletion src/lib/commandKeys.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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", () => {
Expand Down
33 changes: 0 additions & 33 deletions src/lib/commandKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
7 changes: 2 additions & 5 deletions src/ui/lib/appCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
3 changes: 2 additions & 1 deletion src/ui/lib/extensionCommands.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
8 changes: 2 additions & 6 deletions src/ui/lib/extensionCommands.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions src/ui/lib/syntheticKeyEvent.ts
Original file line number Diff line number Diff line change
@@ -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",
});
}