Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions src/components/settings/AppearanceSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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 (
<div className="flex flex-col gap-8">
Expand All @@ -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
</Button>
Expand Down Expand Up @@ -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);
Expand All @@ -295,14 +311,26 @@ function FontSelect({ value, onChange, fonts }: {
<option key={f.id} value={f.id} style={{ fontFamily: f.stack }}>{f.label}</option>
);
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-[13.5px] text-[var(--color-fg)] outline-none focus:border-[var(--color-accent)] min-w-[180px]"
>
<optgroup label="Bundled">{bundled.map(renderOption)}</optgroup>
<optgroup label="Installed">{installed.map(renderOption)}</optgroup>
</select>
<div className="flex flex-col items-end gap-1.5">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 text-[13.5px] text-[var(--color-fg)] outline-none focus:border-[var(--color-accent)] min-w-[180px]"
>
<optgroup label="Bundled">{bundled.map(renderOption)}</optgroup>
<optgroup label="Installed">{installed.map(renderOption)}</optgroup>
</select>
{/* Checkbox stops propagation on its own click, so the row's
onClick only fires for the text part — no double-toggle. */}
<div
onClick={() => 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)]"
>
<Checkbox checked={showAll} onChange={setShowAll} className="h-3.5 w-3.5" />
<span>Show all fonts</span>
</div>
</div>
);
}

Expand Down
80 changes: 80 additions & 0 deletions src/store/prefs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>()),
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");
});
});
27 changes: 24 additions & 3 deletions src/store/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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:<name>", label = family name,
* stack = family). Sorted for display (bundled default first, then A→Z). */
export async function availableMonoFontsAsync(): Promise<typeof MONO_FONT_OPTIONS> {
* 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<typeof MONO_FONT_OPTIONS> {
let system = _systemFontsCache;
let families = _familiesCache;
if (!system || !families) {
Expand All @@ -408,7 +414,7 @@ export async function availableMonoFontsAsync(): Promise<typeof MONO_FONT_OPTION
families = [];
}
}
return mergeFontOptions(MONO_FONT_OPTIONS, families, system);
return mergeFontOptions(MONO_FONT_OPTIONS, families, showAll ? families : system);
}

/** Resolve a font id → CSS font-family stack, defaulting to JetBrains.
Expand Down Expand Up @@ -534,6 +540,12 @@ interface PrefsState {
uiScale: number;
/** Enable font ligatures (=>, !==, ...) 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.
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -760,6 +775,7 @@ export const usePrefs = create<PrefsState>(set => ({
editorFontSize: initialEditorSize,
uiScale: initialUiScale,
codeLigatures: initialLigatures,
showAllInstalledFonts: initialShowAllFonts,
taskExpandMode: initialTaskExpandMode,
hideInactiveProjects: initialHideInactiveProjects,
markdownDefaultView: initialMarkdownView,
Expand Down Expand Up @@ -829,6 +845,10 @@ export const usePrefs = create<PrefsState>(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).
Expand All @@ -844,6 +864,7 @@ export const usePrefs = create<PrefsState>(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
Expand Down
Loading