import { assertEquals, assertThrows } from '@std/assert'; import { createKeybindHandler, createKeybindTracker, InhibitFlags, type KeybindTracker, matchKeybindPress, MOD, MODIFIERS, parseKeybind, parseKeybindChord, } from './mod.ts'; // `const enum` members are only reachable through property access const ARROW = InhibitFlags.ARROW; const ENTER = InhibitFlags.ENTER; const ENTER_EXTRA = InhibitFlags.ENTER_EXTRA; const SELECT = InhibitFlags.SELECT; const SPACE = InhibitFlags.SPACE; const TEXT = InhibitFlags.TEXT; const INHIBITED: [keybind: string, flags: number][] = [ // text insertion ['a', SELECT | TEXT], ['z', SELECT | TEXT], ['Space', SPACE | SELECT | TEXT], // uppercase text insertion ['Shift+X', TEXT], ['Shift+Y', TEXT], // text deletion ['Backspace', TEXT], ['$mod+Backspace', TEXT], // per-char cursor positioning ['ArrowLeft', ARROW | SELECT | TEXT], ['ArrowRight', ARROW | SELECT | TEXT], ['ArrowUp', ARROW | SELECT | TEXT], ['ArrowDown', ARROW | SELECT | TEXT], // per-char cursor selection ['Shift+ArrowLeft', TEXT], ['Shift+ArrowRight', TEXT], ['Shift+ArrowUp', TEXT], ['Shift+ArrowDown', TEXT], // per-word cursor positioning ['$mod+ArrowLeft', TEXT], ['$mod+ArrowRight', TEXT], ['$mod+ArrowUp', TEXT], ['$mod+ArrowDown', TEXT], // per-word cursor selection ['$mod+Shift+ArrowLeft', TEXT], ['$mod+Shift+ArrowRight', TEXT], ['$mod+Shift+ArrowUp', TEXT], ['$mod+Shift+ArrowDown', TEXT], // common text input shortcuts ['$mod+a', TEXT], ['$mod+c', TEXT], ['$mod+v', TEXT], ['$mod+x', TEXT], ['$mod+y', TEXT], ['$mod+z', TEXT], // enter action ['Enter', ENTER], ['NumpadEnter', ENTER], ['$mod+Enter', ENTER_EXTRA], ['$mod+Shift+Enter', ENTER_EXTRA], // keys whose names merely start like a modifier ['Comma', SELECT | TEXT], ['End', SELECT | TEXT], ['Home', SELECT | TEXT], ['ContextMenu', SELECT | TEXT], // per-word deletion and per-document positioning ['$mod+Delete', TEXT], ['$mod+Home', TEXT], ['$mod+End', TEXT], ['$mod+Shift+Home', TEXT], ['$mod+Shift+End', TEXT], // redo, and paste without formatting ['$mod+Shift+z', TEXT], ['$mod+Shift+v', TEXT], // `event.code` spellings have to inhibit the same as `event.key` ones ['KeyA', SELECT | TEXT], ['$mod+KeyA', TEXT], ['$mod+KeyC', TEXT], ['$mod+KeyV', TEXT], ['$mod+Shift+KeyZ', TEXT], // keys that don't produce text ['Escape', 0], ['Tab', 0], ['F5', 0], ['CapsLock', 0], ['Shift+Tab', 0], // bindings that shouldn't be inhibited at all ['$mod+k', 0], ['$mod+KeyK', 0], ['$mod+Shift+k', 0], ]; const ESCAPED: [keybind: string, flags: number][] = [ ['!s', 0], ['!ArrowDown', 0], ['!Enter', 0], ['!$mod+a', 0], ]; // #region test doubles class FakeAnchor { constructor(readonly href: string) {} } class FakeInput { constructor(readonly type: string) {} } // provide DOM constructors for `isKeybindAllowed`'s instanceof checks in Deno Object.assign(globalThis, { HTMLAnchorElement: FakeAnchor, HTMLButtonElement: class {}, HTMLElement: class {}, HTMLInputElement: FakeInput, HTMLSelectElement: class {}, HTMLTextAreaElement: class {}, }); interface FakeEvent { key: string; code: string; repeat: boolean; timeStamp: number; defaultPrevented: boolean; preventDefault(): void; getModifierState(mod: string): boolean; composedPath(): EventTarget[]; } interface EventOptions { defaultPrevented?: boolean; repeat?: boolean; target?: object; timeStamp?: number; } // the double implements only the KeyboardEvent members these tests need const asEvent = (ev: FakeEvent): KeyboardEvent => ev as unknown as KeyboardEvent; const createEvent = ( press: string, { defaultPrevented = false, repeat = false, target, timeStamp = 0 }: EventOptions = {}, ): FakeEvent => { const [mods, key] = parseKeybind(press); const path = target !== undefined ? [target as EventTarget] : []; const ev: FakeEvent = { key, code: key, repeat, timeStamp, defaultPrevented, preventDefault: () => { ev.defaultPrevented = true; }, // a modifier reports itself as held while its own keydown is dispatched getModifierState: (mod) => mods.includes(mod) || (mod == key && MODIFIERS.includes(key)), composedPath: () => path, }; return ev; }; const dispatch = ( handler: (ev: KeyboardEvent) => void, press: string, options?: EventOptions, ): FakeEvent => { const ev = createEvent(press, options); handler(asEvent(ev)); return ev; }; const feed = (tracker: KeybindTracker, press: string, options?: EventOptions) => { return tracker.match(asEvent(createEvent(press, options))); }; // #endregion Deno.test('parseKeybind handles certain keybinds', async (t) => { for (const [keybind, expected] of INHIBITED) { await t.step(keybind, () => { const [_mods, _key, flags] = parseKeybind(keybind); assertEquals(flags, expected); }); } }); Deno.test('parseKeybind opts out of inhibition when prefixed with `!`', async (t) => { for (const [keybind, expected] of ESCAPED) { await t.step(keybind, () => { const [_mods, _key, flags] = parseKeybind(keybind); assertEquals(flags, expected); }); } }); Deno.test('parseKeybind resolves $mod', () => { const [mods] = parseKeybind('$mod+Shift+k'); assertEquals(mods, [MOD, 'Shift']); }); Deno.test('parseKeybind rejects unknown modifiers', () => { assertThrows(() => parseKeybind('Ctrl+k'), TypeError); assertThrows(() => parseKeybind('$mod+Option+k'), TypeError); parseKeybind('$mod+Shift+k'); }); Deno.test('parseKeybind rejects presses that name no key', () => { assertThrows(() => parseKeybind(''), TypeError); assertThrows(() => parseKeybind('$mod+'), TypeError); assertEquals(parseKeybind('$mod++')[1], '+'); }); Deno.test('parseKeybind rejects whitespace', () => { assertThrows(() => parseKeybind('$mod + k'), TypeError); assertThrows(() => parseKeybind('g g'), TypeError); }); Deno.test('parseKeybindChord splits presses on whitespace', () => { const chord = parseKeybindChord('$mod+k s'); assertEquals(chord.length, 2); assertEquals(chord[0][0], [MOD]); assertEquals(chord[0][1], 'k'); assertEquals(chord[1][0], []); assertEquals(chord[1][1], 's'); }); Deno.test('parseKeybindChord applies `!` to the opening press', () => { const chord = parseKeybindChord('!s a'); assertEquals(chord[0][2], 0); }); Deno.test('matchKeybindPress ignores inhibition', () => { const keybind = parseKeybind('s'); const ev = createEvent('s'); ev.composedPath = () => { throw new Error('composedPath should not be called'); }; assertEquals(matchKeybindPress(asEvent(ev), keybind), true); }); // #region chord handling Deno.test('createKeybindHandler fires a single press keybind', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k': () => fired++ }); const ev = dispatch(handler, '$mod+k'); assertEquals(fired, 1); assertEquals(ev.defaultPrevented, true); }); Deno.test('createKeybindHandler fires a chord only once complete', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k s': () => fired++ }); const first = dispatch(handler, '$mod+k'); assertEquals(fired, 0); assertEquals(first.defaultPrevented, true); dispatch(handler, 's'); assertEquals(fired, 1); // completion resets chord progress dispatch(handler, 's'); assertEquals(fired, 1); }); Deno.test('createKeybindHandler fires a chord of bare presses', () => { let fired = 0; const handler = createKeybindHandler({ 'g g': () => fired++ }); dispatch(handler, 'g'); dispatch(handler, 'g'); assertEquals(fired, 1); }); Deno.test('createKeybindHandler re-evaluates a press that abandons a chord', () => { const fired: string[] = []; const handler = createKeybindHandler({ '$mod+k s': () => fired.push('chord'), 'g': () => fired.push('g'), }); dispatch(handler, '$mod+k'); dispatch(handler, 'g'); assertEquals(fired, ['g']); }); Deno.test('createKeybindHandler keeps a chord alive across modifier presses', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k s': () => fired++ }); dispatch(handler, '$mod+k'); dispatch(handler, 'Shift'); dispatch(handler, 's'); assertEquals(fired, 1); }); Deno.test('createKeybindHandler abandons a chord after the timeout', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k s': () => fired++ }, { timeout: 1000 }); dispatch(handler, '$mod+k', { timeStamp: 0 }); const late = dispatch(handler, 's', { timeStamp: 1500 }); assertEquals(fired, 0); assertEquals(late.defaultPrevented, false); }); Deno.test('createKeybindHandler skips events that were already handled', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k': () => fired++ }); dispatch(handler, '$mod+k', { defaultPrevented: true }); assertEquals(fired, 0); }); Deno.test('createKeybindHandler ignores held keys mid-chord', () => { let fired = 0; const handler = createKeybindHandler({ 'g g': () => fired++ }); dispatch(handler, 'g'); const held = dispatch(handler, 'g', { repeat: true }); assertEquals(fired, 0); assertEquals(held.defaultPrevented, true); dispatch(handler, 'g'); assertEquals(fired, 1); }); Deno.test('createKeybindHandler leaves matched events alone when opted out', () => { const handler = createKeybindHandler({ '$mod+k s': () => {} }, { preventDefault: false }); const first = dispatch(handler, '$mod+k'); const second = dispatch(handler, 's'); assertEquals(first.defaultPrevented, true); assertEquals(second.defaultPrevented, false); }); Deno.test('createKeybindHandler only inhibits the opening press of a chord', () => { let fired = 0; const handler = createKeybindHandler({ '$mod+k s': () => fired++ }); const target = new FakeInput('text'); // bare `s` is inhibited in a text field, but allowed as a continuation dispatch(handler, '$mod+k', { target }); dispatch(handler, 's', { target }); assertEquals(fired, 1); }); Deno.test('createKeybindHandler inhibits a chord that opens on a bare press', () => { let fired = 0; const handler = createKeybindHandler({ 'g g': () => fired++ }); const target = new FakeInput('text'); dispatch(handler, 'g', { target }); dispatch(handler, 'g', { target }); assertEquals(fired, 0); }); Deno.test('createKeybindHandler only reserves Enter on anchors that navigate', () => { let fired = 0; const handler = createKeybindHandler({ Enter: () => fired++ }); dispatch(handler, 'Enter', { target: new FakeAnchor('https://example.com') }); assertEquals(fired, 0); dispatch(handler, 'Enter', { target: new FakeAnchor('') }); assertEquals(fired, 1); }); Deno.test('createKeybindTracker reports what each press amounted to', () => { const tracker = createKeybindTracker([[parseKeybindChord('$mod+k s'), 'save']]); assertEquals(feed(tracker, 'Escape'), ['none']); assertEquals(feed(tracker, '$mod+k'), ['pending']); assertEquals(feed(tracker, 's'), ['match', 'save']); }); Deno.test('createKeybindTracker abandons the chord underway on reset', () => { const tracker = createKeybindTracker([[parseKeybindChord('$mod+k s'), 'save']]); feed(tracker, '$mod+k'); tracker.reset(); assertEquals(feed(tracker, 's'), ['none']); }); Deno.test('createKeybindHandler rejects a chord that is a prefix of another', () => { assertThrows( () => createKeybindHandler({ '$mod+k': () => {}, '$mod+k s': () => {} }), TypeError, ); assertThrows( () => createKeybindHandler({ '$mod+k s': () => {}, '$mod+k': () => {} }), TypeError, ); // modifier order doesn't make two presses distinct assertThrows( () => createKeybindHandler({ 'Shift+$mod+k': () => {}, '$mod+Shift+k s': () => {} }), TypeError, ); // shared prefixes are allowed when neither chord is complete createKeybindHandler({ '$mod+k s': () => {}, '$mod+k t': () => {} }); }); // #endregion