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:

  1. 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.
  2. 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.
  3. 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.

Share your opinion, stupid or not! (But it probably is.)

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s