Sun, Jul 14, 2019
#emacs #emacs-lisp
Defying your keyboard with Elisp
The other day I had the idea to do what I suspect, is a weird thing. I wanted to bind keys to other key’s characters in Emacs. This led me on an interesting journey through the Emacs source code until I finally found a solution.
But Why
I’m a software developer, so I hit a lot of keys. One of the reasons I love Emacs (+evil) is that it allows me to minimize the amount of keys I need to press to complete common actions throughout the day. I have hand problems (not related to Emacs, I swear). This means I sometimes go to extreme lengths to remove key presses for common actions, especially those involving pinky usage.
It recently occurred to me that, during my day, I type *
, (
, )
and "
far more often than their “unshifted” counterparts, or at
least I do when typing code or documentation. Because Emacs is really
just a keybinding machine, I thought this would be easy, but I was so
wrong.
The first and obvious solution I have multiple rebinds that I would
like to make with this system, but for discussion purposes I’ll focus
on one key: rebinding 9 -> (
and conversely ( -> 9
. Basically,
making it the reverse of normal such that 9 inserts a parentheses and
Shift+9 inserts a 9.
A quick note: in these examples I’m using global-set-key
to force
the binding for now, but in reality I’ll do something more Evil insert
mode specific, and recommend you do too. With the disclaimer out of
the way the first thing I tried was:
(global-set-key (kbd "S-9") (kbd "9"))
(global-set-key (kbd "9") (kbd "("))
This basically makes it so 9 and Shift+9 both insert parentheses. This
is because of how Emacs keybindings work, Emacs maintains a global
associative map of keys to functions. In our case after we use the
above code it looks in this map for Shift+9 it sees that it’s bound
the result of pressing 9 so it then looks in this for 9 which is bound
to the result of pressing (
and so inserts a parentheses. This isn’t
what we want however because we now can basically never insert a 9
character. The next iteration had a new problem that, had I understood
how Emacs keybindings work, would have been immediately obvious.
(global-set-key (kbd "9") (kbd "("))
(global-set-key (kbd "(") (kbd "9"))
Obviously results in an infinite recursion loop, as it looks in the keybinding map for 9 which is set to the result of pressing ( which is then set to the result of pressing 9 so on and so forth. My next attempt required that I dust off those Elisp skills and do what it does best: macros.
(defmacro chasinglogic-insert-char (char)
"Returns a command that inserts char"
`(lambda ()
(interactive)
(insert ,char)))
(global-set-key (kbd "9") (chasinglogic-insert-char "("))
(global-set-key (kbd "(") (chasinglogic-insert-char "9"))
Which comes close to working, but unfortunately (
is no longer auto
closing with the pair )
. I use electric-pair-mode to get this
functionality in Emacs, so I run C-h f electric-pair-mode
and jump to
the definition of the minor mode. I found the following section of
code which shows how electric-pair-mode works:
(if electric-pair-mode
(progn
(add-hook 'post-self-insert-hook
#'electric-pair-post-self-insert-function)
(electric--sort-post-self-insertion-hook)
(add-hook 'self-insert-uses-region-functions
#'electric-pair-will-use-region))
(remove-hook 'post-self-insert-hook
#'electric-pair-post-self-insert-function)
(remove-hook 'self-insert-uses-region-functions
#'electric-pair-will-use-region)))
Here we can see that electric-pair-mode
is hooking into the
post-self-insert-hook
, which we clearly aren’t triggering with our
explicit call to insert. If we run C-h v post-self-insert-hook
, we
see the following docs:
post-self-insert-hook is a variable defined in 'C source code'.
Its value is
(electric-pair-post-self-insert-function electric-indent-post-self-insert-function blink-paren-post-self-insert-function)
This variable may be risky if used as a file-local variable.
Documentation:
Hook run at the end of 'self-insert-command'.
This is run after inserting the character.
The solution now seems obvious. All we need to do is run these hooks as part of our lambda generated by our macro! So, with a little modification, our Elisp now looks like this:
(defmacro chasinglogic-insert-char (char)
"Returns a command that inserts char"
`(lambda ()
(interactive)
(insert ,char)
(run-hooks 'post-self-insert-hook)))
(global-set-key (kbd "9") (chasinglogic-insert-char "("))
(global-set-key (kbd "(") (chasinglogic-insert-char "9"))
If you’re following along at home and try this code in your
*scratch*
buffer, you will find as I did, that it doesn’t
work. Digging further into the electric-pair-mode
internals, we find
the function that actually does the pairing:
electric-pair-post-self-insert-function
. It’s quite a hefty pile of
Elisp, so I’m not going to include it’s code here, but the important
thing to note is that it is reading a variable last-command-event
,
which is defined in keyboard.c
and is presumably being set by
self-insert-command
.
This leads us back to the post-self-insert-hook docs, and clicking through to self-insert-command’s documentation we see that it’s defined in the C sources for Emacs. Luckily, it’s just a few lines of (fairly) understandable C code:
DEFUN ("self-insert-command", Fself_insert_command, Sself_insert_command, 1, 1, "p",
doc: /* Insert the character you type.
Whichever character you type to run this command is inserted.
The numeric prefix argument N says how many times to repeat the insertion.
Before insertion, `expand-abbrev' is executed if the inserted character does
not have word syntax and the previous character in the buffer does.
After insertion, `internal-auto-fill' is called if
`auto-fill-function' is non-nil and if the `auto-fill-chars' table has
a non-nil value for the inserted character. At the end, it runs
`post-self-insert-hook'. */)
(Lisp_Object n)
{
CHECK_NUMBER (n);
if (XINT (n) < 0)
error ("Negative repetition argument %"pI"d", XINT (n));
if (XFASTINT (n) < 2)
call0 (Qundo_auto_amalgamate);
/* Barf if the key that invoked this was not a character. */
if (!CHARACTERP (last_command_event))
bitch_at_user ();
else {
int character = translate_char (Vtranslation_table_for_input,
XINT (last_command_event));
int val = internal_self_insert (character, XFASTINT (n));
if (val == 2)
Fset (Qundo_auto__this_command_amalgamating, Qnil);
frame_make_pointer_invisible (SELECTED_FRAME ());
}
return Qnil;
}
Reading through this it’s clear that this reads last_command_event
,
but does not set it. To find where that is done we actually have to
read a little bit into the main Emacs event loop. This is a big mess
of C code, but with a little bit of searching we find on L1393 in
keyboard.c
the following bit of code: last_command_event = keybuf[i - 1];
and keybuf
is defined inside of this function and so
isn’t global. This means we can’t touch it in elisp to query or modify
it (not that it was a good idea to do so anyway).
But what we can do is touch last_command_event
. It is exposed to us
as an Elisp global via the C macro DEFVAR. This is how
electric-pair-mode
reads it. We can get the key code for a character
using the aref
function like so: (aref "c" 0)
(thanks to the
awesome abo-abo for answering this StackOverflow post which is how I
discovered this use of
aref
). Knowing this, we can
further modify our macro to better emulate self-insert-command:
(defmacro chasinglogic-insert-char (char)
"Returns a command that inserts char and emulates it's keypress"
`(lambda ()
(interactive)
;; Actually insert the char at (point)
(insert ,char)
;; Set last-command-event to the keycode of char. This emulates
;; the behavior of self-insert-command which is normally responsible
;; for inserting the appropriate character for a given key and is
;; read by many of the hooks in post-self-insert-hook
(setq last-command-event (aref ,char 0))
;; Run the post-self-insert-hook's as if self-insert-command was run.
(run-hooks 'post-self-insert-hook)))
(global-set-key (kbd "9") (chasinglogic-insert-char "("))
(global-set-key (kbd "(") (chasinglogic-insert-char "9"))
After loading this version in my *scratch*
buffer it finally works!
There was much rejoicing as pressing the 9 key on my keyboard not only
inserts a (
but it triggers the correct hooks, and is automatically
paired as appropriate. Conversely, pressing shift and 9 (i.e. the
original combination required for (
) inserts a 9 as expected.
Notes and Caveats
So was this project an utter success? In my case: yes, but I do want to point out a few important notes for the endeavoring Emacs user who wishes to replicate this behavior:
The keys modified / swapped this way only behave like the other keys for purposes of inserting characters. For example, if you want to prefix an Emacs command via C-u 9 you do not need to press Shift+9 on your keyboard. The prefix argument keybinding reads the key code directly, not after inserting, and so just pressing 9 will still prefix with a value of 9.
This is great for me because I want 9 to still repeat things without shift when in Normal mode (as I use Evil) and to insert ( when I’m in insert mode. If you’re looking to actually make keys behave like other keys, it’s easiest to set this at an operating system level, and I only know that it’s possible with X11 on Linux. It’s actually easier to set that up than to do these Elisp hacks, but if you’re like me and have to work on Linux and Non-Linux machines this is a great way to get consistent keyboard behavior anywhere Emacs runs.
Update: /u/clemera on
Reddit
pointed out that there is an Emacs map for these kinds of mappings
called key-translation-map
. The docs for it are:
key-translation-map is a variable defined in 'keyboard.c'.
Its value is
(keymap
(double-down-mouse-1 . mouse--down-1-maybe-follows-link)
(down-mouse-1 . mouse--down-1-maybe-follows-link)
(24 keymap
(56 . iso-transl-ctl-x-8-map)))
This variable may be risky if used as a file-local variable.
Documentation:
Keymap of key translations that can override keymaps.
This keymap works like 'input-decode-map', but comes after 'function-key-map'.
Another difference is that it is global rather than terminal-local.
