Update: Evdoublebind has had a large update focused on ease of use. You can find details on the update in a blog post of its own. Further, the Sway configuration details have now been figured out. Check out the usage details in the readme. The original version discussed in the post below has its own branch in the git repository.
To increase the ergonomics of my daily work I make extensive use of double-bind keys. Overloaded keys are described in more detail below. To achieve this I created Evdoublebind and configs currently for X11. The goal of this post is to introduce Evdoublebind and document the setup before it is migrated to Wayland.
Evdoublebind, via evdev, provides double-bind keys: keys which are overloaded, acting as a modifier when held but as another key when tapped alone. Although other applications strive for the same core functionality, Evdoublebind is unique in that it...
Effectively, I want easy-to-reach keys to provide extra functionality: they act as a modifier while held, but as a different normal key when tapped (pressed and released without other events occurring). For example, my Caps Lock key is remapped to the Hyper modifier and overloaded with the Escape key on tap. Similar binds on Caps Lock are common, but I make extensive use of double binds; the table below lists them.
| Base Key | Modifier Bind | Tap Bind |
|---|---|---|
| Space | Control | Space |
| Caps Lock | Hyper | Escape |
| Left Shift | Shift | Backslash |
| Left Alt | Alt | ASCII Circumflex |
| Right Shift | Shift | Ampersand |
| Semicolon | Super | Semicolon |
When I was developing this mapping I was writing a paper in LaTeX, which influenced the binding of the Shift and Alt keys. Although I utilize the Space bar (remapped to Control) in all my applications, it has been most beneficial in my text editor, Emacs. The strangest feature of my setup, semicolon remapped to Super, allows me to have full control of my tiling window manager with my hands on the home row and even navigate windows and cycle workspaces with one hand.
I should note that this setup is designed and used with a standard QWERTY US layout.
When I first started to experiment with double binds I utilized xcape and xmodmap, only producing a subset of the desired effect described above. This setup served me for a while but xcape had a number of problems, such as:
Control+Super+_ commands.While searching for a Wayland-compatible solution I found a discussion in a feature request issue for Sway, a Wayland compositor. The issue discussed implementing the double-bind functionality either natively inside the compositor itself, which had opposition for upstream use. To implement this feature at a lower level in the input stack, Interception Tools was mentioned, which provides a Unix-style piping interface for intercepting and sending input events on the evdev input interface.
Using Interception Tools, a virtual keyboard could be created that intercepted all keyboard input, modified and injected keys, and transmitted them as its own events to achieve the overloading feature. Since the mapping is done at a lower level, it should work with Wayland. However, I found Interception Tools unnecessarily bloated for my needs, as it required the C++ standard library for some binaries and required multiple programs running in the background while offering little extra since evdev is already exposed as a Unix style interface.
Initially Evdoublebind utilized the virtual keyboard strategy, fully capturing events and reproducing its own. However, in pursuit of simplification and minimization of possible latency, I pivoted to a different strategy which also solved a bug3. Instead of fully capturing events I merely read them, and injected keys when necessary.
Before I discuss some hurdles of the new strategy, I need to mention that Evdoublebind is only one part of the setup — a consequence of the level it operates at and the desired feature set. Although Evdoublebind does the heavy work of injecting keys, a secondary high-level mapping that does simple remapping of keys is necessary. Currently this secondary mapping is handled by X11 via xmodmap; however, the mapping can be done with an xkb layout which can be used with Sway.
Theoretically, this strategy has a fundamental ordering bug. Since the character device forms a queue, a key read followed by an immediate key injection may not be delivered immediately after the read event. However, in practice, even under deliberate stress, I have not observed this issue. I found the simplicity and speed of the current implementation an acceptable trade-off. If this issue becomes a problem it may be possible to mitigate it by use of SYN_DROPPED events in some cases.
The actual hurdle preventing implementation of the new strategy was that it did not produce the same desired effect as described above without compromise. In the desired effect, pressing an overloaded modifier key down does nothing until one of two things happens:
The core problem is that events can't be reordered or conditionally captured, so in case 1 there would be no way to inject a modifier key-down before the newly pressed key is emitted.
To overcome this, remap the overloaded keys to the desired modifier in the secondary map so the modifier is already pressed for case 1. This simplifies Evdoublebind drastically. Essentially, the only job it has now is to detect when an overloaded key is tapped and inject the necessary key on such a tap. The trade-off is that when an overloaded key is tapped, two keys are sent: the modifier and the desired tap key. Fortunately, a tap of a modifier key usually has no effect.
As a result of allowing every key press to be sent and then performing remapping in a secondary map, Evdoublebind cannot directly emit keys in some cases without collisions. For example, consider the Space key, which I remapped to Control in the secondary map. If Evdoublebind directly emitted the Space key to produce the overload, the secondary map would convert the tap to a tap of Control. To avoid this collision I overload keys that have unused keycodes and map those in the secondary map to the desired keys instead.
For the sake of simplicity in implementation, Evdoublebind is source configured. Below is my configuration of Evdoublebind for my setup.
static const KeyBind KEYMAP[] = {
{.key = KEY_LEFTSHIFT, .tap_key = KEY_BACKSLASH},
{.key = KEY_RIGHTSHIFT,.tap_key = KEY_7, .tap_modifier = KEY_LEFTSHIFT},
{.key = KEY_SPACE, .tap_key = KEY_F13},
{.key = KEY_CAPSLOCK, .tap_key = KEY_ESC},
{.key = KEY_SEMICOLON, .tap_key = KEY_F14},
{.key = KEY_LEFTALT, .tap_key = KEY_6, .tap_modifier = KEY_LEFTSHIFT}
};
You'll notice the use of tap_modifier, which allows a tap key to briefly activate a modifier while inserting that key. This has a collision problem but is necessary because xmodmap can't directly remap a key to produce a shifted symbol without removing the original binding. However, I believe that performing the mapping in an xkb layout does not share this limitation.
Then for xmodmap I use the following configuration. I noticed some issues when using keysyms from some of the remappings; as an alternative I used keycodes (found by pressing the key in xev), which tends to be more reliable.
remove Lock = Caps_Lock
keysym Caps_Lock = Hyper_L
keysym semicolon = Super_R
keycode 65 = Hyper_R
keycode 192 = semicolon colon
keycode 191 = space
clear Mod1
clear Mod2
clear Mod3
clear Mod4
clear Mod5
add Mod1 = Alt_L Alt_R Meta_L
add Mod2 = Num_Lock
add Mod3 = Hyper_L
add Mod4 = Super_L Super_R
add Mod5 = ISO_Level3_Shift Mode_switch
add Control = Hyper_R
Notable things in this config are:
Since Evdoublebind accesses the input character devices, the program usually needs elevated privileges of some sort. You can deal with this in a number of ways. Here are some:
/dev/input/ devices are in the input group, so by either adding your user to that group or setting the group sticky bit on the evdoublebind executable you can allow it to run without full root. I do the latter withchown :input evdoublebind
chmod g+s evdoublebind
The script I use to start the program is:
#!/bin/sh
KBD1="/dev/input/by-id/usb-Logitech_Logitech_G710_Keyboard-event-kbd"
kill $(pidof evdoublebind)
[ -e $KBD1 ] && evdoublebind $KBD1 &
setxkbmap -layout us # Reset layout
xmodmap ~/.Xmodmap
xset r rate 250 40
I extend this script to include multiple keyboards on my laptop.
Currently Evdoublebind only reads from one input device, making the program unaware of mouse input. Thus, when a modifier key is used, mouse input occurring before the key is released can cause the tap overload to be inserted when the key is released, which can be annoying in graphical software. There are three solutions I can think of:
Aside from that, Evdoublebind does not use libevdev, partly out of a desire to avoid adding dependencies and bloat.
Overall, I think a more feature-rich (but bloated) version should be created: utilizing libevdev for better compliance with evdev; returning to the virtual keyboard method while still using a secondary map to avoid the collision bug3; polling multiple inputs; and tracking timing to make Evdoublebind able to make smarter decisions. The increased latency introduced by these changes may be negligible, making the increased complexity the only trade-off to produce something truly polished.
Below is the command used to generate the small binary and check the
size. I should note the binary could be smaller by not having a read-only page
via the linker command `-z norelro`. If you also remove the error messages the binary will be less than 4 KB, on most modern systems fitting in a single virtual page of memory.
> musl-gcc -static -O3 -flto -Wl,-z,noseparate-code src/evdoublebind.c;\
strip -s ./a.out; wc -c ./a.out
5184 ./a.out
A patch does exist to add the functionality but is limited and requires adding each combination individually.
When I was using the virtual keyboard mapping strategy in Evmodkey I did all the remapping inside the application as well; this eliminated the need for a secondary map. However, without a secondary map, when keys were remapped to existing keys collisions could occur, and pressing them together could cause issues because libinput could not tell them apart.