1import { LegendList, type LegendListRef } from "@legendapp/list/react";
2import { CheckIcon } from "lucide-react";
3import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts";
5import {
6 Combobox,
7 ComboboxEmpty,
8 ComboboxSearchInput,
9 ComboboxItem,
10 ComboboxListVirtualized,
11 ComboboxPopup,
12 ComboboxTrigger,
13} from "../ui/combobox";
14import { SelectButton } from "../ui/select";
15import { stackedThreadToast, toastManager } from "../ui/toast";
16
17const DEFAULT_FONT_VALUE = "__default__";
18
19function supportsFontEnumeration(): boolean {
20 return (
21 typeof window !== "undefined" &&
22 typeof (window as { queryLocalFonts?: unknown }).queryLocalFonts === "function"
23 );
24}
25
26type FontEnumerationState =
27 | { readonly status: "unknown" }
28 | { readonly status: "granted"; readonly families: readonly string[] }
29 | { readonly status: "unavailable" };
30
31// Shared across every row: once one picker learns the fonts (or learns the
32// permission is blocked), the others follow without re-querying — and the
33// rows can swap to the plain-input control together.
34let enumerationState: FontEnumerationState = supportsFontEnumeration()
35 ? { status: "unknown" }
36 : { status: "unavailable" };
37const enumerationListeners = new Set<() => void>();
38
39function subscribeToEnumeration(listener: () => void): () => void {
40 enumerationListeners.add(listener);
41 return () => enumerationListeners.delete(listener);
42}
43
44function readEnumerationState(): FontEnumerationState {
45 return enumerationState;
46}
47
48let enumerationLoad: Promise<void> | null = null;
49
50/** Query installed fonts; call from a user gesture (the permission prompt needs one). */
51export function discoverInstalledFonts(): void {
52 if (enumerationState.status !== "unknown" || enumerationLoad !== null) return;
53 enumerationLoad = queryInstalledFontFamilies().then((result) => {
54 enumerationState =
55 result.status === "granted"
56 ? { status: "granted", families: result.families }
57 : { status: "unavailable" };
58 enumerationLoad = null;
59 for (const listener of enumerationListeners) listener();
60 });
61}
62
63let grantedProbeStarted = false;
64
65/**
66 * Discover eagerly when the permission is already granted, so the picker
67 * renders without waiting for a focus. Electron's default permission handler
68 * approves silently (it has no prompt UI), and a browser that granted once
69 * reports "granted" on later visits — in both, no user gesture is needed.
70 * "prompt" and "denied" states change nothing: the focus-driven flow stays,
71 * because raising the browser prompt still requires a gesture.
72 */
73function probeAlreadyGrantedPermission(): void {
74 if (grantedProbeStarted || enumerationState.status !== "unknown") return;
75 grantedProbeStarted = true;
76 const permissions = typeof navigator !== "undefined" ? navigator.permissions : undefined;
77 if (typeof permissions?.query !== "function") return;
78 permissions.query({ name: "local-fonts" as PermissionName }).then(
79 (status) => {
80 if (status.state === "granted") discoverInstalledFonts();
81 },
82 () => {
83 // The engine does not recognize the permission name; keep the
84 // focus-driven flow.
85 },
86 );
87}
88
89/**
90 * Whether the engine can list installed fonts (Local Font Access API —
91 * Chromium and Electron). "unknown" until discovery resolves the permission;
92 * rows render a plain family-name input until the state is known granted,
93 * then upgrade to the picker. Where the permission is already granted,
94 * discovery starts at mount and the picker appears without a focus.
95 */
96export function useFontEnumeration(): FontEnumerationState {
97 useEffect(probeAlreadyGrantedPermission, []);
98 return useSyncExternalStore(subscribeToEnumeration, readEnumerationState);
99}
100
101/**
102 * A searchable picker over every installed family, the way native editors
103 * list system fonts. The trigger always names the font in use: the committed
104 * family, or what the default stack resolves to on this machine.
105 */
106export function FontFamilyPicker({
107 ariaLabel,
108 defaultFamily,
109 selectedFamily,
110 requireMonospace = false,
111 initialOpen = false,
112 onSelect,
113}: {
114 ariaLabel: string;
115 /** What an unset preference renders as, e.g. "Menlo". */
116 defaultFamily: string;
117 /** Committed family name; empty string means the default is in use. */
118 selectedFamily: string;
119 requireMonospace?: boolean;
120 /** Open the popup on mount — set when the control upgrades under focus. */
121 initialOpen?: boolean;
122 onSelect: (family: string) => void;
123}) {
124 const [open, setOpen] = useState(false);
125 const [query, setQuery] = useState("");
126 // Open after mount rather than mounting open: a popup that first renders in
127 // its open state never receives Base UI's entrance style baseline, so the
128 // exit transition on close has no style delta, never fires transitionend,
129 // and the popup lingers on screen forever.
130 useEffect(() => {
131 if (initialOpen) setOpen(true);
132 // The prop is only meaningful at mount - the control just swapped in
133 // under an active focus - so later changes are deliberately ignored.
134 }, []);
135 const listRef = useRef<LegendListRef | null>(null);
136 const enumeration = useFontEnumeration();
137
138 const handleOpenChange = (nextOpen: boolean) => {
139 setOpen(nextOpen);
140 if (nextOpen) setQuery("");
141 };
142
143 const families = useMemo(() => {
144 if (enumeration.status !== "granted") return [];
145 return enumeration.families;
146 }, [enumeration]);
147
148 const items = useMemo(() => {
149 const trimmedQuery = query.trim().toLowerCase();
150 const result: string[] = [];
151 if (trimmedQuery.length === 0) result.push(DEFAULT_FONT_VALUE);
152 result.push(
153 ...families.filter(
154 (family) => trimmedQuery.length === 0 || family.toLowerCase().includes(trimmedQuery),
155 ),
156 );
157 return result;
158 }, [query, families]);
159
160 const selectedValue = selectedFamily.length === 0 ? DEFAULT_FONT_VALUE : selectedFamily;
161
162 const handlePick = (value: string) => {
163 setOpen(false);
164 if (value !== DEFAULT_FONT_VALUE && requireMonospace && !isMonospaceFamily(value)) {
165 toastManager.add(
166 stackedThreadToast({
167 type: "error",
168 title: `"${value}" isn't monospace`,
169 description: "Code and terminal need a fixed-width font, so the current font was kept.",
170 }),
171 );
172 return;
173 }
174 onSelect(value === DEFAULT_FONT_VALUE ? "" : value);
175 };
176
177 const renderItem = (item: string, index: number) => {
178 const isDefault = item === DEFAULT_FONT_VALUE;
179 const family = isDefault ? defaultFamily : item;
180 return (
181 <ComboboxItem hideIndicator index={index} key={item} value={item}>
182 <div className="flex w-full min-w-0 items-center justify-between gap-2">
183 <span className="min-w-0 truncate" style={{ fontFamily: family }}>
184 {family}
185 </span>
186 <span className="flex shrink-0 items-center gap-1.5">
187 {isDefault ? <span className="text-3xs text-muted-foreground/60">default</span> : null}
188 {item === selectedValue ? (
189 <CheckIcon className="size-3.5 text-muted-foreground" />
190 ) : null}
191 </span>
192 </div>
193 </ComboboxItem>
194 );
195 };
196
197 return (
198 <Combobox
199 items={items}
200 filteredItems={items}
201 autoHighlight
202 virtualized
203 open={open}
204 onOpenChange={handleOpenChange}
205 value={selectedValue}
206 onValueChange={(next) => {
207 if (typeof next === "string") handlePick(next);
208 }}
209 onItemHighlighted={(_value, eventDetails) => {
210 // Keyboard highlights must pull the virtualized row into view, or
211 // arrow keys walk past the rendered window and navigate blind.
212 if (!open || eventDetails.index < 0 || eventDetails.reason !== "keyboard") return;
213 void listRef.current?.scrollIndexIntoView?.({ index: eventDetails.index, animated: false });
214 }}
215 >
216 <ComboboxTrigger aria-label={ariaLabel} render={<SelectButton size="sm" />}>
217 {selectedFamily.length === 0 ? defaultFamily : selectedFamily}
218 </ComboboxTrigger>
219 <ComboboxPopup align="end" className="flex w-72 flex-col">
220 <ComboboxSearchInput
221 placeholder="Search fonts…"
222 value={query}
223 onChange={(event) => setQuery(event.target.value)}
224 />
225 <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
226 <ComboboxEmpty>No fonts found.</ComboboxEmpty>
227 <div className="relative min-h-0 max-h-72 w-full flex-1 overflow-hidden">
228 <ComboboxListVirtualized>
229 <LegendList<string>
230 ref={listRef}
231 data={items}
232 keyExtractor={(item) => item}
233 renderItem={({ item, index }) => renderItem(item, index)}
234 estimatedItemSize={30}
235 drawDistance={360}
236 style={{ height: Math.min(items.length * 30, 288) }}
237 />
238 </ComboboxListVirtualized>
239 </div>
240 </div>
241 </ComboboxPopup>
242 </Combobox>
243 );
244}