diff --git a/src/components/settings/AppearanceSection.tsx b/src/components/settings/AppearanceSection.tsx index fe8436b6..04972fe1 100644 --- a/src/components/settings/AppearanceSection.tsx +++ b/src/components/settings/AppearanceSection.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { EDITOR_THEMES, resolveEditorTheme, editorSurfaceTheme } from "@/lib/editorTheme"; import { Button } from "@/components/ui/Button"; +import { Checkbox } from "@/components/ui/Checkbox"; import { AuxTerminal } from "@/components/task/AuxTerminal"; import { homeDir } from "@/lib/ipc"; import { IS_MAC, ALT_LABEL, CMD_LABEL } from "@/lib/shortcuts"; @@ -37,6 +38,7 @@ export function AppearanceSection() { const setUiScale = usePrefs(s => s.setUiScale); const codeLigatures = usePrefs(s => s.codeLigatures); const setCodeLigatures = usePrefs(s => s.setCodeLigatures); + const showAllInstalledFonts = usePrefs(s => s.showAllInstalledFonts); const resetAppearance = usePrefs(s => s.resetAppearance); // Start with the curated subset so the picker is usable instantly, then @@ -46,9 +48,16 @@ export function AppearanceSection() { // native popup won't take options added while it's open. const [fonts, setFonts] = useState(() => withSelectedFonts(availableMonoFonts(), [editorFontId, terminalFontId])); + // Re-runs when the show-all toggle flips: the font lists are cached + // process-wide after the first enumeration, so the re-merge is instant. + // The cancelled flag matters during that first enumeration window — a + // flip mid-flight would otherwise race two calls, and the earlier + // mode's result could resolve last and win. useEffect(() => { - availableMonoFontsAsync() + let cancelled = false; + availableMonoFontsAsync(showAllInstalledFonts) .then(list => { + if (cancelled) return; // Read the selected ids at resolve time, not mount time: the async // list is a filtered SUBSET of the instant curated list, so a pick // made while font-kit was still enumerating would otherwise vanish @@ -57,7 +66,8 @@ export function AppearanceSection() { setFonts(withSelectedFonts(list, [editorFontId, terminalFontId])); }) .catch(() => {}); - }, []); + return () => { cancelled = true; }; + }, [showAllInstalledFonts]); // Disable the reset button when every appearance pref already matches // the factory defaults — nothing to undo. @@ -71,7 +81,8 @@ export function AppearanceSection() { terminalGpuEnabled === APPEARANCE_DEFAULTS.terminalGpuEnabled && editorFontSize === APPEARANCE_DEFAULTS.editorFontSize && uiScale === APPEARANCE_DEFAULTS.uiScale && - codeLigatures === APPEARANCE_DEFAULTS.codeLigatures; + codeLigatures === APPEARANCE_DEFAULTS.codeLigatures && + showAllInstalledFonts === APPEARANCE_DEFAULTS.showAllInstalledFonts; return (
@@ -82,7 +93,7 @@ export function AppearanceSection() { size="sm" disabled={atDefaults} onClick={resetAppearance} - title="Restore fonts, sizes, zoom, letter spacing and ligatures to their defaults." + title="Restore fonts, sizes, zoom, letter spacing, ligatures and font list filtering to their defaults." > Reset to defaults @@ -287,6 +298,11 @@ function FontSelect({ value, onChange, fonts }: { onChange: (id: string) => void; fonts: typeof MONO_FONT_OPTIONS; }) { + // One shared pref rendered on each picker (it widens the list both feed + // from), so the affordance sits with the control it affects instead of + // as a page-level toggle that looks tied to whichever picker it's near. + const showAll = usePrefs(s => s.showAllInstalledFonts); + const setShowAll = usePrefs(s => s.setShowAllInstalledFonts); // The bundled default is pinned first by sortFontOptions; give it its own // labeled group so it doesn't read as a sorting glitch above the A-Z list. const bundled = fonts.filter(f => f.id === BUNDLED_FONT_ID); @@ -295,14 +311,26 @@ function FontSelect({ value, onChange, fonts }: { ); return ( - +
+ + {/* Checkbox stops propagation on its own click, so the row's + onClick only fires for the text part — no double-toggle. */} +
setShowAll(!showAll)} + title="List every installed font family, not just fonts detected as monospace. Applies to both font pickers. Proportional fonts will misalign terminal output." + className="flex cursor-pointer select-none items-center gap-1.5 text-[12px] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]" + > + + Show all fonts +
+
); } diff --git a/src/store/prefs.test.ts b/src/store/prefs.test.ts index 9aa1976f..37756ca6 100644 --- a/src/store/prefs.test.ts +++ b/src/store/prefs.test.ts @@ -169,3 +169,83 @@ describe("prefs: mergeFontOptions", () => { expect(rest[0]).toBe("aardvark mono"); }); }); + +// "Show all installed fonts" (follow-up to the installed-only filter): a +// default-off escape hatch for monos that is_monospace() misses. Plain +// persisted boolean, reset with the rest of the Appearance page. +describe("prefs: showAllInstalledFonts", () => { + const LS = "showAllInstalledFonts"; + beforeEach(() => { + vi.stubGlobal("localStorage", fakeLocalStorage()); + vi.resetModules(); + }); + afterEach(() => { vi.unstubAllGlobals(); }); + + it("defaults to false with nothing in localStorage", async () => { + const { usePrefs } = await import("./prefs"); + expect(usePrefs.getState().showAllInstalledFonts).toBe(false); + }); + + it("picks up a persisted true value as the initial state on load", async () => { + localStorage.setItem(LS, "1"); + const { usePrefs } = await import("./prefs"); + expect(usePrefs.getState().showAllInstalledFonts).toBe(true); + }); + + it("setShowAllInstalledFonts updates state and persists it", async () => { + const { usePrefs } = await import("./prefs"); + usePrefs.getState().setShowAllInstalledFonts(true); + expect(usePrefs.getState().showAllInstalledFonts).toBe(true); + expect(localStorage.getItem(LS)).toBe("1"); + }); + + it("resetAppearance restores it to off", async () => { + const { usePrefs } = await import("./prefs"); + usePrefs.getState().setShowAllInstalledFonts(true); + usePrefs.getState().resetAppearance(); + expect(usePrefs.getState().showAllInstalledFonts).toBe(false); + expect(localStorage.getItem(LS)).toBe("0"); + }); +}); + +// availableMonoFontsAsync(showAll): showAll widens the system: extras source +// from the is_monospace() subset to the full family catalog. The curated +// installed-only filter is unchanged either way. IPC is mocked (importOriginal +// keeps the module's other exports real) so both toggle states can be observed +// against the same fake catalog, including after the lists are cached. +describe("prefs: availableMonoFontsAsync showAll", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", fakeLocalStorage()); + vi.resetModules(); + vi.doMock("@/lib/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + listFontFamilies: async () => ["Menlo", "Comic Sans MS"], + listMonospaceFonts: async () => ["Menlo"], + })); + }); + afterEach(() => { + vi.doUnmock("@/lib/ipc"); + vi.unstubAllGlobals(); + }); + + it("hides non-monospace families by default", async () => { + const { availableMonoFontsAsync } = await import("./prefs"); + const ids = (await availableMonoFontsAsync()).map(o => o.id); + expect(ids).not.toContain("system:Comic Sans MS"); + expect(ids).toContain("menlo"); + }); + + it("lists every installed family as a system: extra when showAll is on", async () => { + const { availableMonoFontsAsync } = await import("./prefs"); + // First call caches the enumerated lists; the showAll call must + // re-merge from those caches, not serve the filtered result. + await availableMonoFontsAsync(); + const ids = (await availableMonoFontsAsync(true)).map(o => o.id); + expect(ids).toContain("system:Comic Sans MS"); + // curated handling is untouched: Menlo stays curated (no system: + // duplicate), uninstalled curated entries stay hidden + expect(ids).toContain("menlo"); + expect(ids).not.toContain("system:Menlo"); + expect(ids).not.toContain("hack"); + }); +}); diff --git a/src/store/prefs.ts b/src/store/prefs.ts index ebd30403..3e3a9c49 100644 --- a/src/store/prefs.ts +++ b/src/store/prefs.ts @@ -54,6 +54,7 @@ const LS_SHORTCUTS = "shortcutBindings"; const LS_PANE_DIM = "splitPaneDim"; const LS_PANE_DIM_AMT = "splitPaneDimAmount"; const LS_UI_SCALE = "uiScale"; +const LS_SHOW_ALL_FONTS = "showAllInstalledFonts"; /** UI zoom bounds (percent). The whole webview is scaled via the CSS * `zoom` property, so these are browser-zoom-style limits. */ @@ -388,8 +389,13 @@ let _familiesCache: string[] | null = null; /** Returns the curated entries whose font is actually installed, MERGED with * every monospace font Rust finds via font-kit. Fonts not in the curated map * get an auto-generated entry (id = "system:", label = family name, - * stack = family). Sorted for display (bundled default first, then A→Z). */ -export async function availableMonoFontsAsync(): Promise { + * stack = family). Sorted for display (bundled default first, then A→Z). + * + * `showAll` widens the extras source from the is_monospace() subset to the + * full family catalog — the escape hatch for monos the detection misses + * (see PrefsState.showAllInstalledFonts). Curated entries are still + * installed-filtered either way; only the monospace wall moves. */ +export async function availableMonoFontsAsync(showAll = false): Promise { let system = _systemFontsCache; let families = _familiesCache; if (!system || !families) { @@ -408,7 +414,7 @@ export async function availableMonoFontsAsync(): Promise, !==, ...) in the editor. */ codeLigatures: boolean; + /** List EVERY installed font family in the font pickers, not just the + * is_monospace()-detected subset. OFF by default: the wall exists because + * proportional fonts break terminal column math, but font-kit's monospace + * detection misses some legitimate monos (unusual naming, missing OS/2 + * flags) and this is the escape hatch. */ + showAllInstalledFonts: boolean; /** How a task row's tab list (its "agents") expands in the sidebar: * - "chevron": only the chevron toggles. Row click just activates. * No auto-expand. Default — most predictable. @@ -585,6 +597,7 @@ interface PrefsState { /** Bump zoom by one step in either direction (for the Cmd +/- shortcuts). */ nudgeUiScale: (dir: 1 | -1) => void; setCodeLigatures: (v: boolean) => void; + setShowAllInstalledFonts: (v: boolean) => void; /** Restore every Appearance-section pref (fonts, sizes, weight, * letter-spacing, ligatures) to `APPEARANCE_DEFAULTS`. Theme is * left alone — it's not part of the Appearance page. */ @@ -678,6 +691,7 @@ export const APPEARANCE_DEFAULTS = { editorFontSize: 13, uiScale: 100, codeLigatures: true, + showAllInstalledFonts: false, } as const; const initialEditorFont = lsGet(LS_EDITOR_FONT, APPEARANCE_DEFAULTS.editorFontId); @@ -692,6 +706,7 @@ const initialTerminalCopyOnSelect = lsGetBool(LS_TERMINAL_COPY_ON_SELECT, true) const initialEditorSize = lsGetNum(LS_EDITOR_SIZE, APPEARANCE_DEFAULTS.editorFontSize); const initialUiScale = clampUiScale(lsGetNum(LS_UI_SCALE, APPEARANCE_DEFAULTS.uiScale)); const initialLigatures = lsGetBool(LS_LIGATURES, APPEARANCE_DEFAULTS.codeLigatures); +const initialShowAllFonts = lsGetBool(LS_SHOW_ALL_FONTS, APPEARANCE_DEFAULTS.showAllInstalledFonts); const initialTheme = parseThemeMode(lsGet(LS_THEME, "claude")); const initialDesktopNotif = lsGetBool(LS_DESKTOPNOTIF, false); const initialCompletionSound = readCompletionSoundEnabled(); @@ -760,6 +775,7 @@ export const usePrefs = create(set => ({ editorFontSize: initialEditorSize, uiScale: initialUiScale, codeLigatures: initialLigatures, + showAllInstalledFonts: initialShowAllFonts, taskExpandMode: initialTaskExpandMode, hideInactiveProjects: initialHideInactiveProjects, markdownDefaultView: initialMarkdownView, @@ -829,6 +845,10 @@ export const usePrefs = create(set => ({ try { localStorage.setItem(LS_LIGATURES, v ? "1" : "0"); } catch {} set({ codeLigatures: v }); }, + setShowAllInstalledFonts: (v) => { + try { localStorage.setItem(LS_SHOW_ALL_FONTS, v ? "1" : "0"); } catch {} + set({ showAllInstalledFonts: v }); + }, resetAppearance: () => { // Route through the individual setters so each one's side // effects fire (localStorage write, applyEditorFont, clamps). @@ -844,6 +864,7 @@ export const usePrefs = create(set => ({ s.setEditorFontSize(d.editorFontSize); s.setUiScale(d.uiScale); s.setCodeLigatures(d.codeLigatures); + s.setShowAllInstalledFonts(d.showAllInstalledFonts); }, setThemeMode: (m) => { // Picking a custom theme refreshes the first-paint cache so applyTheme