Extending use-package's :bind to support evil and keymaps
Use-package is a popular macro for declaring, configuring and organising
packages in your Emacs config. One of the features it offers is the :bind
keyword, which allows you to declare bindings like this:
(use-package org
:bind (:map org-mode-map
("C-c C-y" . org-store-link)))
This will bind C-c C-y
to the function (org-store-link)
in the org-mode-map
keymap.
There are two limitations that I've wanted to fix for a while. The first is that
I use evil to provide vim-style modal bindings. Evil's use of keymaps is
slightly different to the usual Emacs convention as it contains an additional
piece of state – the editing mode. It provides (evil-define-key)
, which lets
you do something like…
(evil-define-key 'normal org-mode-map "gk" 'outline-previous-visual-heading)
…to bind to org-mode-map
specifically in evil's normal state. I don't
believe it's possible to make these evil-state assignments using the :bind
keyword.
The second limitation is that sometimes I want to bind a key to a
keymap. Eg. maybe I want to assign <leader>h
to help-map
:
(use-package emacs
:bind (:map my/leader-map
("h" . help-map)))
This doesn't work, because when the :bind
handler expands, it quotes all of
the values of the associations, so you end up with (bind-key "h" 'help-map
my/leader-map)
. This gets interpretated as a function and fails – to reference
the keymap you need to pass it in without the quote[1]. Use-package does provide a :bind-keymap
feature, but that expects
the keymap to be defined in the package you're configuring, and it doesn't allow
you to specify a :map
– you can only create global keymap bindings.
1. Extending use-package with new keywords
Enter: use-package extensions, which I explored yesterday for the first time. It's pretty easy to add new keywords to use-package. You have to add three things:
;; 1. Extend use-package-keywords
(add-to-list 'use-package-keywords :my-keyword t)
;; 2. Validate and return the args passed to the keyword
(defun use-package-normalize/:my-keyword (name keyword args)
args)
;; 3. Handle the keyword -- expand into the code you want it to run.
(defun use-package-handler/:my-keyword (name _keyword args rest state)
(let ((body (use-package-process-keywords name rest state)))
`((with-eval-after-load ',name)
(message "%s" ,@args))))
;; This will print Hello, world after org has loaded.
(use-package org
:my-keyword "Hello, world")
If you use pp-macroexpand-last-sexp
, you can see how the macro expands, which
makes it a lot easier to understand what use-package does under the hood. For
me, our use-package
declaration above expands to:
(progn
(straight-use-package 'org)
(defvar use-package--warning46
#'(lambda
(keyword err)
(let
((msg
(format "%s/%s: %s" 'org keyword
(error-message-string err))))
(display-warning 'use-package msg :error))))
(condition-case-unless-debug err
(let
((now
(current-time)))
(message "%s..." "Loading package org")
(prog1
(if
(not
(require 'org nil t))
(display-warning 'use-package
(format "Cannot load %s" 'org)
:error)
(with-eval-after-load 'org)
(message "%s" "Hello, world"))
(let
((elapsed
(float-time
(time-subtract
(current-time)
now))))
(if
(> elapsed 0.001)
(message "%s...done (%.3fs)" "Loading package org" elapsed)
(message "%s...done" "Loading package org")))))
(error
(funcall use-package--warning46 :catch err))))
2. The implementation
Back to the :bind
issue: I don't want to modify the default behaviour of
:bind
, but I can imagine a new keyword that provides similar
functionality. The implementation I got to was this:
(add-to-list 'use-package-keywords :md/bind t)
(defun use-package-normalize/:md/bind (name keyword args)
"Custom use-keyword :md/bind. I use this to provide something similar to ':bind',
but with two additional features that I miss from the default implementation:
1. Integration with 'evil-define-key', so I can extend the keymap declaration
to specify one or more evil states that the binding should apply to.
2. The ability to detect keymaps that aren't defined as prefix commands. This
allows me to define a binding to a keymap variable, eg. maybe I want '<leader>h'
to trigger 'help-map'. This fails using the default ':bind', meaning that I
have to fall back to calling 'bind-key' manually if I want to assign a
prefix.
The expected form is slightly different to 'bind':
((:map (KEYMAP . STATE) (KEY . FUNC) (KEY . FUNC) ...)
(:map (KEYMAP . STATE) (KEY . FUNC) (KEY . FUNC) ...) ...)
STATE is the evil state. It can be nil or omitted entirely. If given, it should be an
argument suitable for passing to 'evil-define-key' -- meaning a symbol like 'normal', or
a list like '(normal insert)'."
(setq args (car args))
(unless (listp args)
(use-package-error ":md/bind expects ((:map (MAP . STATE) (KEY . FUNC) ..) ..)"))
(dolist (def args args)
(unless (and (eq (car def) :map)
(consp (cdr def))
(listp (cddr def)))
(use-package-error ":md/bind expects ((:map (MAP . STATE) (KEY . FUNC) ..) ..)"))))
(defun use-package-handler/:md/bind (name _keyword args rest state)
"Handler for ':md/bind' use-package extension. See 'use-package-normalize/:md/bind' for docs."
(let ((body (use-package-process-keywords name rest
(use-package-plist-delete state :md/bind))))
(use-package-concat
`((with-eval-after-load ',name
,@(mapcan
(lambda (entry)
(let ((keymap (car (cadr entry)))
(state (cdr (cadr entry)))
(bindings (cddr entry)))
(mapcar
(lambda (binding)
(let ((key (car binding))
(val (if (and (boundp (cdr binding)) (keymapp (symbol-value (cdr binding))))
;; Keymaps need to be vars without quotes
(cdr binding)
;; But functions need to be quoted symbols
`(quote ,(cdr binding)))))
;; When state is provided, use evil-define-key. Otherwise fall back to bind-key.
(if state
`(evil-define-key ',state ,keymap (kbd ,key) ,val)
`(bind-key ,key ,val ,keymap))))
bindings)))
args)))
body))))
If you can look past all the cars and cdrs, the main part of the handler logic
here is that, if an evil state is provided, we pass the definition to
(evil-define-key)
instead of (bind-key)
. And, we check to see if the passed
in variable is bound to a keymap – and then pass it in unquoted.
3. The result
The result is that I can finally get all of my bindings into the use-package declaration:
(use-package org
:after (evil)
:md/bind ((:map (org-mode-map) ;; Expands to (bind-key), similar to :bind
("C-c C-y" . org-store-link))
(:map (org-mode-map . normal) ;; Only bind in normal mode
("gk" . outline-previous-visible-heading)
("gj" . outline-next-visible-heading))
(:map (org-mode-map . (normal insert)) ;; Bind in normal and insert mode
("M-k" . org-metaup)
("M-j" . org-metadown))
(:map (md/leader-map) ;; Bind to a keymap
("h" . help-map))))
This is a lot cleaner than what I had previously: no more calls to
(evil-define-key)
and (bind-key)
littered everywhere, and I can see
everything defined in one place.
One nice thing about this is that I didn't need to do any special handling to
support passing in a list of evil modes because (evil-define-key)
supports
that by default.
This implementation isn't at feature parity with the original :bind
keyword. I
haven't considered any kind of lazy-loading of bound commands here, and I don't
pass the arguments through to the bind-keys
macro, meaning that lots of the
documented features aren't implemented (:repeat-map
, :prefix
etc.). I don't
use these things though, so I think this new implementation is actually all I
need.
The code can be found in my dotfiles repo.