Keyszer is a smart key remapper for Linux (and X11) written in Python. It's similar to xmodmap
but allows far more flexible remappings. Keyszer was forked from xkeysnail which no longer seems actively maintained.
Keyszer works at quite a low-level. It grabs input directly from the kernel's evdev
input devices ( /dev/input/event*
) and then creates an emulated uinput
device to inject those inputs back into the kernel. During this process the input stream is transformed on the fly as necessary to remap keys.
Upgrading from xkeysnail
evdev
and uinput
) allows remapping to work from the console all the way into X11.Ctrl+x Ctrl+c
could map to Ctrl+q
Ctrl-s
could type out :save
, and then hit enterNew Features (since xkeysnail 0.4.0)
EMERGENCY EJECT
hotkeyDIAGNOSTIC
hotkeyroot
is now deprecated)include
to allow config to pull in other Python filesthrottle_delays
to allow control of output speed of macros/combosimmediately
to nested keymapsMeta
, Command
and Cmd
aliases for Super/Meta modifierC
combo helper (eventually to replace K
)add_modifier
(such as Hyper
)Fn
as a potential modifier (on hardware where it works)bind
helper to support persistent holds across multiple combos
Cmd-tab
app switcher panels--check
for checking the config file for issueswm_name
context for all conditionals (PR #40)device_name
context for all conditionals (including keymaps)xmodmap
cannot be used until some keys are first pressed on the emulated outputmultipurpose
and suspend
)launch
macroRequires Python 3.
Over time we should add individual instructions for various distros here.
Just download the source and install.
git clone https://github.com/joshgoebel/keyszer.git
cd keyszer
pip3 install --user --upgrade .
Using a Python venv
might be the simplest way to get started:
git clone https://github.com/joshgoebel/keyszer.git
cd keyszer
python -m venv .venv
source .venv/bin/activate
pip3 install -e .
./bin/keyszer -c config_file
Keyszer requires read/write access to:
/dev/input/event*
- to grab input from your evdev
input devices/dev/uinput
- to provide an emulated keyboard to the kernelIt's best to create an entirely isolated user to run the keymapper. Group or ACL based permissions can be used to provide this user access to the necessary devices. You'll need only a few udev
rules to ensure that the input devices are all given correct permissions.
First, lets make a new user:
sudo useradd keymapper
...then use udev and ACL to grant our new user access:
Manually edit /etc/udev/rules.d/90-keymapper-acl.rules
to include the following:
KERNEL=="event*", SUBSYSTEM=="input", RUN+="/usr/bin/setfacl -m user:keymapper:rw /dev/input/%k"
KERNEL=="uinput", SUBSYSTEM=="misc", RUN+="/usr/bin/setfacl -m user:keymapper:rw /dev/uinput"
...or do it by copypasting these lines into a shell:
cat <<EOF | sudo tee /etc/udev/rules.d/90-keymapper-acl.rules
KERNEL=="event*", SUBSYSTEM=="input", RUN+="/usr/bin/setfacl -m user:keymapper:rw /dev/input/%k"
KERNEL=="uinput", SUBSYSTEM=="misc", RUN+="/usr/bin/setfacl -m user:keymapper:rw /dev/uinput"
EOF
Many distros already have an input group; if not, you can create one. Next, add a new user that's a member of that group:
sudo useradd keymapper -G input
...then use udev to grant our new user access (via the input
group):
Manually edit /etc/udev/rules.d/90-keymapper-input.rules
to include the following:
SUBSYSTEM=="input", GROUP="input"
KERNEL=="uinput", SUBSYSTEM=="misc", GROUP="input"
...or do it by copypasting these lines into a shell:
cat <<EOF | sudo tee /etc/udev/rules.d/90-keymapper-input.rules
SUBSYSTEM=="input", GROUP="input"
KERNEL=="uinput", SUBSYSTEM=="misc", GROUP="input"
EOF
For a sample systemd service file for running Keyszer as a service please see keyszer.service.
This may be appropriate in some limited development scenarios, but is not recommended. Giving the active, logged in user access to evdev
and uinput
potentially allows all keystrokes to be logged and could allow a malicious program to take over (or destroy) your machine by injecting input into a Terminal session or other application.
It would be better to open a terminal, su
to a dedicated keymapper
user and then run Keyszer inside that context, as shown earlier.
root
Don't do this, it's dangerous, and unnecessary. A semi-privileged user with access to only the necessary input devices is a far better choice.
keyszer
A successful startup should resemble:
keyszer v0.5.0
(--) CONFIG: /home/jgoebel/.config/keyszer/config.py
(+K) Grabbing Apple, Inc Apple Keyboard (/dev/input/event3)
(--) Ready to process input.
Limiting Devices
Limit remapping to specific devices with --devices
:
keyszer --devices /dev/input/event3 'Topre Corporation HHKB Professional'
The full path or complete device name may be used. Device name is usually better to avoid USB device numbering jumping around after a reboot, etc...
Other Options:
-c
, --config
- location of the configuration file-w
, --watch
- watch for new keyboard devices to hot-plug-v
- increase verbosity greatly (to help with debugging)--list-devices
- list out all available input devicesBy default we look for the configuration in ~/.config/keyszer/config.py
. You can override this location using the -c
/--config
switch. The configuration file is written in Python.
For an example configuration please see example/config.py
.
The configuration API:
timeouts(multipurpose, suspend)
throttle_delays(key_pre_delay_ms, key_post_delay_ms)
wm_class_match(re_str)
not_wm_class_match(re_str)
add_modifier(name, aliases, key/keys)
modmap(name, map, when_conditional)
multipurpose_modmap(name, map, when_conditional)
keymap(name, map, when_conditional)
conditional(condition_fn, map)
- used to wrap maps, applying them conditionallydump_diagnostics_key(key)
emergency_eject_key(key)
include(relative_filename)
include(relative_filename)
Include a sub-configuration file into the existing config. This file is loaded and executed at the point of inclusion and shares the same global scope as the existing config. These files should be present in the same directory as your main configuration.
include("os.py")
include("apps.py")
include("deadkeys.py")
timeouts(...)
Configures the timing behavior of various aspects of the keymapper.
multipurpose
- The number of seconds before a held multi-purpose key is assumed to be a modifier (even in the absence of other keys).suspend
- The number of seconds modifiers are "suspended" and withheld from the output waiting to see whether if they are part of a combo or if they may be the actual intended output.Defaults:
timeouts(
multipurpose = 1,
suspend = 1,
)
throttle_delays(...)
Configures the speed of virtual keyboard keystroke output to deal with issues that occur in various situations with the timing of modifier key presses and releases being misinterpreted.
key_pre_delay_ms
- The number of milliseconds to delay the press-release keystroke of the "normal" key after pressing modifier keys. key_post_delay_ms
- The number of milliseconds to delay the next key event (modifier release) after the "normal" key press-release event.Defaults:
throttle_delays(
key_pre_delay_ms = 0, # default: 0 ms, range: 0 to 150 ms, suggested: 1-50 ms
key_post_delay_ms = 0, # default: 0 ms, range: 0 to 150 ms, suggested: 1-100 ms
)
Use the throttle delays if you are having the following kinds of problems:
Suggested values to try if you are in a virtual machine and having major problems with even common shortcut combos:
The post delay seems a little more effective in testing, but your situation may be different. For a bare-metal install where you are just having a few glitches in macro output, try much smaller delays:
These are just examples that have worked fairly well in current testing on machines that have had these issues.
dump_diagnostics_key(key)
Configures a key that when hit will dump additional diagnostic information to STDOUT.
dump_diagnostics_key(Key.F15) # default
emergency_eject_key(key)
Configures a key that when hit will immediately terminate keyszer; useful for development, recovering from bugs, or badly broken configurations.
emergency_eject_key(Key.F16) # default
add_modifier(name, aliases, key/keys)
Allows you to add custom modifiers and then map them to actual keys.
add_modifier("HYPER", aliases = ["Hyper"], key = Key.F24)
Note: Just adding HYPER
doesn't necessarily make it work with your software, you may still need to configure X11 setup to accept the key you choose as the "Hyper" key.
wm_class_match(re_str)
Helper to make matching conditionals (and caching the compiled regex) just a tiny bit simpler.
keymap("Firefox",{
# ... keymap here
}, when = wm_class_match("^Firefox$"))
not_wm_class_match(re_str)
The negation of wm_class_match
, matches only when the regex does NOT match.
modmap(name, mappings, when_conditional = None)
Maps a single physical key to a different key. A default modmap will always be overruled by any conditional modmaps that apply. when_conditional
can be passed to make the modmap conditional. The first modmap found that includes the pressed key and matches the when_conditional
will be used to remap the key.
modmap("default", {
# mapping caps lock to left control
Key.CAPSLOCK: Key.LEFT_CTRL
})
If you don't create a default (non-conditional) modmap a blank one is created for you. For modmap
both sides of the pairing will be Key
literals (not combos).
multipurpose_modmap(name, mappings)
Used to bestow a key with multiple-purposes, both for regular use and for use as a modifier.
multipurpose_modmap("default",
# Enter is enter if pressed and immediately released...
# ...but Right Control if held down and paired with other keys.
{Key.ENTER: [Key.ENTER, Key.RIGHT_CTRL]}
)
keymap(name, mappings)
Defines a keymap of input combos mapped to output equivalents.
keymap("Firefox", {
# when Cmd-S is input instead send Ctrl-S to the output
C("Cmd-s"): C("Ctrl-s"),
}, when = lambda ctx: ctx.wm_class == "Firefox")
Because of the when
conditional this keymap will only apply for Firefox.
The argument mappings
is a dictionary in the form of { combo: command, ...}
where combo
and command
take following forms:
combo
: Combo to map, specified by K(combo_str)
command
: one of the following
K(combo_str)
: Type a specific key combo to the output.[command1, command2, ...]
: Execute multiple commands sequentially.{ ... }
: Sub-keymap. Used to define Multiple Stroke Keys.escape_next_key
: Escape the next key pressed.ignore_key
: Ignore the key that is pressed next. (often used to disable native combos)bind
: Bind an input and output modifier together such that the output is not lifted until the input is.The argument name
specifies the keymap name. Every keymap has a name - using default
is suggested for a non-conditional keymap.
conditional(fn, map)
Applies a map conditionally, only when the fn
function evaluates True
. The below example is a modmap that is only active when the current WM_CLASS
is Terminal
.
conditional(
lambda ctx: ctx.wm_class == "Terminal",
modmap({
# ...
})
)
The context
object passed to the fn
function has several attributes:
wm_class
- the WM_CLASS of the [input] focused X11 windowwm_name
- the WM_NAME of the [input] focused X11 windowdevice_name
- name of the device where an input originatedcapslock_on
- state of CapsLock (boolean)numlock_on
- state of NumLock (boolean)Note: The same conditional fn
can always be passed directly to modmap
using the when
argument.
TODO: need docs (See issue #8)
The Combo specification in a keymap is written in the form of C("(<Modifier>-)*<Key>")
.
<Modifier>
is one of the following:
C
or Ctrl
-> Control keyAlt
-> Alt keyShift
-> Shift keySuper
, Win
, Command
, Cmd
, Meta
-> Super/Windows/Command keyFn
-> Function key (on supported keyboards)You can specify left/right modifiers by adding the prefixes L
or R
.
<Key>
is any key whose name is defined in key.py
.
Some combo examples:
C("LC-Alt-j")
: left Control, Alt, j
C("Ctrl-m")
: Left or Right Control, m
C("Win-o")
: Cmd/Windows, o
C("Alt-Shift-comma")
: Alt, Left or Right Shift, commaTo use multiple stroke keys, simply define a nested keymap. For example, the
following example remaps C-x C-c
to C-q
.
keymap("multi stroke", {
C("C-x"): {
C("C-c"): C("C-q"),
}
})
If you'd like the first keystroke to also produce it's own output, immediately
can be used:
keymap("multi stroke", {
C("C-x"): {
# immediately output "x" when Ctrl-X is pressed
immediately: C("x"),
C("C-c"): C("C-q"),
}
})
Key.NAME
literal for a key on your keyboardFrom a terminal session run evtest
and select your keyboard's input device. Now hit the key in question.
Event: time 1655723568.594844, type 1 (EV_KEY), code 69 (KEY_NUMLOCK), value 1
Event: time 1655723568.594844, -------------- SYN_REPORT ------------
Above I've just pressed "clear" on my numpad and see code 69 (KEY_NUMLOCK)
in the output. For Keyszer this would translate to Key.NUMLOCK
. You can also browse the full list of key names in the source.
WM_CLASS
and WM_NAME
using xprop
Use the xprop
command from a terminal:
xprop WM_CLASS WM_NAME
...then click an application window. Let's try it with Google Chrome:
WM_CLASS(STRING) = "google-chrome", "Google-chrome"
WM_NAME(UTF8_STRING) = "README - Google Chrome"
Use the second WM_CLASS
value (in this case Google-chrome
) when matching context.wm_class
.
terminals = ["gnome-terminal","konsole","io.elementary.terminal","sakura"]
terminals = [term.casefold() for term in terminals]
USING_TERMINAL_RE = re.compile("|".join(terminals), re.IGNORECASE)
modmap("not in terminal", {
Key.LEFT_ALT: Key.RIGHT_CTRL,
# ...
}, when = lambda ctx: ctx.wm_class.casefold() not in terminals
)
modmap("terminals", {
Key.RIGHT_ALT: Key.RIGHT_CTRL,
# ...
}, when = lambda ctx: USING_TERMINAL_RE.search(ctx.wm_class)
)
Can I remap the keyboard's Fn
key?
It depends. Most laptops do not allow this as the Fn
keypress events are not directly exposed to the operating system. On some keyboards, it's just another key. To find out you can run evtest
. Point it to your keyboard device and then hit a few keys; then try Fn
. If you get output, then you can map Fn
. If not, you can't.
Here is an example from a full size Apple keyboard I have:
Event: time 1654948033.572989, type 1 (EV_KEY), code 464 (KEY_FN), value 1
Event: time 1654948033.572989, -------------- SYN_REPORT ------------
Event: time 1654948033.636611, type 1 (EV_KEY), code 464 (KEY_FN), value 0
Event: time 1654948033.636611, -------------- SYN_REPORT ------------
What if my keyboard seems laggy or is not repeating keys fast enough?
You likely need to set the [virtual] keyboards repeat rate to match your actual keyboard.
Here is the command I use:
xset r rate 200 20
For best results your real keyboard and Keyszer [virtual] keyboard should have matching repeat rates. That seems to work best for me. Anytime you restart keyszer you'll need to reconfigure the repeat rate because each time a new virtual keyboard device is created... or maybe it's that there is only a single repeat rate and every time you "plug in" a new keyboard it changes?
If you could shed some light on this, please get in touch.
Does Keyszer support FreeBSD/NetBSD or other BSDs?
Not at the moment, perhaps never. If you're an expert on the BSD kernel's input layers please join the discussion. I'm at the very least open to the discussion to find out if this is possible, a good idea, etc...
Does this work with Wayland?
Not yet. This is desires but seems impossible at the moment until there is a standardized system to quickly and easily determine the app/window that has input focus on Wayland, just like we do so easily on X11.
Is keyszer compatible with Kinto.sh?
That is certainly the plan. The major reason Kinto.sh required it's own fork has been resolved. Kinto.sh should simply "just work" with keyszer
(with a few tiny config changes). In fact, hopefully it works better than before since many quirks with the Kinto fork should be resolved. (such as nested combos not working, etc)
Reference:
kinto.py
config slightly. See USING_WITH_KINTO.md.How can I help or contribute?
Please open an issue to discuss how you'd like to get involved or respond on one of the existing issues. Also feel free to open new issues for feature requests. Many issues are tagged good first issue or help welcome.
keyszer
is distributed under GPL3. See LICENSE.