Open History to see the last commit
1import type { ReactNode } from "react";
2import { useEffect, useEffectEvent, useRef, useState } from "react";
3import {
4 DEFAULT_UNIFIED_SETTINGS,
5 MAX_CODE_FONT_SIZE,
6 MAX_INTERFACE_FONT_SIZE,
7 MAX_PROMPT_FONT_SIZE,
8 MAX_TERMINAL_FONT_SIZE,
9 MIN_CODE_FONT_SIZE,
10 MIN_INTERFACE_FONT_SIZE,
11 MIN_PROMPT_FONT_SIZE,
12 MIN_TERMINAL_FONT_SIZE,
13} from "@t3tools/contracts/settings";
14import * as Schema from "effect/Schema";
15import { useLocalStorage } from "../../hooks/useLocalStorage";
16import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
17import { isMacPlatform } from "../../lib/utils";
18import { Input } from "../ui/input";
19import {
20 DEFAULT_CODE_FONT_STACK,
21 DEFAULT_SANS_FONT_STACK,
22 isFontFamilyAvailable,
23 isMonospaceFamily,
24 resolveDefaultFamilyLabel,
25 resolveTerminalFontPreference,
26 resolveTerminalFontSizePreference,
27 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
28} from "../../appearanceFonts";
29import { AdvancedSettings } from "./AdvancedSettings";
30import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
31import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
32import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
33import { Switch } from "../ui/switch";
34import {
35 SettingResetButton,
36 SettingsRow,
37 SettingsSection,
38 useSettingsSearchTargetId,
39} from "./settingsLayout";
40import {
41 isAdvancedTypographyTarget,
42 revealSettingsSearchTarget,
43 searchableSetting,
44} from "./settingsSearch";
45
46let fontDefaultFamilies: { readonly sans: string; readonly code: string } | undefined;
47
48function resolveFontDefaultFamilies() {
49 if (fontDefaultFamilies !== undefined) return fontDefaultFamilies;
50 const families = {
51 sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default",
52 code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace",
53 };
54 if (document.fonts.status === "loaded") fontDefaultFamilies = families;
55 return families;
56}
57
58function useFontDefaultFamilies() {
59 const settings = useScopedSettings();
60 const [defaults] = useState(resolveFontDefaultFamilies);
61 return {
62 sans: defaults.sans,
63 code: defaults.code,
64 interfaceFamily: settings.fontFamilySans.trim() || defaults.sans,
65 };
66}
67
68function InterfaceFontRow({ preview }: { preview?: ReactNode }) {
69 const settings = useScopedSettings();
70 const updateSettings = useUpdateScopedSettings();
71 const defaults = useFontDefaultFamilies();
72 return (
73 <FontFamilySettingsRow
74 {...searchableSetting("interface-font")}
75 description="Everything outside code blocks and the terminal."
76 defaultFamily={defaults.sans}
77 defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilySans}
78 value={settings.fontFamilySans}
79 onValueChange={(fontFamilySans) => updateSettings({ fontFamilySans })}
80 onReset={() =>
81 updateSettings({
82 fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
83 fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface,
84 })
85 }
86 size={{
87 label: "Interface font size",
88 min: MIN_INTERFACE_FONT_SIZE,
89 max: MAX_INTERFACE_FONT_SIZE,
90 value: settings.fontSizeInterface,
91 defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface,
92 onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }),
93 }}
94 {...(preview !== undefined ? { preview } : {})}
95 />
96 );
97}
98
99function PromptFontRow() {
100 const settings = useScopedSettings();
101 const updateSettings = useUpdateScopedSettings();
102 const defaults = useFontDefaultFamilies();
103 return (
104 <FontFamilySettingsRow
105 {...searchableSetting("prompt-font")}
106 description="Only the box you write prompts in. Mono works well here."
107 defaultFamily={defaults.interfaceFamily}
108 defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer}
109 value={settings.fontFamilyComposer}
110 onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })}
111 onReset={() =>
112 updateSettings({
113 fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,
114 fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
115 })
116 }
117 size={{
118 label: "Prompt font size",
119 min: MIN_PROMPT_FONT_SIZE,
120 max: MAX_PROMPT_FONT_SIZE,
121 value: settings.fontSizePrompt,
122 defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
123 onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }),
124 }}
125 preview={<PromptFontPreview />}
126 />
127 );
128}
129
130function CodeFontRow({
131 title,
132 description = "Code blocks, diffs, and file previews.",
133 preview,
134}: {
135 title?: string;
136 description?: string;
137 preview?: ReactNode;
138}) {
139 const settings = useScopedSettings();
140 const updateSettings = useUpdateScopedSettings();
141 const defaults = useFontDefaultFamilies();
142 return (
143 <FontFamilySettingsRow
144 {...searchableSetting("code-font")}
145 {...(title !== undefined ? { title } : {})}
146 description={description}
147 defaultFamily={defaults.code}
148 defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyCode}
149 value={settings.fontFamilyCode}
150 onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })}
151 onReset={() =>
152 updateSettings({
153 fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode,
154 fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
155 })
156 }
157 requireMonospace
158 size={{
159 label: "Code font size",
160 min: MIN_CODE_FONT_SIZE,
161 max: MAX_CODE_FONT_SIZE,
162 value: settings.fontSizeCode,
163 defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
164 onChange: (fontSizeCode) => updateSettings({ fontSizeCode }),
165 }}
166 preview={preview ?? <CodeFontPreview />}
167 />
168 );
169}
170
171function TerminalFontRow() {
172 const settings = useScopedSettings();
173 const updateSettings = useUpdateScopedSettings();
174 const defaults = useFontDefaultFamilies();
175 return (
176 <FontFamilySettingsRow
177 {...searchableSetting("terminal-font")}
178 description="Terminal output, independent from code blocks and diffs."
179 defaultFamily={defaults.code}
180 defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal}
181 value={settings.fontFamilyTerminal}
182 onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
183 onReset={() =>
184 updateSettings({
185 fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal,
186 fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,
187 })
188 }
189 requireMonospace
190 size={{
191 label: "Terminal font size",
192 min: MIN_TERMINAL_FONT_SIZE,
193 max: MAX_TERMINAL_FONT_SIZE,
194 value: settings.fontSizeTerminal,
195 defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,
196 onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }),
197 }}
198 preview={
199 <TerminalFontPreview
200 family={resolveTerminalFontPreference({
201 advanced: true,
202 code: settings.fontFamilyCode,
203 terminal: settings.fontFamilyTerminal,
204 })}
205 size={settings.fontSizeTerminal}
206 />
207 }
208 />
209 );
210}
211
212function FontSmoothingRow() {
213 const settings = useScopedSettings();
214 const updateSettings = useUpdateScopedSettings();
215 if (!isMacPlatform(navigator.platform)) return null;
216 return (
217 <SettingsRow
218 {...searchableSetting("font-smoothing")}
219 description="Use thinner grayscale text smoothing instead of the macOS default."
220 resetAction={
221 settings.fontSmoothing !== DEFAULT_UNIFIED_SETTINGS.fontSmoothing ? (
222 <SettingResetButton
223 label="font smoothing"
224 onClick={() =>
225 updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing })
226 }
227 />
228 ) : null
229 }
230 control={
231 <Switch
232 checked={settings.fontSmoothing}
233 onCheckedChange={(checked) => updateSettings({ fontSmoothing: Boolean(checked) })}
234 aria-label="Font smoothing"
235 />
236 }
237 />
238 );
239}
240
241function WordWrapRow() {
242 const settings = useScopedSettings();
243 const updateSettings = useUpdateScopedSettings();
244 return (
245 <SettingsRow
246 {...searchableSetting("word-wrap")}
247 description="Wrap long lines in code blocks, diffs, and file previews by default."
248 resetAction={
249 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (
250 <SettingResetButton
251 label="word wrapping"
252 onClick={() => updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })}
253 />
254 ) : null
255 }
256 control={
257 <Switch
258 checked={settings.wordWrap}
259 onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })}
260 aria-label="Wrap code, diffs, and file previews by default"
261 />
262 }
263 />
264 );
265}
266
267function FontSettingsGroup() {
268 return (
269 <>
270 <InterfaceFontRow />
271 <PromptFontRow />
272 <CodeFontRow />
273 <TerminalFontRow />
274 </>
275 );
276}
277
278function SeparateFontsRow({
279 checked,
280 onCheckedChange,
281}: {
282 readonly checked: boolean;
283 readonly onCheckedChange: (checked: boolean) => void;
284}) {
285 return (
286 <SettingsRow
287 {...searchableSetting("separate-fonts")}
288 description="Set the prompt, code, and terminal fonts separately instead of one monospace font."
289 control={
290 <Switch
291 checked={checked}
292 onCheckedChange={(next) => onCheckedChange(Boolean(next))}
293 aria-label="Separate fonts"
294 />
295 }
296 />
297 );
298}
299
300function SimpleFontRows() {
301 const settings = useScopedSettings();
302 return (
303 <>
304 <InterfaceFontRow preview={<PromptFontPreview />} />
305 <CodeFontRow
306 title="Monospace font"
307 description="Code blocks, diffs, file previews, and the terminal."
308 preview={
309 <>
310 <CodeFontPreview />
311 <TerminalFontPreview
312 family={resolveTerminalFontPreference({
313 advanced: false,
314 code: settings.fontFamilyCode,
315 terminal: settings.fontFamilyTerminal,
316 })}
317 size={resolveTerminalFontSizePreference({
318 advanced: false,
319 code: settings.fontSizeCode,
320 terminal: settings.fontSizeTerminal,
321 })}
322 />
323 </>
324 }
325 />
326 </>
327 );
328}
329
330export function TypographySection() {
331 const [advanced, setAdvanced] = useLocalStorage(
332 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
333 false,
334 Schema.Boolean,
335 );
336 // A deep link or history step must persist the flag too: the terminal reads it, so advanced
337 // rows without it would make terminal font edits do nothing. The rows follow the target
338 // directly so its row mounts in the first commit; the target clears once handled.
339 const searchTargetId = useSettingsSearchTargetId();
340 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the advanced typography flag to localStorage when a search target needs it
341 useEffect(() => {
342 if (searchTargetId !== null) revealSettingsSearchTarget(searchTargetId);
343 }, [searchTargetId]);
344 const showAdvanced =
345 advanced || (searchTargetId !== null && isAdvancedTypographyTarget(searchTargetId));
346 return (
347 <SettingsSection id="typography" title="Typography">
348 {showAdvanced ? <FontSettingsGroup /> : <SimpleFontRows />}
349 <WordWrapRow />
350 <AdvancedSettings
351 targetIds={[searchableSetting("separate-fonts").id, searchableSetting("font-smoothing").id]}
352 >
353 <SeparateFontsRow checked={showAdvanced} onCheckedChange={setAdvanced} />
354 <FontSmoothingRow />
355 </AdvancedSettings>
356 </SettingsSection>
357 );
358}
359
360function FontFamilySettingsRow({
361 id,
362 title,
363 description,
364 defaultFamily,
365 defaultValue,
366 preview,
367 value,
368 onValueChange,
369 onReset,
370 requireMonospace = false,
371 size,
372}: {
373 id?: string;
374 title: string;
375 description: string;
376 defaultFamily: string;
377 defaultValue: string;
378 preview?: ReactNode;
379 value: string;
380 onValueChange: (value: string) => void;
381 onReset: () => void;
382 requireMonospace?: boolean;
383 size: {
384 label: string;
385 min: number;
386 max: number;
387 value: number;
388 defaultValue: number;
389 onChange: (v: number) => void;
390 };
391}) {
392 const trimmed = value.trim();
393 const [draft, setDraft] = useState(value);
394 const [draftSettled, setDraftSettled] = useState(true);
395 // Each pending commit remembers the value it was typed against, so one that outlives
396 // an external change (hydration, reset, picker selection) drops its stale draft.
397 const commitTimerRef = useRef<{ readonly id: number; readonly value: string } | null>(null);
398 const cancelPendingCommit = () => {
399 if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current.id);
400 commitTimerRef.current = null;
401 };
402 // Bumped on every external value change, so a pending commit typed before one never lands,
403 // even if the value has since changed back to what it was typed against.
404 const [adopted, setAdopted] = useState({ value, generation: 0 });
405 if (adopted.value !== value) {
406 setAdopted({ value, generation: adopted.generation + 1 });
407 setDraft(value);
408 setDraftSettled(true);
409 }
410 const readAdoptedGeneration = useEffectEvent(() => adopted.generation);
411 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears the pending commit timer on unmount
412 useEffect(
413 () => () => {
414 if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current.id);
415 },
416 [],
417 );
418 const acceptsFamily = (candidate: string) =>
419 isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate));
420 const commitDraft = (next: string) => {
421 setDraftSettled(true);
422 if (next.trim().length === 0 || acceptsFamily(next)) {
423 onValueChange(next);
424 }
425 };
426 const flushDraft = () => {
427 const pending = commitTimerRef.current;
428 cancelPendingCommit();
429 if (pending !== null && pending.value === value) commitDraft(draft);
430 };
431 const draftTrimmed = draft.trim();
432 const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed;
433 const resetToDefault = () => {
434 cancelPendingCommit();
435 setDraft(defaultValue);
436 setDraftSettled(true);
437 onReset();
438 };
439 const resetAction =
440 value !== defaultValue || size.value !== size.defaultValue ? (
441 <SettingResetButton label={title.toLowerCase()} onClick={resetToDefault} />
442 ) : null;
443 const fontEnumeration = useFontEnumeration();
444 const [inputFocused, setInputFocused] = useState(false);
445 const familyControl =
446 fontEnumeration.status === "granted" ? (
447 <FontFamilyPicker
448 ariaLabel={`${title} family`}
449 defaultFamily={defaultFamily}
450 selectedFamily={trimmed}
451 requireMonospace={requireMonospace}
452 initialOpen={inputFocused}
453 onSelect={onValueChange}
454 />
455 ) : (
456 <Input
457 size="sm"
458 aria-label={`${title} family`}
459 aria-invalid={draftPending || undefined}
460 autoCapitalize="off"
461 autoComplete="off"
462 className="min-w-0 flex-1"
463 maxLength={200}
464 onFocus={() => {
465 setInputFocused(true);
466 discoverInstalledFonts();
467 }}
468 onBlur={() => {
469 setInputFocused(false);
470 flushDraft();
471 }}
472 onChange={(event) => {
473 const next = event.currentTarget.value;
474 setDraft(next);
475 setDraftSettled(false);
476 cancelPendingCommit();
477 const typedAgainst = value;
478 const typedInGeneration = adopted.generation;
479 commitTimerRef.current = {
480 id: window.setTimeout(() => {
481 commitTimerRef.current = null;
482 if (readAdoptedGeneration() === typedInGeneration) commitDraft(next);
483 }, 400),
484 value: typedAgainst,
485 };
486 }}
487 onKeyDown={(event) => {
488 if (event.key === "Enter") flushDraft();
489 if (event.key === "Escape") {
490 // Discard uncommitted typing without closing the settings page,
491 // which is what an unhandled Escape does.
492 event.preventDefault();
493 event.stopPropagation();
494 cancelPendingCommit();
495 setDraft(value);
496 setDraftSettled(true);
497 }
498 }}
499 placeholder={defaultFamily}
500 spellCheck={false}
501 value={draft}
502 />
503 );
504 const control = (
505 <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto @min-[32rem]/settings-row:flex-nowrap">
506 <div className="min-w-36 flex-1 sm:w-44 sm:flex-none">{familyControl}</div>
507 <Select
508 value={String(size.value)}
509 onValueChange={(next) => {
510 if (typeof next !== "string") return;
511 const parsed = Number(next);
512 if (Number.isInteger(parsed) && parsed >= size.min && parsed <= size.max) {
513 size.onChange(parsed);
514 }
515 }}
516 >
517 <SelectTrigger size="sm" className="w-22 shrink-0" aria-label={size.label}>
518 <SelectValue>{size.value} px</SelectValue>
519 </SelectTrigger>
520 <SelectPopup align="end" alignItemWithTrigger={false}>
521 {Array.from({ length: size.max - size.min + 1 }, (_, index) => size.min + index).map(
522 (px) => (
523 <SelectItem hideIndicator key={px} value={String(px)}>
524 {px} px
525 </SelectItem>
526 ),
527 )}
528 </SelectPopup>
529 </Select>
530 </div>
531 );
532 return (
533 <SettingsRow
534 {...(id !== undefined ? { id } : {})}
535 title={title}
536 description={description}
537 resetAction={resetAction}
538 control={control}
539 >
540 {preview}
541 </SettingsRow>
542 );
543}