composable-keybindings

1.1.0 • Public • Published

composable-keybindings

Define complex keybindings, inspired by Vim.

At a glance

import { Keybinder } = from 'composable-keybindings';

const keybindings = new Map([
  ['t', { name: 'test' }],
  ['ctrl+f', { name: 'find' }],
  ['z', {
    name: 'z',
    keybindings: new Map([
      ['t', { name: 'nested-t' }]
    ])
  }]
]);

const keybinder = new Keybinder(keybindings, (type, keybinding) => {
  if (type === 'keybinding') {
    console.log('found keybinding', keybinding);
  } else if (type === 'unrecognized') {
    console.log('unrecognized');
  }
});

keybinder.handleKey('t');
// {
//   keys: ['t'],
//   count: 1,
//   countChars: [],
//   action: { name: 'test' },
//   store: {}
// }

Keybinding map

Recommended to use strings as keys and objects as values. Keybinding actions (map values) accept 3 special fields:

Nested keybindings

Allows multiple keys to be inputted before firing an event.

new Map([
  ['b', {
    keybindings: new Map([
      ['r', {
        keybindings: new Map([
          ['b', { name: 'be-right-back' }]
        ])
      }]
    ])
  }]
]);

// inputting `brb` emits:
// keybinding
// {
//   keys: ['b', 'r', 'b'],
//   count: 1,
//   countChars: [],
//   action: { name: 'be-right-back' },
//   store: {}
// }

Keybinding count

Number characters are interpreted as count and by default do not fire keybindings when inputted.

new Map([['t', { name: 'test' }]])

// inputting `30t` emits:
// type 'keybinding'
// {
//   keys: ['3', '0', 't'],
//   count: 30,
//   countChars: ['3', '0'],
//   action: { name: 'test' },
//   store: {}
// }

isKeyNumber in keybinder options dictates whether number characters are interpreted as count.

Behavior Function

The behavior function is used for more dynamic behavior.

new Map(['g', {
  name: 'read-three',
  behavior: (instructions, kb) => {
    instructions.read(3, keys => {
      kb.store.readThree = keys;
      instructions.done();
    });
  }
}]);

keybinder.handleKeys(['g', 'x', 'y', 'z']);
// emits:
// type 'keybinding'
// {
//   keys: ['g', 'x', 'y', 'z'],
//   count: 1,
//   countChars: [],
//   action: { name: 'read-three', behavior: [Function: behavior] },
//   store: { readThree: ['x', 'y', 'z'] }
// }

The function takes in an instructions API and the current keybinding that's being generated. 3 instructions are available:

instructions.done() must be called to complete the behavior function.

Nested keybindings take priority over the behavior function. Extending the above example, if gx was defined as a nested keybinding the behavior function would not be called.

instructions.read

Waits for more keys to be inputted, then calls the callback. Requires 2 arguments: the count and callback function. Callback function is called with the array of inputted keys.

instructions.read(1, keys => {
  console.log(keys.length); // 1
  instructions.done();
});

instructions.interpret

Waits for the next inputted key, then checks if it points to an action in the keybinding map. If so, that action's behavior function is entered. It requires a callback function and optionally a filter function.

The callback function is called given the same arguments as the keybinder listener, the type and keybinding of the sub-event.

The filter function is called given the found sub-action and should return a boolean true if OK to continue and false if the keybinding should be cancelled.

instructions.interpret() will interpret any defined keybindings "above" the action it's defined in. To scope keybindings inside an action or override "above" actions, specify an interprets map.

Actions are not allowed to interpret themselves in order to avoid endless loops. In that case the 'cancel' event would emit.

new Map([
  // root 'a'
  ['a', {
    behavior: (instructions, kb) => {
      // `instructions.interpret()` here can look up all "above" actions (root
      // 'b' and root 'c'), as well as scoped `interprets` ('d'). However a
      // filter is given that explicity ignores 'c'
      instructions.interpret((type, subKeybinding) => {
        instructions.done();
      }, (action) => action.name !== 'c')
    },
    inteprets: new Map([
      // scoped and overrided 'b'
      ['b', {
        behavior: (instructions, kb) => {
          // inputting keys `ab` will begin to read 2 keys, not 1
          instructions.read(2, keys => instructions.done());
        }
      }],
      // scoped 'd'
      ['d', {
        behavior: (instructions, kb) => {
          instructions.done();
        }
      }]
    ])
  }],

  // root 'b'
  ['b', {
    behavior: (instructions, kb) => {
      instructions.read(1, keys => instructions.done());
    }
  }],

  // root 'c'
  ['c', {
    name: 'c',
    behavior: (instructions, kb) => {
      instructions.done();
    }
  }],
])

instructions.emit

Calls the listener function given with the given arguments. Similar to instructions.done but doesn't reset the key map.

instructions.done

Must be called to signal the behavior function is finished. If this keybinding is being "interpreted" by another behavior function, calling this will bubble up to the parent's interpret() callback function, otherwise an event will emitted with type either 'keybinding', 'unrecognized', or 'cancel'.

Optionally takes an options object:

  • type: 'keybinding', 'unrecognized', or 'cancel' (default 'keybinding')
  • status: one of the status enums (default STATUS.DONE)

If a string is given instead of an options object, it represents type.

type can also be 'resume' or STATUS.WAITING, which will avoid emitting a keybinding event and continue building on the current generated keybinding object.

Keybinding store

Any other data you want associated with the keybinding event used in conjuction with the behavior function. The store can have any data in it and is not used internally.

Keybinder listener

The listener is called whenever something happens. It's called with the event type and the associated generated keybinding object.

// type: 'keybinding', 'unrecognized', or 'cancel'
// keybinding: generated keybinding object
new Keybinder(map, (type, keybinding) => {
  if (type === 'keybinding') {
    console.log('keybinding event emitted', keybinding);
  } else if (type === 'unrecognized') {
    console.log('no defined keybinding for the inputted keys', keybinding);
  } else if (type === 'cancel') {
    console.log('keybinding was cancelled', keybinding);
  }
});

Keybinder options

Defaults, all optional:

const DEFAULTS = {
	getKeybinding: (key, map) => map.get(key),
	isKeyNumber: key => /\d/.test(key),
	isKeyEscape: key => key === 'escape',
	store: {},
	filter: null
};
  • getKeybinding: an intermediary step to map keypress info into the keys inside the keybinding map.
  • isKeyNumber: an intermediary step to map keypress info into whether it's a number.
  • isKeyEscape: an intermediary step to map keypress info into whether it's the escape key.
  • store: any associated data to be attached to keybinding events (used internally).
  • filter: a function to filter out unwanted keybindings (used internally), see instructions.interpret().

Keypress listeners

You will need to set up your own keypress listeners and call keybinder.handleKey() inside them.

The argument to handleKey() should be a key that can be looked up in the keybinding map, i.e. if the map keys are strings and a browser's KeyboardEvent is fired, you need to transform the event into a string.

Alternatively use the getKeybinding option in keybinder options.

There are a couple helpers to format keypress events in the browser and Node to serve as starting points. TODO complete these.

There is also a Node listener to help with the boilerplate of keypress in Node.

Playground

Playground

Readme

Keywords

none

Package Sidebar

Install

npm i composable-keybindings

Weekly Downloads

1

Version

1.1.0

License

ISC

Unpacked Size

34 kB

Total Files

19

Last publish

Collaborators

  • slammayjammay