Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const clientSettings: ClientSettings = {
diffIgnoreWhitespace: true,
environmentIdentificationMode: "artwork",
favorites: [],
fontFamilyCode: "",
fontFamilyComposer: "",
fontFamilySans: "",
fontFamilyTerminal: "",
glassOpacity: 80,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/appearanceFonts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vite-plus/test";

import {
DEFAULT_CODE_FONT_STACK,
DEFAULT_SANS_FONT_STACK,
appearanceFontStack,
cssFontFamilies,
} from "./appearanceFonts";

describe("cssFontFamilies", () => {
it("returns null for effectively empty input", () => {
expect(cssFontFamilies("")).toBeNull();
expect(cssFontFamilies(" ")).toBeNull();
expect(cssFontFamilies(" , , ")).toBeNull();
});

it("quotes names with spaces and keeps single idents bare", () => {
expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"');
expect(cssFontFamilies("monospace")).toBe("monospace");
expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"');
});

it("normalizes comma-separated lists and strips embedded quotes", () => {
expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo');
expect(cssFontFamilies('Bad"Name')).toBe('"BadName"');
});
});

describe("appearanceFontStack", () => {
it("prepends the custom family to the default stack", () => {
expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe(
`"Fira Code", ${DEFAULT_CODE_FONT_STACK}`,
);
});

it("falls back to the default stack when unset", () => {
expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK);
});
});
175 changes: 175 additions & 0 deletions apps/web/src/appearanceFonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Font-family preferences from Settings → Appearance, applied as CSS custom
* properties. The default stacks mirror the `--font-sans` / `--font-mono`
* definitions in `index.css`; a custom family is always prepended to the
* matching default stack so glyph coverage never regresses.
*/

export const DEFAULT_SANS_FONT_STACK =
'"DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, ' +
"sans-serif";

export const DEFAULT_CODE_FONT_STACK =
'"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace';

function quoteFontFamilyName(name: string): string {
const bare = name.trim();
if (bare.length === 0) return "";
// Already quoted, or a single ident that needs no quoting.
if (/^(['"]).*\1$/.test(bare)) return bare;
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare;
return `"${bare.replaceAll('"', "")}"`;
}

/**
* Normalize a user-entered family (single name or comma-separated list) into a
* safe CSS font-family list, or null when the input is effectively empty.
*/
export function cssFontFamilies(input: string): string | null {
const families = input
.split(",")
.map(quoteFontFamilyName)
.filter((name) => name.length > 0);
return families.length > 0 ? families.join(", ") : null;
}

/** The full stack a preference resolves to: custom families before the default. */
export function appearanceFontStack(custom: string, defaultStack: string): string {
const families = cssFontFamilies(custom);
return families === null ? defaultStack : `${families}, ${defaultStack}`;
}

export interface AppearanceFontPreferences {
readonly sans: string;
readonly code: string;
readonly composer: string;
}

/**
* Apply the preferences to the root element. Unset preferences remove the
* override so the stylesheet defaults (and theme changes) stay in charge.
*/
export function applyAppearanceFontVariables(
root: HTMLElement,
preferences: AppearanceFontPreferences,
): void {
const assignments: ReadonlyArray<readonly [string, string | null, string]> = [
["--font-sans", cssFontFamilies(preferences.sans), DEFAULT_SANS_FONT_STACK],
["--font-mono", cssFontFamilies(preferences.code), DEFAULT_CODE_FONT_STACK],
// The composer falls back to whatever the sans preference resolves to.
["--font-composer", cssFontFamilies(preferences.composer), "var(--font-sans)"],
];
for (const [variable, families, defaultStack] of assignments) {
if (families === null) {
root.style.removeProperty(variable);
} else {
root.style.setProperty(variable, `${families}, ${defaultStack}`);
}
}
}

export type FontCategory = "Sans serif" | "Monospace";

export interface FontOption {
readonly label: string;
readonly family: string;
readonly category: FontCategory;
}

function fontCatalog(
category: FontCategory,
entries: ReadonlyArray<Omit<FontOption, "category">>,
): readonly FontOption[] {
return entries.map((entry) => ({ ...entry, category }));
}

/**
* Curated choices for the Appearance dropdowns. The settings UI filters these
* through `isFontFamilyAvailable`, so platforms only offer faces that will
* actually render; "Custom" in the UI covers everything else. The category
* groups mixed dropdowns (composer) into labeled sections.
*/
export const SANS_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Sans serif", [
{ label: "DM Sans", family: "DM Sans" },
{ label: "Inter", family: "Inter" },
{ label: "SF Pro", family: "SF Pro Text" },
{ label: "Segoe UI", family: "Segoe UI" },
{ label: "Roboto", family: "Roboto" },
{ label: "Helvetica Neue", family: "Helvetica Neue" },
{ label: "Arial", family: "Arial" },
{ label: "System UI", family: "system-ui" },
]);

export const MONO_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Monospace", [
{ label: "SF Mono", family: "SF Mono" },
{ label: "JetBrains Mono", family: "JetBrains Mono" },
{ label: "Fira Code", family: "Fira Code" },
{ label: "Cascadia Code", family: "Cascadia Code" },
{ label: "Menlo", family: "Menlo" },
{ label: "Monaco", family: "Monaco" },
{ label: "Consolas", family: "Consolas" },
{ label: "Source Code Pro", family: "Source Code Pro" },
{ label: "IBM Plex Mono", family: "IBM Plex Mono" },
{ label: "Ubuntu Mono", family: "Ubuntu Mono" },
{ label: "Courier New", family: "Courier New" },
]);

/** The options split into their labeled category sections, in catalog order. */
export function fontOptionCategories(
options: readonly FontOption[],
): ReadonlyArray<readonly [FontCategory, readonly FontOption[]]> {
const sections = new Map<FontCategory, FontOption[]>();
for (const option of options) {
const section = sections.get(option.category);
if (section === undefined) {
sections.set(option.category, [option]);
} else {
section.push(option);
}
}
return [...sections.entries()];
}

const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord";
let fontProbeContext: CanvasRenderingContext2D | null | undefined;

function probeWidth(fontList: string): number | null {
if (fontProbeContext === undefined) {
fontProbeContext = document.createElement("canvas").getContext("2d");
}
if (fontProbeContext === null) return null;
fontProbeContext.font = `16px ${fontList}`;
return fontProbeContext.measureText(FONT_PROBE_TEXT).width;
}

/**
* Canvas metric probing instead of document.fonts.check(): check() reports
* true for families that are not installed at all (nothing needs loading), so
* it cannot filter the dropdown. A family exists when falling back to at
* least one generic changes the measured advance.
*/
export function isFontFamilyAvailable(family: string): boolean {
const families = cssFontFamilies(family);
if (families === null) return false;
if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true;
try {
for (const generic of ["monospace", "serif", "sans-serif"]) {
const baseline = probeWidth(generic);
const candidate = probeWidth(`${families}, ${generic}`);
if (baseline === null || candidate === null) return false;
if (candidate !== baseline) return true;
}
return false;
} catch {
return false;
}
}

/** Webfonts the app bundles; offered even before document.fonts has loaded them. */
const BUNDLED_FAMILIES = new Set(["DM Sans", "JetBrains Mono"]);

export function availableFontOptions(options: readonly FontOption[]): readonly FontOption[] {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return options.filter(
(option) => BUNDLED_FAMILIES.has(option.family) || isFontFamilyAvailable(option.family),
);
}
2 changes: 1 addition & 1 deletion apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1747,7 +1747,7 @@ function ComposerPromptEditorInner({

return (
<ComposerTerminalContextActionsContext value={terminalContextActions}>
<div className="relative">
<div className="composer-editor-surface relative">
<PlainTextPlugin
contentEditable={
<ContentEditable
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
type ThreadTerminalGroup,
} from "../types";
import { readLocalApi } from "~/localApi";
import { useClientSettings } from "../hooks/useSettings";
import { useAttachedTerminalSession } from "../state/terminalSessions";
import { serverEnvironment } from "../state/server";
import { previewEnvironment } from "../state/preview";
Expand Down Expand Up @@ -305,6 +306,8 @@ export function TerminalViewport({
onAddTerminalContext(selection);
});
const readTerminalLabel = useEffectEvent(() => terminalLabel);
const terminalFontFamily = useClientSettings((settings) => settings.fontFamilyTerminal);
const terminalFontFamilyRef = useRef(terminalFontFamily);
const terminalSession = useAttachedTerminalSession({
environmentId,
terminal: {
Expand Down Expand Up @@ -367,6 +370,13 @@ export function TerminalViewport({
keybindingsRef.current = keybindings;
}, [keybindings]);

useEffect(() => {
if (terminalFontFamilyRef.current === terminalFontFamily) return;
terminalFontFamilyRef.current = terminalFontFamily;
const family = terminalFontFamily.trim();
void terminalRef.current?.setFont(family.length > 0 ? { family } : {});
}, [terminalFontFamily]);

useEffect(() => {
const mount = containerRef.current;
if (!mount) return;
Expand All @@ -378,8 +388,10 @@ export function TerminalViewport({
let setupCleanups: Array<() => void> = [];

const setup = async (): Promise<(() => void) | null> => {
const setupFontFamily = terminalFontFamilyRef.current;
const terminalOptions: GhosttyTerminalSurfaceOptions = {
theme: terminalThemeFromApp(mount),
...(setupFontFamily.length > 0 ? { font: { family: setupFontFamily } } : {}),
onData: (data) => handleData(data),
onResize: (cols, rows) => void resizeTerminal(cols, rows),
onSelectionChange: () => handleSelectionChange(),
Expand All @@ -397,6 +409,13 @@ export function TerminalViewport({
terminal.setTheme(terminalThemeFromApp(mount));
setupTerminal = terminal;
terminalRef.current = terminal;
// Client settings hydrate asynchronously; a font preference that landed
// while the surface was loading found terminalRef null, so its setFont
// was dropped. Re-apply whatever is current once the terminal exists.
if (terminalFontFamilyRef.current !== setupFontFamily) {
const family = terminalFontFamilyRef.current.trim();
void terminal.setFont(family.length > 0 ? { family } : {});
}
const latestSession = latestSessionRef.current;
previousSessionRef.current = latestSession;
if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer);
Expand Down
Loading
Loading