Semi modal editing
Vanilla Emacs bindings often lack the snappiness of a modal editor like
Vim, especially when navigating, having to prefix each command with a
bunch of C-x, C-c, C-@ and similar monstrosities can get quite
tiring (or downright harmful!). It would be nice to be able to switch to
some sort of a temporary modal state, where a single key press binds
to a command (just like in Vim), and to get back to good old vanilla
Emacs pinky workout by simply pressing any unbound key.
Implementation
Turns out that Emacs implements natively what I’ve been describing as “temporary modal states”, they are called transient-keymaps, you can think of a transient keymap as a temporary keymap that takes precedence over all the others until an unbound key is pressed.
(defun transient-next/prev-line ()
(interactive)
(set-transient-map
(let ((tmap (make-sparse-map)))
(define-key (kbd "n") #'next-line)
(define-key (kbd "p") #'prev-line)
tmap)))Calling transient-next/prev-line sets up a transient keymap in which
key n is bound to next-line and key p to prev-line, pressing
anything else drops you out of the transient keymap. This is close to
the behavior we want but our n and p bindings are not sticky like
they would be in Vim, we want to be able to press n and p infinitely
many times without leaving the transient keymap.
To achieve that we use some mutual recursion magic:
(defun transient-next-line ()
(interactive)
(next-line)
(set-transient-map
(let ((tmap (make-sparse-map)))
(define-key (kbd "n") #'transient-next-line)
(define-key (kbd "p") #'transient-prev-line)
tmap)))
(defun transient-prev-line ()
(interactive)
(prev-line)
(set-transient-map
(let ((tmap (make-sparse-map)))
(define-key (kbd "n") #'transient-next-line)
(define-key (kbd "p") #'transient-prev-line)
tmap)))Command transient-next-line moves point to the next line and sets up a
transient keymap, from there we can press n or p to to keep moving
between lines how many times we want. To wrap everything up we bind our
to commands to a common prefix:
(global-set-key (kbd "C-c n") #'transient-next-line)
(global-set-key (kbd "C-c p") #'transient-prev-line)Now after pressing either C-c p or C-c n we can keep pressing either
n or p to move point, pressing anything else kicks us out of the
transient. This is exactly the behavior we are looking for, mission
accomplished :D. This is pretty much how Emacs handles text resizing
inside a buffer via C-x C-+ and C-x C--.
Tidying up
We can get rid of all the above boilerplate by writing a macro that defines the mutually recursive commands and binds them to a common prefix for us:
(defmacro bind-transient-keymap (prefix &rest keyalist)
"Build a transient keymap consisting of each (\"key\" . command) pair in
KEYALIST and bind it to PREFIX."
(cl-flet ((rename-transient (symbol)
"Add `--transient-wrapper` to SYMBOL"
(intern (concat (symbol-name symbol) "--transient-wrapper"))))
;; create a --transient-wrapper for each entry in KEYALIST
`(progn
,@(mapcar (lambda (pair)
(let ((key (car pair))
(cmd (cdr pair)))
`(defun ,(rename-transient cmd) ()
(interactive)
(call-interactively #',cmd)
(set-transient-map
(let ((tmap (make-sparse-keymap)))
,@(mapcar (lambda (p)
(let ((k (car p))
(c (cdr p)))
`(define-key tmap
(kbd ,k)
#',(rename-transient c))))
keyalist)
tmap)))))
keyalist)
;; bind each --transiet-wrapper
,@(mapcar (lambda (pair)
(let ((key (car pair))
(cmd (cdr pair)))
`(global-set-key (kbd ,(concat prefix " " key))
#',(rename-transient cmd))))
keyalist))))Replicating the example from above using our newly defined macro is as simple as:
(bind-transient-keymap "C-C"
("n" . next-line)
("p" . prev-line))