Test-Driving Fonts in Emacs with a Transient Menu
Intro
I usually stick with some favorite font for a while, then, for some reason, the bug bites: some new activity, different hardware or work, and I want to change the default font.
I have this old file with content like this:
(set-face-attribute 'default nil :family "3270 Nerd Font Mono")
(set-face-attribute 'default nil :family "GoMono Nerd Font")
(set-face-attribute 'default nil :family "Hack Nerd Font Mono")
(set-face-attribute 'default nil :family "Iosevka Nerd Font Mono")
(set-face-attribute 'default nil :family "JetBrainsMono Nerd Font")
;; ... and a bunch more
What I do (shame on me) is put the cursor at the end of a line, C-x C-e, switch back to the buffer or tab I was working on, look around for a while, switch back to the file, move down one line, C-x C-e again... You get the idea. It works, but it's clunky, and changing the size means editing the line and adding :height to it.
I wanted a way to scrub through my font list and increase or decrease the font height using only the arrow keys, while I check live what feels right.
That's a perfect job for a transient menu, which has shipped with Emacs since version 28, written by the one and only Jonas Bernoulli, author of nothing less than Magit.
This post is a walkthrough of how you can implement such functionality yourself, and hopefully a guide along your Elisp learning journey.
TL;DR: jump straight to the , put it in your init file, edit the font list, and run M-x font-cycle. Font Candidates
First, we build a list of font families:
(defvar font-cycle-families
'("3270 Nerd Font Mono"
"GoMono Nerd Font"
"Hack Nerd Font Mono"
"Hurmit Nerd Font Mono"
"Inconsolata LGC Nerd Font"
"Iosevka Nerd Font Mono"
"IosevkaTerm Nerd Font Mono"
"JetBrainsMono Nerd Font"
"Lekton Nerd Font Mono"
"M+CodeLat50 Nerd Font"
"Maple Mono NF"
"MartianMono Nerd Font"
"SauceCodePro Nerd Font"
"UbuntuMono Nerd Font Mono"
"VictorMono Nerd Font Mono"
"ZedMono Nerd Font Mono")
"Font families to cycle through with `font-cycle'.")
If you don't know the exact family name of a font you have installed, and how it is "perceived" by Emacs, you can use M-x describe-font with completion, or M-: (font-family-list).
If a family on that list is not installed (a typo, another machine, a font you uninstalled), set-face-attribute won't complain. Emacs just falls back to some other font, and you end up judging the wrong thing. So, before cycling, we keep only the families Emacs can actually find:
(defun font-cycle--installed ()
"Return the members of `font-cycle-families' installed on this system."
(seq-filter (lambda (family) (find-font (font-spec :family family)))
font-cycle-families))
Left and Right (fonts) Cycling
Cycling is an index and a mod. The mod is what makes it wrap around: going right from the last font takes you to the first one, and going left from the first takes you to the last.
(defvar font-cycle--index 0
"Position of the current font in the installed families.")
(defun font-cycle--select (index)
"Use the installed family at INDEX, wrapping around both ends."
(let ((fonts (font-cycle--installed)))
(unless fonts
(user-error "None of `font-cycle-families' is installed"))
(setq font-cycle--index (mod index (length fonts)))
(set-face-attribute 'default nil
:family (nth font-cycle--index fonts))))
(defun font-cycle-next ()
"Switch to the next font."
(interactive)
(font-cycle--select (1+ font-cycle--index)))
(defun font-cycle-previous ()
"Switch to the previous font."
(interactive)
(font-cycle--select (1- font-cycle--index)))
Note that mod in Elisp follows the sign of the divisor, so (mod -1 16) is 15, not -1. This is what we want for going left.
Up and Down (height) Cycling
For the size, we change the :height of the default face. Its unit is 1/10 of a point, so a step of 10 is one point:
(defun font-cycle--resize (delta)
"Change the default face height by DELTA (1/10 pt)."
(set-face-attribute 'default nil
:height (+ (face-attribute 'default :height) delta)))
(defun font-cycle-bigger ()
"Increase the font size by 1pt."
(interactive)
(font-cycle--resize 10))
(defun font-cycle-smaller ()
"Decrease the font size by 1pt."
(interactive)
(font-cycle--resize -10))
Keeping the Winner
When I finally find a combination I like, I don't want to remember and retype it. So one more command builds the exact line for my init file and puts it in the kill ring for later yanking:
(defun font-cycle--form ()
"Return the `set-face-attribute' call for the current font."
(format "(set-face-attribute 'default nil :family %S :height %d)"
(face-attribute 'default :family)
(face-attribute 'default :height)))
(defun font-cycle-copy ()
"Copy the current font setting as Elisp to the kill ring."
(interactive)
(kill-new (font-cycle--form))
(message "Copied: %s" (current-kill 0)))
Then it's just a C-y in init.el.
The Transient Menu
Now let's glue everything together. Each suffix marked with :transient t runs its command and keeps the menu open, so you can press the arrows as many times as you want. y and q don't have it, so they close the menu.
(transient-define-prefix font-cycle ()
"Cycle through `font-cycle-families' and adjust the font size."
[:description font-cycle--description
["Font"
("" "next" font-cycle-next :transient t)
("" "previous" font-cycle-previous :transient t)]
["Size"
("" "bigger" font-cycle-bigger :transient t)
("" "smaller" font-cycle-smaller :transient t)]
["Done"
("y" "copy as elisp" font-cycle-copy)
("q" "quit" transient-quit-one)]])
The :description of the outer group is a function, and transient calls it again every time the menu is redrawn. That gives us a header that always tells which font is on screen, its size, and where we are in the list:
(defun font-cycle--description ()
"Header showing the current font, its size and position in the list."
(format "%s %dpt [%d/%d]"
(propertize (face-attribute 'default :family) 'face 'bold)
(/ (face-attribute 'default :height) 10)
(1+ font-cycle--index)
(length (font-cycle--installed))))
Run M-x font-cycle and you get something like this at the bottom of the frame:
IosevkaTerm Nerd Font Mono 16pt [7/16]
Font Size Done
next bigger y copy as elisp
previous smaller q quitSince the menu lives at the bottom, everything above it is your real work: the buffers you were editing, your theme, your splits. Tap right, look, tap right again. If you like it, bump it up a point or two. When you're done, y and paste.
If you want it one key away:
(keymap-global-set "C-c f" #'font-cycle)
Full Source
;;; font-cycle.el --- Test-drive fonts with a transient menu -*- lexical-binding: t; -*-
(require 'transient)
(defvar font-cycle-families
'("3270 Nerd Font Mono"
"GoMono Nerd Font"
"Hack Nerd Font Mono"
"Hurmit Nerd Font Mono"
"Inconsolata LGC Nerd Font"
"Iosevka Nerd Font Mono"
"IosevkaTerm Nerd Font Mono"
"JetBrainsMono Nerd Font"
"Lekton Nerd Font Mono"
"M+CodeLat50 Nerd Font"
"Maple Mono NF"
"MartianMono Nerd Font"
"SauceCodePro Nerd Font"
"UbuntuMono Nerd Font Mono"
"VictorMono Nerd Font Mono"
"ZedMono Nerd Font Mono")
"Font families to cycle through with `font-cycle'.")
(defvar font-cycle--index 0
"Position of the current font in the installed families.")
(defun font-cycle--installed ()
"Return the members of `font-cycle-families' installed on this system."
(seq-filter (lambda (family) (find-font (font-spec :family family)))
font-cycle-families))
(defun font-cycle--select (index)
"Use the installed family at INDEX, wrapping around both ends."
(let ((fonts (font-cycle--installed)))
(unless fonts
(user-error "None of `font-cycle-families' is installed"))
(setq font-cycle--index (mod index (length fonts)))
(set-face-attribute 'default nil
:family (nth font-cycle--index fonts))))
(defun font-cycle-next ()
"Switch to the next font."
(interactive)
(font-cycle--select (1+ font-cycle--index)))
(defun font-cycle-previous ()
"Switch to the previous font."
(interactive)
(font-cycle--select (1- font-cycle--index)))
(defun font-cycle--resize (delta)
"Change the default face height by DELTA (1/10 pt)."
(set-face-attribute 'default nil
:height (+ (face-attribute 'default :height) delta)))
(defun font-cycle-bigger ()
"Increase the font size by 1pt."
(interactive)
(font-cycle--resize 10))
(defun font-cycle-smaller ()
"Decrease the font size by 1pt."
(interactive)
(font-cycle--resize -10))
(defun font-cycle--form ()
"Return the `set-face-attribute' call for the current font."
(format "(set-face-attribute 'default nil :family %S :height %d)"
(face-attribute 'default :family)
(face-attribute 'default :height)))
(defun font-cycle-copy ()
"Copy the current font setting as Elisp to the kill ring."
(interactive)
(kill-new (font-cycle--form))
(message "Copied: %s" (current-kill 0)))
(defun font-cycle--description ()
"Header showing the current font, its size and position in the list."
(format "%s %dpt [%d/%d]"
(propertize (face-attribute 'default :family) 'face 'bold)
(/ (face-attribute 'default :height) 10)
(1+ font-cycle--index)
(length (font-cycle--installed))))
(transient-define-prefix font-cycle ()
"Cycle through `font-cycle-families' and adjust the font size."
[:description font-cycle--description
["Font"
("" "next" font-cycle-next :transient t)
("" "previous" font-cycle-previous :transient t)]
["Size"
("" "bigger" font-cycle-bigger :transient t)
("" "smaller" font-cycle-smaller :transient t)]
["Done"
("y" "copy as elisp" font-cycle-copy)
("q" "quit" transient-quit-one)]])
(provide 'font-cycle)
;;; font-cycle.el ends here
What Could be Improved
A few extras, if you want to practice some Elisp:
- Replace the index and
modwith Emacs' built-inring.el. - Show the visible columns and lines in the header, so you know how much fits on screen with each font and size.
- Start cycling from the font you are currently using.
- Add a key that restores the font and size you had before opening the menu.
- Cycle themes, or a second list for the
variable-pitchface, the same way. - Turn it into a publishable package:
defcustoms, an autoload, proper headers, and a cleancheckdocand byte-compile.
Honorable Mentions
Font picking seems to be in the air lately. If you'd rather use something ready-made, or just want to see other takes on the idea, check these out:
- show-font, by Protesilaos, on GNU ELPA: previews installed fonts in a dedicated buffer, and
show-font-tabulatedlists every font rendered in its own typeface. - flick, "a quick and simple font picker", by Vegas Harmon, recently shared on r/emacs.
- This r/emacs comment, from a recent discussion on the same subject.
Wrapping Up
There's nothing specifically clever here: a list, an index, a mod, and a transient menu holding it all together.
I hope this educational post helps you with the basics of using transient, and maybe teaches you something new about Emacs Lisp along the way.
As for me, I'm currently on font 7 of 16. Ask me again next month.