Skip to content

feat(theme): pick terminal and UI fonts from the installed families (#196) - #201

Merged
kipavy merged 2 commits into
devfrom
feat/system-font-picker
Sep 1, 2026
Merged

feat(theme): pick terminal and UI fonts from the installed families (#196)#201
kipavy merged 2 commits into
devfrom
feat/system-font-picker

Conversation

@kipavy

@kipavy kipavy commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Follows #199. Together they close #196.

Why

The font pickers offered two bundled presets and a free-text box. A family typed into that box was accepted whether or not it existed on the machine, and an unresolvable family silently falls through to the engine's proportional default — cells measured from a serif face, letters painted in it. That is exactly what #196 looks like, and nothing in the UI ever said the font had not been found.

#199 makes that failure degrade to a monospace face instead of a proportional one. This PR stops it happening in the first place.

What

Backend. A new list_system_fonts command enumerates installed families with fontdb. It scans the OS font directories in pure Rust, so it needs no fontconfig, CoreText or DirectWrite linkage and stays out of the cross-compile images' way. Faces are deduplicated into one row per family, reported under the English name record because that is what CSS matches on, and flagged monospace if any face in the family claims fixed pitch.

Frontend. The picker keeps the bundled presets pinned at the top, then lists the installed families with a search box, each row previewed in its own face. The terminal picker filters to monospaced families, with a Show all fonts toggle — some fonts misreport fixed pitch, and Nerd Fonts' non-Mono variants legitimately report proportional. Picking a family stores 'Family', monospace (or sans-serif for the UI picker), so the generic tail from #199 is present from the start. The free-text box stays as an escape hatch and now warns when the family it holds is not installed.

Bundled webfonts (JetBrains Mono, Source Code Pro) ship inside the app rather than being installed, so fontdb cannot see them — they stay pinned as presets and are never reported missing.

Strings added to all four locales (en/fr/ru/zh); the locale diffs are additive only.

Verification

Driven live in the headless container with the Meslo Nerd Fonts installed:

step result
Picker open Presets on top, then installed families, each row previewed in its own font
Monospace filter Only DejaVu Sans Mono and the Meslo Mono variants; DejaVu Sans/Serif and the non-Mono Meslo variants hidden
Show all fonts Proportional families appear; toggle flips to "Show monospace fonts only"
Pick MesloLGS Nerd Font Mono Stored as 'MesloLGS Nerd Font Mono', monospace
Custom MesloLGS NF Warns: "MesloLGS NF" isn't installed — the terminal will fall back to another font

That last case is the likely shape of the original report: powerlevel10k's installer ships the family as MesloLGS NF, not the Nerd Fonts v3 name MesloLGS Nerd Font Mono.

  • tsc --noEmit clean.
  • Frontend suite green: 519 files, 3977 tests.
  • Rust --lib green: 349 passed, 0 failed. (A fresh worktree fails 3 commands::plugins tests until pnpm build:plugins has run — unrelated to this change.)
  • cargo fmt --check clean.

Notes for review

  • The monospaced flag comes from post.isFixedPitch and PANOSE, which is why the escape hatch exists rather than a hard filter.
  • Enumeration parses every font on disk, so the result is cached for the session and resolved off the UI thread; a failure yields an empty list and the picker still offers presets plus free text.

…196)

The font pickers offered two bundled presets and a free-text box. A family
typed into that box was accepted whether or not it existed, and an unresolvable
name silently falls through to a proportional default — cells measured from a
serif face, letters painted in it. That is what #196 looks like, and nothing in
the UI said the font had not been found.

A new `list_system_fonts` command enumerates installed families with `fontdb`,
which scans the OS font directories in pure Rust and so needs no fontconfig,
CoreText or DirectWrite linkage. Families are deduplicated across faces and
reported under their English name, which is what CSS matches on.

The picker now lists those families, previews each in its own face, and filters
the terminal one to `monospaced` faces with a "show all fonts" escape hatch —
some fonts misreport fixed pitch, and Nerd Fonts' non-Mono variants report
proportional. Picking one stores `'Family', monospace`, so the generic tail is
there even when the family later fails to resolve. The free-text box stays, and
now warns when the family it holds is not installed.

Bundled webfonts (JetBrains Mono, Source Code Pro) ship inside the app rather
than being installed, so they stay pinned as presets and are never reported
missing.
@kipavy
kipavy merged commit f102954 into dev Sep 1, 2026
4 checks passed
kipavy added a commit that referenced this pull request Sep 1, 2026
…203)

The font picker added in #201 came with its own trigger, rows, dividers, footer
actions and search box, so the one dropdown next to the appearance settings looked
unlike every other dropdown in the app: a smaller trigger whose chevron swapped
glyphs instead of rotating, rows tinted with accent-12% instead of carrying an
accent label, and its own radius, paddings and shadow.

It is now assembled from PickerTrigger, PickerSurface, PickerOption, PickerDivider
and PickerFooterAction, the same parts as the encoding, key, identity and folder
pickers. That also gives it the surface's behaviour, which the in-flow absolute
menu could not have: the list is a portalled float that flips above the trigger
when the theme panel is scrolled near the bottom, and a bottom sheet on Android.

PickerTrigger and PickerOption gain an optional labelFont so a row can preview
itself in the face it names, and PickerOption's icon becomes optional for rows
whose label is the whole content. The in-surface filter box, until now copied
between the distro icon picker and the host command field, is extracted as
PickerSearch and used by all three.

A nested fixed-height scroller inside PickerSurface fought the surface's own
320px cap — two scrollbars, and a row clipped mid-glyph. The surface is the only
scroller now, with the filter pinned sticky to its top.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant