I recently read Greg Hendershott's emacs themes blog post, and cribbed liberally from his approach to loading themes. I also cribbed his Hydra theme switcher. It was so fun to use! I wanted to try it with all the themes I have installed—but I didn't want to add all of them manually. Boo! So I set out to see if I could dynamically add all installed themes to my Hydra theme switcher.
First I needed a list of all installed themes. I remembered that when
you do M-x load-theme RET
and TAB you get a list of all the themes,
so I started looking there. C-h f load-theme RET
brings up the
documentation for that function, and this has a link to the source at
the top. I clicked that, and quickly found that it calls
custom-available-themes
.
Next I needed to generate the Hydra doc-string/menu. My biggest annoyance with the manual process was modifying the doc-string to add the key & hint. So initially I thought about automating this in some way, perhaps by listing light & dark & "other" themes separately. But it would still be annoying. I thought about chaining hydras too, but then I discovered that Hydra supports a different mode: you can provide a hint as the third argument in the "head" and it will create the menu for you. I opted for this approach.
I now needed to come up with a strategy for how to select KEYs to
select each theme. First I thought about using mnemonics for the
themes themselves, but I have both leuven
, leuven-dark
,
light-blue
, and liso
all starting with l
. I then thought about
using shortest unique sub-string, but that would mean variable-length
keys which I didn't want; as much as possible I wanted
single-keystroke keys. So I decided to go with an alphabet of 62
candidate keys:
(setq sb/hydra-selectors "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")
The list returned by custom-available-themes
is not sorted
alphabetically, but I wanted my Hydra menu to present them that way.
It took a while to figure out how to sort the list since it's a list
of symbols rather than strings, and also I spent a long time hunting
for non-destructive sort. (I didn't find any, but it turns out the
list is created every time so it's not necessary.) My theme sorter
looks like this:
(defun sb/sort-themes (themes) (sort themes (lambda (a b) (string< (symbol-name a) (symbol-name b)))))
Now I was ready create my Hydra's "heads". These should be of the
form (KEY ACTION HINT)
. I had a list of candidate KEYs, and a list
of themes to build the action and hint, but I needed to piece it
together. I struggled to figure out how to correlate the KEY and THEME
using mapcar
, but then I noticed mapcar*
(final *
is
significant) among the auto-complete candidates. This is very similar
to mapcar
but takes multiple lists and passes a value from each to
the mapping function. Just what I needed! As a bonus it stops when
either list runs out of items.
(defun sb/hydra-load-theme-heads (themes) (mapcar* (lambda (a b) (list (char-to-string a) `(sb/load-theme ',b) (symbol-name b))) sb/hydra-selectors themes))
The back-quote (`
) in that snippet is similar to quoting with '
or
quote
, but allows you to selectively unquote bits inside with ,
.
I need it because I needed to quote the argument to sb/load-theme
.
defhydra
doesn't take a list of heads, but I thought I might find a
related function that would, perhaps defhydra*
. Unfortunately, I had
no such luck. However, this is Lisp so there are ways. We'll reach for
back-quote again, but this time instead of a simple unquote we splice
our heads into the defhydra
argument list with ,@
. This now looked
like so:
(eval `(defhydra sb/hydra-select-themes (:hint nil :color pink) "Select Theme" ,@(sb/hydra-load-theme-heads (sb/sort-themes (custom-available-themes))) ("DEL" (sb/disable-all-themes)) ("RET" nil "done" :color blue)))
We then just need to assign a keybinding, which I do like this:
(bind-keys ("C-c w t" . sb/hydra-select-themes/body))
This worked beautifully, except for one issue: if I installed a new
theme it would not show up in my Hydra menu until I manually
re-evaluated the config snippet, or restart Emacs. That's not ideal.
Perusing the Hydra examples revealed a recipe that assigned the return
value of defhydra
to the key, so next I tried to rewrite my code to
this:
(bind-keys ("C-c w t" . (eval `(defhydra sb/hydra-select-themes (:hint nil :color pink) "Select Theme" ,@(sb/hydra-load-theme-heads (sb/sort-themes (custom-available-themes))) ("DEL" (sb/disable-all-themes)) ("RET" nil "done" :color blue)))))
Unfortunately that did not work. Launching the Hydra now I got the following error:
command-execute: Wrong type argument: commandp, (eval (\` (defhydra sb/hydra-select-themes (:hint nil :color pink) "Select Theme" (\,@ (sb/hydra-load-theme-heads (sb/sort-themes (custom-available-themes)))) ("DEL" (sb/disable-all-themes)) ("RET" nil "done" :color blue))))
I didn't really understand what that meant, but I searched the hydra
issues some more for "dynamic" invocation and found a comment with a
recipe that I was able to adapt. It's a bit more faff, and I don't
understand why the call-interactively
is necessary, but it works and
here it is:
(bind-keys ("C-c w t" . (lambda () (interactive) (call-interactively (eval `(defhydra sb/hydra-select-themes (:hint nil :color pink) "Select Theme" ,@(sb/hydra-load-theme-heads (sb/sort-themes (custom-available-themes))) ("DEL" (sb/disable-all-themes)) ("RET" nil "done" :color blue)))))))
For completeness here's the full source for this switcher:
(defun sb/disable-all-themes () (interactive) (mapc #'disable-theme custom-enabled-themes)) (defun sb/load-theme (theme) "Enhance `load-theme' by first disabling enabled themes." (sb/disable-all-themes) (load-theme theme)) (setq sb/hydra-selectors "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ") (defun sb/sort-themes (themes) (sort themes (lambda (a b) (string< (symbol-name a) (symbol-name b))))) (defun sb/hydra-load-theme-heads (themes) (mapcar* (lambda (a b) (list (char-to-string a) `(sb/load-theme ',b) (symbol-name b))) sb/hydra-selectors themes)) (bind-keys ("C-c w t" . (lambda () (interactive) (call-interactively (eval `(defhydra sb/hydra-select-themes (:hint nil :color pink) "Select Theme" ,@(sb/hydra-load-theme-heads (sb/sort-themes (custom-available-themes))) ("DEL" (sb/disable-all-themes)) ("RET" nil "done" :color blue)))))))
For what it's worth, here's my Emacs Themes Config on GitHub.