Rahul Juliato: Test-Driving Fonts in Emacs with a Transient Menu

Wait 5 sec.

IntroI usually stick with some favorite font for a while, then, for somereason, 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 moreWhat 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 aroundfor a while, switch back to the file, move down one line, C-x C-eagain... You get the idea. It works, but it's clunky, and changing thesize means editing the line and adding :height to it.I wanted a way to scrub through my font list and increase or decreasethe font height using only the arrow keys, while I check live whatfeels right.That's a perfect job for atransientmenu, which has shipped with Emacs since version 28, written by theone and only Jonas Bernoulli, author of nothing less thanMagit.This post is a walkthrough of how you can implement such functionalityyourself, and hopefully a guide along your Elisp learning journey.TL;DR: jump straight to the full source, put itin your init file, edit the font list, and run M-x font-cycle.Font CandidatesFirst, 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-fontwith completion, or M-: (font-family-list).If a family on that list is not installed (a typo, another machine, afont you uninstalled), set-face-attribute won't complain. Emacs justfalls back to some other font, and you end up judging the wrongthing. So, before cycling, we keep only the families Emacs canactually 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) CyclingCycling is an index and a mod. The mod is what makes it wraparound: 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) CyclingFor the size, we change the :height of the default face. Its unitis 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 WinnerWhen I finally find a combination I like, I don't want to remember andretype it. So one more command builds the exact line for my init fileand 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 MenuNow let's glue everything together. Each suffix marked with:transient t runs its command and keeps the menu open, so you canpress 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 transientcalls it again every time the menu is redrawn. That gives us a headerthat always tells which font is on screen, its size, and where we arein 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 ofthe 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 realwork: the buffers you were editing, your theme, your splits. Tapright, look, tap right again. If you like it, bump it up a point ortwo. 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 hereWhat Could be ImprovedA few extras, if you want to practice some Elisp:Replace the index and mod with Emacs' built-in ring.el.Show the visible columns and lines in the header, so you know howmuch 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 themenu.Cycle themes, or a second list for the variable-pitch face, thesame way.Turn it into a publishable package: defcustoms, an autoload,proper headers, and a clean checkdoc and byte-compile.Honorable MentionsFont picking seems to be in the air lately. If you'd rather usesomething ready-made, or just want to see other takes on the idea,check these out:show-font, byProtesilaos, on GNU ELPA: previews installed fonts in a dedicatedbuffer, and show-font-tabulated lists every font rendered in itsown typeface.flick, "a quick and simplefont picker", by Vegas Harmon, recently shared on r/emacs.This r/emacs comment,from a recent discussion on the same subject.Wrapping UpThere'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 usingtransient, and maybe teaches you something new about Emacs Lisp alongthe way.As for me, I'm currently on font 7 of 16. Ask me again next month.