font sets the terminal font on every terminal on the machine at once. It logs every apply, like,
dislike, note and rejection, and its rankings come out of that log. Which font you prefer is read
from how long you kept each one, not only from what you said about it. Sync shares one history
across machines through a GitHub Gist.
Each terminal keeps its own config. font owns one small file per app, holding the font family and
size, and your config includes that file. Nothing else in your config is rewritten.
curl -fsSL https://raw.githubusercontent.com/datapointchris/font/main/install.sh | bashOr from a clone, which runs the same installer without piping it:
git clone https://github.com/datapointchris/font.git ~/.local/share/font
bash ~/.local/share/font/install.shThe installer clones font into ~/.local/share/font, moves it to its newest release tag, and links
~/.local/bin/font. FONT_INSTALL_DIR and FONT_BIN_DIR move either path. It also clones
bashselfupdate into ~/.local/lib. A failure
there is not fatal: every command still runs, and only font update and the update notice need it.
Re-running the installer moves an existing checkout to the newest tag rather than cloning again.
font update moves the checkout to the newest release tag. It refuses a checkout that is not
sitting on a tag, because that is a development clone and git is the tool for moving it.
font also checks once a day and prints one line when a newer release exists:
font <newer> available (running <current>) — run `font update`
It never installs anything and never prints an error. A failed check is recorded and swallowed,
because an update notice must not break the command you typed. Nothing is printed when either
stream is not a terminal, under CI, from a checkout that is not sitting on a release tag, or within
the interval. Set NO_AUTO_UPDATE or FONT_NO_AUTO_UPDATE to turn it off, and
FONT_AUTO_UPDATE_INTERVAL=6h to change the cadence.
bash4 or newer. On macOS that means Homebrew's, since the system bash is 3.2.jq— every read of the history and the registryfzf—change,infoandunrejectgit— the installer, version reporting andfont updategh—font installdownloads through it, andfont syncstores history with itbashselfupdate—font updateand the daily notice; the installer installs it- fontconfig —
font installrefreshes the font cache on Linux and WSL bat(optional) — colorizesfont log
curl -fsSL https://raw.githubusercontent.com/datapointchris/font/main/install.sh | bash # font, at its newest release
gh auth login # install downloads through gh, and sync keeps history on your account
font install --check # which curated families this machine already has; installs nothing
font install # unpack the curated archive into your user font directory
echo 'include fonts/current.conf' >>~/.config/kitty/kitty.conf # kitty reads font's file
echo 'config-file = ?fonts/current.conf' >>~/.config/ghostty/config # ghostty reads it too
echo 'gtk-custom-css = ?fonts/current.css' >>~/.config/ghostty/config # and its tab labels, on Linux
font change # pick a font in fzf and apply it, each one's record previewed beside it
font current # the font this terminal's file names, with its record, to confirm the apply landedfont install uses ~/fonts.tar.xz when one exists and downloads nothing. That is the route for a
machine that cannot reach GitHub. Otherwise it downloads fonts.tar.xz from this repository's
fonts-v1 release. It unpacks into ~/Library/Fonts on macOS and $XDG_DATA_HOME/fonts on Linux,
which is ~/.local/share/fonts when that variable is unset. On WSL it unpacks into the Windows
user's font directory and registers each font with Windows.
The include lines are the one edit your config needs. Any font command creates either file when it
is missing, naming monospace at size 17. The leading ? tells Ghostty that a missing file is not
an error. Waybar, Hyprlock and dunst on Arch take their own lines, listed under apply below.
apply reloads Kitty, and Ghostty on Linux, so their open windows redraw in the new font. It
restarts Waybar, and stops dunst so it reads the drop-in when it next starts. Waybar is restarted
rather than signaled, because Waybar's mpris module can crash on its reload signal. Ghostty on macOS
shows the font after a config reload or in a new window, and apply says so there.
Every verb answers font <verb> --help with its own synopsis. font help lists them all.
change, info and unreject open their fzf picker only when stdin is a terminal. --no-input
keeps it closed even on one. Without a terminal each exits 2 and names the form that takes the font
as an argument: font apply <font>, font info <font> or font unreject <font>.
font apply "Fira Code Nerd Font" # by name; an unknown name lists the curated names that contain it
font change # the fzf picker
font random # weighted toward the font you have gone longest without
font last # back to the font applied before this one
font size-up # this machine's terminal size one point larger; size-down is the reverseEach apply writes every app below that is present, whichever terminal you typed it in:
| App | font writes | Your config adds |
|---|---|---|
| Ghostty | ~/.config/ghostty/fonts/current.conf, and current.css beside it |
config-file = ?fonts/current.conf and gtk-custom-css = ?fonts/current.css |
| Kitty | ~/.config/kitty/fonts/current.conf |
include fonts/current.conf |
| Windows Terminal, on WSL | its settings.json, after copying it to settings.json.backup |
nothing |
| Waybar, on Arch | ~/.config/waybar/fonts/current.css |
@import "fonts/current.css"; in style.css, and its font-size in pt |
| Hyprlock, on Arch | ~/.config/hypr/fonts/current.conf |
source = ~/.config/hypr/fonts/current.conf, then font_family = $font, and each label's text in a sized span |
| dunst, on Arch | ~/.config/dunst/dunstrc.d/99-font.conf |
nothing, since it is a drop-in |
Each ~/.config above is $XDG_CONFIG_HOME when that variable is set.
The Ghostty and Kitty files are written whether or not either terminal is installed. The Arch apps
are written only when their config directory exists. An include file keeps a config you track in
version control clean: font rewrites its own file whole and edits nothing around it. Windows
Terminal is the exception, and its settings.json is edited in place with a backup beside it.
Every app above draws every font at the same digit height. Fonts at one size do not: Serious
Shanns draws its 0 at 0.662em and Monaspice Ne at 0.743em, so the second looks a size larger.
Each size you choose is read as meant for a font whose digits are 0.74em tall, then scaled by the
registry's digit_height for the font applied. A font with no digit_height is drawn at the size
as chosen.
The terminal size is this machine's own, kept in terminal-size.json and never synced, since each
screen wants its own. size-up and size-down move it, and every terminal is rewritten with it
scaled to the font it shows. Until the first move it is the size your terminal already had.
Waybar's scale is the -gtk-dpi font writes beside the family. GTK applies it to pt sizes and
ignores px ones, which is why style.css states its size in pt. dunst's size is the one on your
dunstrc's own font line, or 10 when it has none, and the drop-in carries it scaled.
Hyprlock's scale is $font_scale, a plain factor such as 1.118. A label states its size inside its
text, and hyprlang multiplies it when it reads the config:
text = <span size="{{$font_scale * 18}}pt">$USER</span>font_size = {{$font_scale * 18}} would be simpler and fails: font_size is an integer setting, and
hyprlang rejects 20.124 there. A percentage span such as size="111.8%" is also out, because
Hyprlock's renderer adds its own scale of 1 over the text, which replaces it. Define $font = monospace
and $font_scale = 1 before the source line, so a machine that has never run font apply still
parses.
Every action appends one record to the history, carrying the context it happened in:
{"terminal":"ghostty","in_tmux":false,"cols":212,"rows":58,"resolution":"3440x1440","font_size":17,"machine":"laptop","platform":"macos","ts":"2026-01-04T17:24:03Z","font":"Fira Code Nerd Font","action":"apply","trigger":"manual"}An apply's trigger says who made it: manual for one you typed, schedule for one the timer
made. A row with no trigger predates the field and was applied by hand. A scheduled apply runs
with no terminal, so its row carries the machine, platform, time, font and trigger and nothing else.
random draws from the curated families, minus the rejected ones and the font already on screen.
A font's weight grows with the days since it was last applied, up to a cap, and a font never applied
starts at the cap. Apply count is the wrong axis. Counts bunch together and separate nothing, and a
font added at zero applies would be the only thing a count-based draw returned until it caught up.
last toggles between the two fonts most recently applied on this machine.
font like "great ligatures" # the reason is optional
font dislike "too wide"
font note "good for prose" # a note needs its message
font reject "zero has no slash" # out of random and the rankings; the reason is required
font unreject # pick a rejected font in fzf, its history beside it
font unreject "Hack Nerd Font" # or name it, which is how a script restores one
font list --status rejected # the rejected families; active and all are the other two statesThese act on the font of the terminal you type them in, read from that terminal's own file. font knows Ghostty, Kitty and Windows Terminal by their environment variables. In any other terminal there is no current font, and these four refuse.
Rejection is a logged action, not a separate list. A font is rejected when reject is the later of
its reject and unreject records, so there is no second file to keep in step. A rejected font
leaves font random and both rankings. It stays in font list, change, info and font apply,
so it is never unreachable.
font list --status takes active, rejected or all, and defaults to all. The curated set is
small enough that both halves stay worth seeing. font apply accepts every name in it, so a
narrower default would hide a name the tool still takes. Why a font sits where it does reads per
font: font current and font info render its history, rejection reason included.
font current # this terminal's font: description, history, stats and a 12-month usage sparkline
font info # the same page for any curated font, chosen in fzf, or named: font info "Hack Nerd Font"
font rank # one ranking by likes and one by hours used, then each font's scheduled turns
font stats # where the two signals disagree, how fast you try fonts, each machine's favorite
font log # every record, with the machine and terminal it came fromfont keeps two signals and never blends them:
- Stated preference is likes minus dislikes.
- Revealed preference is how long each font was active. It is reconstructed from the gap
between consecutive
applyrecords on the same machine, and the font on screen counts up to now. Another machine's apply says nothing about this screen, so it ends nothing here. Another machine's latest apply counts for nothing until that machine applies again, since nothing here knows when it ended.
font rank shows the two as separate lists, because a font you keep reaching for is a different
signal from one you liked once. Both lists rank only curated fonts that are not rejected. Under
them, the fonts the schedule has applied show how often each kept the screen until the next
scheduled change.
font stats reads the whole history: fonts used more than average but never rated, fonts liked
but used less than average, how many fonts you first tried each month, and the most-used font on
each machine.
font sync init # find the gist by its description, or create a secret one; run once per machine
font sync status # whether sync is on, the gist id, the last sync, whether gh is signed in
font sync pull # merge every other machine's rows in now
font sync push # pull, then upload this machine's rows now
font sync off # stop syncing; sync on resumes it and syncs at onceSync runs by itself once initialized. Every logged action pushes in the background, and change
and info pull before they open. init looks for a gist described as font-preferences, so a
second machine joins the first one's history rather than starting its own. The gist it creates is
secret: unlisted, but readable by anyone holding its URL. Each record carries a hostname, a terminal
and a screen resolution.
The gist holds one file per machine, history-<host>.jsonl, named for the lowercased short
hostname. Each machine writes only its own file. A pull takes every other machine's file as it
stands, and takes this machine's rows from the local history. A union of every machine's rows
cannot express a deletion: a row removed on one machine comes back from the next one still holding
it. With one writer per file, deleting a row you wrote is an ordinary edit to the local history,
and no other machine can put it back. Merged rows are deduplicated on timestamp, machine, font and
action, then sorted by time.
font schedule on # install the timer: a systemd user timer on Linux and WSL, a launchd agent on macOS
font schedule status # whether it is on, the font on screen, when the next change is due, the last check
font schedule off # remove the timer
font schedule run # one check, which is what the timer runsThe font changes at 04:00, 12:00 and 20:00 local time, at the first check in the hour after each
that finds you at the machine. The timer checks every five minutes. A turn you are away for is
skipped, so changes missed overnight never all land on your return. One you applied by hand holds
for twelve hours. At the machine on macOS means input within the last ten minutes. On Linux it
means logind marks your display session neither idle nor locked. On WSL it means a keystroke in
one of your WSL terminals within the last ten minutes. The draw is font random's.
Every scheduled apply is logged with "trigger":"schedule". So the history shows which fonts you
switched away from and which stayed on until the timer replaced them. font rank counts both for
each font under SCHEDULED TURNS:
SCHEDULED TURNS
1. Hack Nerd Font 1/1 kept 0 switched
2. Iosevka Term Slab Nerd Font 1/2 kept 1 switched after 1h 30m
3. JetBrains Mono Nerd Font 0/1 kept 1 switched after 20m
A turn starts at a scheduled apply and ends at the next apply of another font on the same machine. It was kept when the schedule made that apply, and switched when you did. Re-applying the same font ends nothing, and a turn still running is not counted.
hypridle does not set logind's idle hint by itself. This listener in hypridle.conf does:
listener {
timeout = 600
on-timeout = busctl call org.freedesktop.login1 /org/freedesktop/login1/session/auto org.freedesktop.login1.Session SetIdleHint b true
on-resume = busctl call org.freedesktop.login1 /org/freedesktop/login1/session/auto org.freedesktop.login1.Session SetIdleHint b false
}
On Linux the timer runs in the systemd user manager, which carries only the environment your session
gave it. A change needs WAYLAND_DISPLAY or DISPLAY, and under Hyprland its
HYPRLAND_INSTANCE_SIGNATURE. Without them a check is recorded as blocked and changes nothing. A
session that does not export them runs this at login:
systemctl --user import-environment WAYLAND_DISPLAY DISPLAY HYPRLAND_INSTANCE_SIGNATUREon writes ~/.config/systemd/user/font-schedule.service and .timer, or
~/Library/LaunchAgents/com.datapointchris.font-schedule.plist. Each check records its outcome in
schedule.json: waiting, away, blocked, applied or failed. A check counts as applied only when the
history holds the row the apply wrote, whatever the apply's exit status. status warns when no
check has run for three check intervals.
A scheduled change redraws open Kitty windows, and open Ghostty windows on Linux. On macOS Ghostty shows it after a config reload or in a new window, the same as a font applied by hand.
On WSL the timer needs systemd=true under [boot] in /etc/wsl.conf. A systemd unit there
cannot start Windows programs, so presence is read from the WSL terminals rather than from Windows,
and Windows Terminal's settings.json is found under /mnt/c/Users without asking Windows for the
username. WSL stops an idle distro once its last terminal closes, and the timer stops with it.
$XDG_STATE_HOME/font/ # ~/.local/state/font/ when XDG_STATE_HOME is unset
├── history.jsonl # the action log, one JSON record per line, merged from every machine
├── sync-state.json # gist id, last sync time, whether sync is on
├── schedule.json # the outcome of the last scheduled check
└── terminal-size.json # this machine's terminal size, which size-up and size-down move
A deleted history file is recreated empty by the next command. A font name recorded under an older spelling is read under the name the registry uses.
The curated families and their metadata live in the checkout at data/font-registry.json: a
description, creator, year, license and URL for each, and a digit_height for each family measured.
Only families marked managed appear in font list, the pickers, the rankings and
font install --check. font list prints them. After adding a family, install it and run
scripts/measure-digit-heights.py. It reads the 0 glyph's outline from the file fontconfig
resolves for each family, and leaves a family this machine lacks as it was.
FONT_ENV=development moves all of the state above into a gitignored .dev-data/ inside the
checkout and turns sync off, so working on font never touches real history. The repository's
.envrc sets it through direnv. task setup fills an empty .dev-data/ with a realistic fixture,
and scripts/seed-dev-data.sh regenerates it. task test runs the suite, and every test points
HOME at a temporary directory. task lint and task format check and apply the shell style, and
task install links this checkout's bin/font onto PATH in place of the release.