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
43 changes: 43 additions & 0 deletions apps/web/src/appearanceFonts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";

import {
areFontAdvancesMonospace,
clampCodeFontSize,
clampInterfaceFontSize,
clampPromptFontSize,
Expand All @@ -9,8 +10,22 @@ import {
appearanceFontStack,
cssFontFamilies,
resolveDefaultFamilyLabel,
resolveTerminalFontPreference,
} from "./appearanceFonts";

describe("areFontAdvancesMonospace", () => {
it("accepts a fixed advance and rejects any proportional glyph", () => {
expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true);
expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false);
expect(areFontAdvancesMonospace([10, 10.02])).toBe(false);
});

it("fails open when canvas metrics are unavailable", () => {
expect(areFontAdvancesMonospace([])).toBe(true);
expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true);
});
});

describe("cssFontFamilies", () => {
it("returns null for effectively empty input", () => {
expect(cssFontFamilies("")).toBeNull();
Expand Down Expand Up @@ -54,6 +69,34 @@ describe("appearanceFontStack", () => {
});
});

describe("resolveTerminalFontPreference", () => {
it("inherits the code font in simple mode", () => {
expect(
resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }),
).toBe("Fira Code");
expect(
resolveTerminalFontPreference({
advanced: false,
code: "Fira Code",
terminal: "Berkeley Mono",
}),
).toBe("Fira Code");
});

it("keeps code and terminal fonts independent in advanced mode", () => {
expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe(
"",
);
expect(
resolveTerminalFontPreference({
advanced: true,
code: "Fira Code",
terminal: "Berkeley Mono",
}),
).toBe("Berkeley Mono");
});
});

describe("font size clamping", () => {
it("keeps sizes inside the ranges the UI can absorb", () => {
expect(clampInterfaceFontSize(16)).toBe(16);
Expand Down
44 changes: 39 additions & 5 deletions apps/web/src/appearanceFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK =
export const DEFAULT_CODE_FONT_STACK =
'"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace';

export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced";

/**
* Simple typography treats the terminal as another monospace surface. In
* Advanced mode an empty terminal preference means the terminal default,
* keeping later code-font changes isolated to code surfaces.
*/
export function resolveTerminalFontPreference(input: {
readonly advanced: boolean;
readonly code: string;
readonly terminal: string;
}): string {
if (input.advanced) return input.terminal;
return input.code;
}

function quoteFontFamilyName(name: string): string {
const bare = name.trim();
if (bare.length === 0) return "";
Expand Down Expand Up @@ -165,6 +181,22 @@ export function isFontFamilyAvailable(family: string): boolean {
}
}

const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const;
const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const;
const MONOSPACE_ADVANCE_TOLERANCE = 0.01;

export function areFontAdvancesMonospace(advances: readonly number[]): boolean {
const reference = advances[0];
if (
reference === undefined ||
reference <= 0 ||
advances.some((advance) => !Number.isFinite(advance) || advance <= 0)
) {
return true;
}
return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE);
}

/**
* Whether a family renders every character on the same advance. Cell-grid
* surfaces (the terminal) require this: a proportional face draws its text
Expand All @@ -182,13 +214,15 @@ export function isMonospaceFamily(family: string): boolean {
fontProbeContext = document.createElement("canvas").getContext("2d");
}
if (fontProbeContext === null) return true;
const context = fontProbeContext;
// Fall back to a generic mono so an absent face measures as monospace and
// is left for the normal fallback chain to resolve.
fontProbeContext.font = `32px ${families}, monospace`;
const narrow = fontProbeContext.measureText("i").width;
const wide = fontProbeContext.measureText("M").width;
if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true;
return Math.abs(wide - narrow) < 0.5;
for (const variant of MONOSPACE_PROBE_VARIANTS) {
context.font = `${variant} 32px ${families}, monospace`;
const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width);
if (!areFontAdvancesMonospace(advances)) return false;
}
return true;
} catch {
return true;
}
Expand Down
22 changes: 18 additions & 4 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type ThreadId,
} from "@t3tools/contracts";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
import * as Schema from "effect/Schema";
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
Expand Down Expand Up @@ -57,13 +58,15 @@ import {
} from "../types";
import { readLocalApi } from "~/localApi";
import { useClientSettings } from "../hooks/useSettings";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { useAttachedTerminalSession } from "../state/terminalSessions";
import { serverEnvironment } from "../state/server";
import { previewEnvironment } from "../state/preview";
import { terminalEnvironment } from "../state/terminal";
import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview";
import { useAtomCommand } from "../state/use-atom-command";
import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts";

const MIN_DRAWER_HEIGHT = 180;
const MAX_DRAWER_HEIGHT_RATIO = 0.75;
Expand Down Expand Up @@ -241,6 +244,7 @@ export function shouldHandleTerminalExit(
}

interface TerminalViewportProps {
advancedTypography: boolean;
threadRef: ScopedThreadRef;
threadId: ThreadId;
terminalId: string;
Expand All @@ -264,6 +268,7 @@ interface TerminalLaunchLocation {
}

export function TerminalViewport({
advancedTypography,
threadRef,
threadId,
terminalId,
Expand Down Expand Up @@ -312,10 +317,12 @@ export function TerminalViewport({
onAddTerminalContext(selection);
});
const readTerminalLabel = useEffectEvent(() => terminalLabel);
// The terminal inherits the monospace (code) preference unless it has an
// override of its own, so one font choice drives every mono surface.
const terminalFontFamily = useClientSettings(
(settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode,
const terminalFontFamily = useClientSettings((settings) =>
resolveTerminalFontPreference({
advanced: advancedTypography,
code: settings.fontFamilyCode,
terminal: settings.fontFamilyTerminal,
}),
);
const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal);
const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize });
Expand Down Expand Up @@ -921,6 +928,11 @@ export default function ThreadTerminalDrawer({
terminalLaunchLocationsById,
}: ThreadTerminalDrawerProps) {
const isPanel = mode === "panel";
const [advancedTypography] = useLocalStorage(
TYPOGRAPHY_ADVANCED_STORAGE_KEY,
false,
Schema.Boolean,
);
const controlledDrawerHeight = clampDrawerHeight(height);
const [drawerHeightState, setDrawerHeightState] = useState(() => ({
threadId,
Expand Down Expand Up @@ -1357,6 +1369,7 @@ export default function ThreadTerminalDrawer({
>
<div className="h-full p-1">
<TerminalViewport
advancedTypography={advancedTypography}
threadRef={threadRef}
threadId={threadId}
terminalId={terminalId}
Expand Down Expand Up @@ -1384,6 +1397,7 @@ export default function ThreadTerminalDrawer({
) : (
<div className="h-full p-1">
<TerminalViewport
advancedTypography={advancedTypography}
key={resolvedActiveTerminalId}
threadRef={threadRef}
threadId={threadId}
Expand Down
30 changes: 20 additions & 10 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ import {
isFontFamilyAvailable,
isMonospaceFamily,
resolveDefaultFamilyLabel,
resolveTerminalFontPreference,
TYPOGRAPHY_ADVANCED_STORAGE_KEY,
} from "../../appearanceFonts";
import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
Expand Down Expand Up @@ -1151,10 +1153,8 @@ function useFontDefaultFamilies() {
return {
sans: defaults.sans,
code: defaults.code,
// The composer inherits whatever the interface preference resolves to;
// the terminal inherits the monospace preference the same way.
// The composer inherits whatever the interface preference resolves to.
interfaceFamily: settings.fontFamilySans.trim() || defaults.sans,
monoFamily: settings.fontFamilyCode.trim() || defaults.code,
};
}

Expand Down Expand Up @@ -1244,8 +1244,8 @@ function TerminalFontRow() {
return (
<FontFamilySettingsRow
{...searchableSetting("terminal-font")}
description="Terminal output. Follows the monospace font unless set."
defaultFamily={defaults.monoFamily}
description="Terminal output, independent from code blocks and diffs."
defaultFamily={defaults.code}
value={settings.fontFamilyTerminal}
onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
requireMonospace
Expand All @@ -1258,7 +1258,11 @@ function TerminalFontRow() {
}}
preview={
<TerminalFontPreview
family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode}
family={resolveTerminalFontPreference({
advanced: true,
code: settings.fontFamilyCode,
terminal: settings.fontFamilyTerminal,
})}
size={settings.fontSizeTerminal}
/>
}
Expand Down Expand Up @@ -1350,7 +1354,11 @@ function SimpleFontRows() {
<>
<CodeFontPreview />
<TerminalFontPreview
family={settings.fontFamilyTerminal.trim() || settings.fontFamilyCode}
family={resolveTerminalFontPreference({
advanced: false,
code: settings.fontFamilyCode,
terminal: settings.fontFamilyTerminal,
})}
size={settings.fontSizeTerminal}
/>
</>
Expand All @@ -1370,8 +1378,6 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet<string> = new Set([
: []),
]);

const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";

/**
* The two-font view by default - one sans, one monospace, each cascading to
* every surface it reaches - with an Advanced switch in the section header
Expand All @@ -1380,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced";
* target exists to scroll to.
*/
function TypographySection() {
const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean);
const [advanced, setAdvanced] = useLocalStorage(
TYPOGRAPHY_ADVANCED_STORAGE_KEY,
false,
Schema.Boolean,
);
const searchTargetId = useSettingsSearchTargetId();
// Flip Advanced on once per search jump so the hidden target can mount and
// scroll; tracking the handled id lets the user turn it back off without
Expand Down
24 changes: 23 additions & 1 deletion apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

import type { GhosttyCell, GhosttyRow } from "./core";
import {
Expand All @@ -11,6 +11,7 @@ import {
isTerminalCopyShortcut,
isTerminalLinkPointerGesture,
isTerminalPasteShortcut,
loadTerminalFontFamily,
shouldBlinkTerminalCursor,
shouldReportTerminalMouse,
shouldShowTerminalLinkHover,
Expand Down Expand Up @@ -295,6 +296,27 @@ describe("application mouse reporting", () => {
});

describe("terminal font resolution", () => {
it("validates the requested face after its styles load", async () => {
let loaded = false;
const load = vi.fn(async () => {
loaded = true;
return [];
});
const resolve = vi.fn(() => {
expect(loaded).toBe(true);
return DEFAULT_TERMINAL_FONT_FAMILY;
});

await expect(
loadTerminalFontFamily("Proportional Test", 12, {
load,
resolve,
}),
).resolves.toBe(DEFAULT_TERMINAL_FONT_FAMILY);
expect(load).toHaveBeenCalledTimes(4);
expect(resolve).toHaveBeenCalledWith("Proportional Test");
});

it("keeps the glyph fallbacks behind a custom text face", () => {
expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
Expand Down
Loading
Loading