23 KiB
How to write a Kaleidoscope plugin
This is a brief guide intended for those who want to write custom Kaleidoscope plugins. It covers basic things you'll need to know about how Kaleidoscope calls plugin event handlers, and how it will respond to actions taken by those plugins.
What can a plugin do?
There are many things that Kaleidoscope plugins are capable of, from LED effects, serial communication with the host, altering HID reports, and interacting with other plugins. It's useful to break these capabilities down into some broad categories, based on the types of input a plugin can respond to.
- Key events (key switches toggling on and off)
- Focus commands (sent to the keyboard from software on the host via the serial port)
- LED updates
- Keymap layer changes
- Timers
An example plugin
To make a Kaleidoscope plugin, we create a subclass of the kaleidoscope::Plugin
class, usually in the kaleidoscope::plugin
namespace:
namespace kaleidoscope {
namespace plugin {
class MyPlugin : public Plugin {};
} // namespace kaleidoscope
} // namespace plugin
This code can be placed in a separate C++ source file, but it's simplest to just define it right in the sketch's *.ino file for now.
By convention, we create a singleton object named like the plugin's class in the global namespace. This is typical of Arduino code.
kaleidoscope::plugin::MyPlugin MyPlugin;
Next, in order to connect that plugin to the Kaleidoscope event handler system, we need to register it in the call to the preprocessor macro KALEIDOSCOPE_INIT_PLUGINS()
in the sketch:
KALEIDOSCOPE_INIT_PLUGINS(MyPlugin, OtherPlugin);
To make our plugin do anything useful, we need to add event-handler-hooks to it. This is how Kaleidoscope delivers input events to its registered plugins. Here's an example:
class MyPlugin : public Plugin {
public:
EventHandlerResult onKeyEvent(KeyEvent &event);
};
This will result in MyPlugin.onKeyEvent()
being called (along with other plugins' onKeyEvent()
methods) when Kaleidoscope detects a key state change. This function returns one of three EventHandlerResult
values:
EventHandlerResult::OK
indicates that Kaleidoscope should proceed on to the event handler for the next plugin in the chain.EventHandlerResult::ABORT
indicates that Kaleidoscope should stop processing immediately, and treat the event as if it didn't happen.EventHandlerResult::EVENT_CONSUMED
stops event processing likeABORT
, but records that the key is being held.
The onKeyEvent()
method takes one argument: a reference to a KeyEvent
object, which is a simple container for these essential bits of information:
event.addr
— the physical location of the keyswitch, if anyevent.state
— a bitfield containing information on the current and previous state of the keyswitch (from which we can find out if it just toggled on or toggled off)event.key
— a 16-bitKey
value containing the contents looked up from the sketch's current keymap (if the key just toggled on) or the current live value of the key (if the key just toggled off)
Because the KeyEvent
parameter is passed by (mutable) reference, our plugin's onKeyEvent()
method can alter the components of the event, causing subsequent plugins (and, eventually, Kaleidoscope itself) to treat it as if it was a different event. In practice, except in very rare cases, the only member of a KeyEvent
that a plugin should alter is event.key
. Here's a very simple onKeyEvent()
handler that changes all X
keys into Y
keys:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_X)
event.key = Key_Y;
return EventHandlerResult::OK;
}
The difference between ABORT
& EVENT_CONSUMED
Here's a plugin that will suppress all X
key events:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_X)
return EventHandlerResult::ABORT;
return EventHandlerResult::OK;
}
Here's an almost identical plugin that has an odd failure mode:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_X)
return EventHandlerResult::EVENT_CONSUMED;
return EventHandlerResult::OK;
}
In this case, when an X
key is pressed, no Keyboard HID report will be generated and sent to the host, but the key will still be recorded by Kaleidoscope as "live". If we hold that key down and press a Y
key, we will suddenly see both x
and y
in the output on the host. This is because returning ABORT
suppresses the key event entirely, as if it never happened, whereas EVENT_CONSUMED
signals to Kaleidoscope that the key should still become "live", but that no further processing is necessary. In this case, since we want to suppress all X
keys entirely, we should return ABORT
.
A complete in-sketch plugin
Here's an example of a very simple plugin, defined as it would be in a firmware sketch (e.g. a *.ino
file):
namespace kaleidoscope {
namespace plugin {
class KillX : public Plugin {
public:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_X)
return EventHandlerResult::ABORT;
return EventHandlerResult::OK;
}
};
} // namespace kaleidoscope
} // namespace plugin
kaleidoscope::plugin::KillX;
On its own, this plugin won't have any effect unless we register it later in the sketch like this:
KALEIDOSCOPE_INIT_PLUGINS(KillX);
Note: KALEIDOSCOPE_INIT_PLUGINS()
should only appear once in a sketch, with a list of all the plugins to be registered.
Plugin registration order
Obviously, the KillX
plugin isn't very useful. But more important, it's got a potential problem. Suppose we had another plugin defined, like so:
namespace kaleidoscope {
namespace plugin {
class YtoX : public Plugin {
public:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_Y)
event.key = Key_X;
return EventHandlerResult::OK;
}
};
} // namespace kaleidoscope
} // namespace plugin
kaleidoscope::plugin::YtoX;
YtoX
changes any Y
key to an X
key. These two plugins both work fine on their own, but when we put them together, we get some undesirable behavior. Let's try it this way first:
KALEIDOSCOPE_INIT_PLUGINS(YtoX, KillX);
This registers both plugins' event handlers with Kaleidoscope, in order, so for each KeyEvent
generated in response to a keyswitch toggling on or off, YtoX.onKeyEvent(event)
will get called first, then KillX.onKeyEvent(event)
will get called.
If we press X
, the YtoX
plugin will effectively ignore the event, allowing it to pass through to KillX
, which will abort the event.
If we press Y
, YtoX.onKeyEvent()
will change event.key
from Key_Y
to Key_X
. Then, KillX.onKeyEvent()
will abort the event. As a result, both X
and Y
keys will be suppressed by the combination of the two plugins.
Now, let's try the same two plugins in the other order:
KALEIDOSCOPE_INIT_PLUGINS(KillX, YtoX);
If we press X
, its keypress event will get aborted by KillX.onKeyEvent()
, and that key will not become live, so when it gets released, the event generated won't have the value Key_X
, but will instead by Key_Inactive
, which will not result in anything happening, either from the plugins or from Kaleidoscope itself.
Things get interesting if we press and release Y
, though. First, KillX.onKeyEvent()
will simply return OK
, allowing YtoX.onKeyEvent()
to change event.key
from Key_Y
to Key_X
, causing that Key_X
to become live, and sending its keycode to the host in the Keyboard USB HID report. That's all as expected, but then we release the key, and that's were it goes wrong.
KillX.onKeyEvent()
doesn't distinguish between presses and releases. When a key toggles off, rather than looking up that key's value in the keymap, Kaleidoscope takes it from the live keys array. That means that event.key
will be Key_X
when KillX.onKeyEvent()
is called, which will result in that event being aborted. And when an event is aborted, the key's entry in the live keys array doesn't get updated, so Kaleidoscope will treat it as if the key is still held after release. Thus, far from preventing the keycode for X
getting to the host, it keeps that key pressed forever! The X
key becomes "stuck on" because the plugin suppresses both key presses and key releases.
Differentiating between press and release events
There is a solution to this problem, which is to have KillX
suppress Key_X
toggle-on events, but not toggle-off events:
EventHandlerResult KillX::onKeyEvent(KeyEvent &event) {
if (event.key == Key_X && keyToggledOn(event.state))
return EventHandlerResult::ABORT;
return EventHandlerResult::OK;
}
Kaleidoscope provides keyToggledOn()
and keyToggledOff()
functions that operate on the event.state
bitfield, allowing plugins to differentiate between the two different event states. With this new version of the KillX
plugin, it won't keep an X
key live, but it will stop one from becoming live.
Our two plugins still yield results that depend on registration order in KALEIDOSCOPE_INIT_PLUGINS()
, but the bug where the X
key becomes "stuck on" is gone.
It is very common for plugins to only act on key toggle-on events, or to respond differently to toggle-on and toggle-off events.
Timers
Another thing that many plugins need to do is handle timeouts. For example, the OneShot plugin keeps certain keys live for a period of time after those keys are released. Kaleidoscope provides some infrastructure to help us keep track of time, starting with the afterEachCycle()
"event" handler function.
The onKeyEvent()
handlers only get called in response to keyswitches toggling on and off (or as a result of plugins calling Runtime.handleKeyEvent()
). If the user isn't actively typing for a period, its onKeyEvent()
handler won't get called at all, so it's not very useful to check timers in that function. Instead, if we need to know if a timer has expired, we need to do it in a function that gets called regularly, regardless of input. The afterEachCycle()
handler gets called once per cycle, guaranteed.
This is what an afterEachCycle()
handler looks like:
EventHandlerResult afterEachCycle() {
return EventHandlerResult::OK;
}
It returns an EventHandlerResult
, like other event handlers, but this one's return value is ignored by Kaleidoscope; returning ABORT
or EVENT_CONSUMED
has no effect on other plugins.
In addition to this, we need a way to keep track of time. For this, Kaleidoscope provides the function Runtime.millisAtCycleStart()
, which returns an unsigned integer representing the number of milliseconds that have elapsed since the keyboard started. It's a 32-bit integer, so it won't overflow until about one month has elapsed, but we usually want to use as few bytes of RAM as possible on our MCU, so most timers store only as many bytes as needed, usually a uint16_t
, which overflows after about one minute, or even a uint8_t
, which is good for up to a quarter of a second.
We need to use an integer type that's at least as big as the longest timeout we expect to be used, but integer overflow can still give us the wrong answer if we check it by naïvely comparing the current time to the time at expiration, so Kaleidoscope provides a timeout-checking service that's handles the integer overflow properly: Runtime.hasTimeExpired(start_time, timeout)
. To use it, your plugin should store a timestamp when the timer begins, using Runtime.millisAtCycleStart()
(usually set in response to an event in onKeyEvent()
). Then, in its afterEachCycle()
call hasTimeExpired()
:
namespace kaleidoscope {
namespace plugin {
class MyPlugin : public Plugin {
public:
constexpr uint16_t timeout = 500;
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_X && keyToggledOn(event.state)) {
start_time_ = Runtime.millisAtCycleStart();
timer_running_ = true;
}
return EventHandlerResult::OK;
}
EventHandlerResult afterEachCycle() {
if (Runtime.hasTimeExpired(start_time_, timeout)) {
timer_running_ = false;
// do something...
}
return EventHandlerResult::OK;
}
private:
bool timer_running_ = false;
uint16_t start_time_;
};
} // namespace kaleidoscope
} // namespace plugin
kaleidoscope::plugin::MyPlugin;
In the above example, the private member variable start_time_
and the constant timeout
are the same type of unsigned integer (uint16_t
), and we've used the additional boolean timer_running_
to keep from checking for timeouts when start_time_
isn't valid. This plugin does something (unspecified) 500 milliseconds after a Key_X
toggles on.
Creating additional events
Another thing we might want a plugin to do is generate "extra" events that don't correspond to physical state changes. An example of this is the Macros plugin, which might turn a single keypress into a series of HID reports sent to the host. Let's build a simple plugin to illustrate how this is done, by making a key type a string of characters, rather than a single one.
For the sake of simplicity, let's make the key H
result in the string Hi!
being typed (from the point of view of the host computer). To do this, we'll make a plugin with an onKeyEvent()
handler (because we want it to respond to a particular keypress event), which will call Runtime.handleKeyEvent()
to generate new events sent to the host.
The first thing we need to understand to do this is how to use the KeyEvent()
constructor to create a new KeyEvent
object. For example:
KeyEvent event = KeyEvent(KeyAddr::none(), IS_PRESSED, Key_H);
This creates a KeyEvent
where event.addr
is an invalid address that doesn't correspond to a physical keyswitch, event.state
has only the IS_PRESSED
bit set, but not WAS_PRESSED
, which corresponds to a key toggle-on event, and event.key
is set to Key_H
.
We can then cause Kaleidoscope to process this event, including calling plugin handlers, by calling Runtime.handleKeyEvent(event)
:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_H && keyToggledOn(event.state)) {
// Create and send the `H` (shift + h)
KeyEvent new_event = KeyEvent(KeyAddr::none(), IS_PRESSED, LSHIFT(Key_H));
Runtime.handleKeyEvent(new_event);
// Change the key value and send `i`
new_event.key = Key_I;
Runtime.handleKeyEvent(new_event);
// Change the key value and send `!` (shift + 1)
new_event.key = LSHIFT(Key_1);
Runtime.handleKeyEvent(new_event);
return EventHandlerResult::ABORT;
}
return EventHandlerResult::OK;
}
A few shortcuts were taken with this plugin that are worth pointing out. First, you may have noticed that we didn't send any key release events, just three presses. This works, but there's a small chance that it could cause problems for some plugin that's trying to match key presses and releases. To be nice (or pedantic, if you will), we could also send the matching release events, but this is probably not necessary in this case, because we've used an invalid key address (KeyAddr::none()
) for these generated events. This means that Kaleidoscope will not be recording these events as held keys. If we had used valid key addresses (corresponding to physical keyswitches) instead, it would be more important to send matching release events to keep keys from getting "stuck" on. For example, we could just use the address of the H
key that was pressed:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_H && keyToggledOn(event.state)) {
KeyEvent new_event = KeyEvent(event.addr, IS_PRESSED, LSHIFT(Key_H));
Runtime.handleKeyEvent(new_event);
new_event.key = Key_I;
Runtime.handleKeyEvent(new_event);
new_event.key = LSHIFT(Key_1);
Runtime.handleKeyEvent(new_event);
return EventHandlerResult::ABORT;
}
return EventHandlerResult::OK;
}
This new version has the curious property that if the H
key is held long enough, it will result in repeating !!!!
characters on the host, until the key is released, which will clear it. In fact, instead of creating a whole new KeyEvent
object, we could further simplify this plugin by simply modifying the event
object that we already have, instead:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_H && keyToggledOn(event.state)) {
event.key = LSHIFT(Key_H);
Runtime.handleKeyEvent(event);
event.key = Key_I;
Runtime.handleKeyEvent(event);
event.key = LSHIFT(Key_1);
}
return EventHandlerResult::OK;
}
Note that, with this version, we've only sent two extra events, then changed the event.key
value, and returned OK
instead of ABORT
. This is basically the same as the above pluging that turned Y
into X
, but with two extra events sent first.
As one extra precaution, it would be wise to mark the generated event(s) as "injected" to let other plugins know that these events should be ignored. This is a convention that is used by many existing Kaleidoscope plugins. We do this by setting the INJECTED
bit in the event.state
variable:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_H && keyToggledOn(event.state)) {
event.state |= INJECTED;
event.key = LSHIFT(Key_H);
Runtime.handleKeyEvent(event);
event.key = Key_I;
Runtime.handleKeyEvent(event);
event.key = LSHIFT(Key_1);
}
return EventHandlerResult::OK;
}
If we wanted to be especially careful, we could also add the corresponding release events:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if (event.key == Key_H && keyToggledOn(event.state)) {
event.key = LSHIFT(Key_H);
event.state = INJECTED | IS_PRESSED;
Runtime.handleKeyEvent(event);
event.state = INJECTED | WAS_PRESSED;
Runtime.handleKeyEvent(event);
event.key = Key_I;
event.state = INJECTED | IS_PRESSED;
Runtime.handleKeyEvent(event);
event.state = INJECTED | WAS_PRESSED;
Runtime.handleKeyEvent(event);
event.key = LSHIFT(Key_1);
event.state = INJECTED | IS_PRESSED;
}
return EventHandlerResult::OK;
}
Avoiding infinite loops
One very important consideration for any plugin that calls Runtime.handleKeyEvent()
from an onKeyEvent()
handler is recursion. Runtime.handleKeyEvent()
will call all plugins' onKeyEvent()
handlers, including the one that generated the event. Therefore, we need to take some measures to short-circuit the resulting recursive call so that our plugin doesn't cause an infinite loop.
Suppose the example plugin above was changed to type the string hi!
instead of Hi!
. When sending the first generated event, with event.key
set to Key_H
, our plugin would recognize that event as one that should be acted on, and make another call to Runtime.handleKeyEvent()
, which would again call MyPlugin.onKeyEvent()
, and so on until the MCU ran out of memory on the stack.
The simplest mechanism used by many plugins that mark their generated events "injected" is to simply ignore all generated events:
EventHandlerResult onKeyEvent(KeyEvent &event) {
if ((event.state & INJECTED) != 0)
return EventHandlerResult::OK;
if (event.key == Key_H && keyToggledOn(event.state)) {
event.state |= INJECTED;
event.key = LSHIFT(Key_H);
Runtime.handleKeyEvent(event);
event.key = Key_I;
Runtime.handleKeyEvent(event);
event.key = LSHIFT(Key_1);
}
return EventHandlerResult::OK;
}
There are other techniques to avoid inifinite loops, employed by plugins whose injected events should be processed by other plugins, but since most of those will be using the onKeyswitchEvent()
handler instead of onKeyEvent()
, we'll cover that later in this guide.
Physical keyswitch events
Most plugins that respond to key events can do their work using the onKeyEvent()
handler, but in some cases, it's necessary to use the onKeyswitchEvent()
handler instead. These event handlers are strictly intended for physical keyswitch events, and plugins that implement the onKeyswitchEvent()
handler must abide by certain rules in order to work well with each other. As a result, such a plugin is a bit more complex, but there are helper mechanisms to make things easier:
#include "kaleidoscope/KeyEventTracker.h"
namespace kaleidoscope {
namespace plugin {
class MyKeyswitchPlugin : public Plugin {
public:
EventHandlerResult onKeyswitchEvent(KeyEvent &event) {
if (event_tracker_.shouldIgnore(event))
return EventHandlerResult::OK;
// Plugin logic goes here...
return EventHandlerResult::OK;
}
private:
KeyEventTracker event_tracker_;
};
} // namespace kaleidoscope
} // namespace plugin
kaleidoscope::plugin::MyKeyswitchPlugin;
We've just added a KeyEventTracker
object to our plugin, and made the first line of its onKeyswitchEvent()
handler call that event tracker's shouldIgnore()
method, returning OK
if it returns true
(thereby ignoring the event). Every plugin that implements onKeyswitchEvent()
should follow this template to avoid plugin interaction bugs, including possible infinite loops.
The main reason for this event tracker mechanism is that plugins with onKeyswitchEvent()
handlers often delay events because some aspect of those events (usually event.key
) needs to be determined by subsequent events or timeouts. To do this, event information is stored, and the event is later regenerated by the plugin, which calls Runtime.handleKeyswitchEvent()
so that the other onKeyswitchEvent()
handlers can process it.
We need to prevent infinite loops, but simply marking the regenerated event INJECTED
is no good, because it would prevent the other plugins from acting on it, so we instead keep track of a monotonically increasing event id number and use the KeyEventTracker
helper class to ignore events that our plugin has already recieved, so that when the plugin regenerates an event with the same event id, it (and all the plugins before it) can ignore that event, but the subsequent plugins, which haven't seen that event yet, will recongize it as new and process the event accordingly.
Regenerating stored events
When a plugin that implements onKeyswitchEvent()
regenerates a stored event later so that it can be processed by the next plugin in the chain, it must use the correct event id value (the same one used by the original event). This is an object of type EventId
, and is retrieved by calling event.id()
(unlike the other properties of a KeyEvent
object the event id is not directly accessible).
KeyEventId stored_id = event.id();
When reconstructing an event to allow it to proceed, we then use the four-argument version of the KeyEvent
constructor:
KeyEvent event = KeyEvent(addr, state, key, stored_id);
In the above, addr
and state
are usually also the same as the original event's values, and key
is most often the thing that changes. If your plugin wants a keymap lookup to take place, the value Key_Undefined
can be used instead of explicitly doing the lookup itself.