FN-6638: stabilize mobile terminal font metrics
Stabilize terminal font metric remeasurement across mobile and attach surfaces. - Add a shared terminal font metric wait helper that falls back from full font stacks to concrete individual font families before remeasurement. - Reapply font options, refit, resize, and refresh SessionTerminal after font metrics settle to cover attached CLI terminals. - Pin text-size adjustment on xterm measurement subtrees so mobile WebKit cannot inflate ASCII cell metrics. - Extend terminal preference and rendering tests, documentation, and changeset coverage for the recurrence. Files changed: .changeset/fn-6638-terminal-render.md | 5 + .../xterm-async-font-remeasure-paste-dedupe.md | 20 +++- .../dashboard/app/__tests__/terminal-input.test.ts | 26 +++++ .../dashboard/app/components/SessionTerminal.css | 10 ++ .../dashboard/app/components/SessionTerminal.tsx | 29 ++++++ .../dashboard/app/components/TerminalModal.css | 10 ++ .../dashboard/app/components/TerminalModal.tsx | 24 ++--- .../components/__tests__/SessionTerminal.test.tsx | 41 +++++++- .../components/__tests__/TerminalModal.test.tsx | 38 ++++++++ .../utils/__tests__/terminalPreferences.test.ts | 29 +++++- .../dashboard/app/utils/terminalPreferences.ts | 107 +++++++++++++++++++++ 11 files changed, 319 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-6638 Fusion-Task-Lineage: edbf071d-a0c1-4955-95ff-83e6996f4674
This commit is contained in:
5
.changeset/fn-6638-terminal-render.md
Normal file
5
.changeset/fn-6638-terminal-render.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix mobile iOS terminal cell measurement by making xterm font remeasure resilient to strict FontFaceSet shorthand rejection and pinning text-size adjustment on terminal viewports.
|
||||
@@ -14,10 +14,15 @@ resolution_type: code_fix
|
||||
severity: high
|
||||
related_components:
|
||||
- packages/dashboard/app/components/TerminalModal.tsx
|
||||
- packages/dashboard/app/components/TerminalModal.css
|
||||
- packages/dashboard/app/components/SessionTerminal.tsx
|
||||
- packages/dashboard/app/components/SessionTerminal.css
|
||||
- packages/dashboard/app/utils/terminalPreferences.ts
|
||||
- packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
|
||||
- packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx
|
||||
- packages/dashboard/app/__tests__/terminal-input.test.ts
|
||||
- FN-6390
|
||||
- FN-6638
|
||||
tags:
|
||||
- xterm
|
||||
- font-loading
|
||||
@@ -33,6 +38,10 @@ tags:
|
||||
|
||||
xterm.js measures character-cell geometry when `terminal.open()` runs. If a custom web font is declared with `font-display: swap`, a cold load can let xterm cache fallback-font metrics and then swap to the real font later. The renderer may keep the stale cell width, producing widely spaced glyphs on mobile/DOM-renderer surfaces.
|
||||
|
||||
FN-6638 was the fourth recurrence of the mobile wide-cell defect (FN-6390 → FN-6424 → FN-6603 → FN-6638). The FN-6603 font-stack ordering hypothesis was ruled out: the supplied diagnostic measured `66.76px for AGENTS.md` identically for symbols-first, symbols-last, and system-mono stacks, and desktop/mobile-emulated WebKit rendered ASCII tightly while a real iOS Safari screenshot still showed `A G E N T S . m d`. Treat Playwright/desktop WebKit emulation as a blind spot for this class; it can prove CSS contracts and fallback paths but cannot be the acceptance surface.
|
||||
|
||||
The recurrence path was stricter real-iOS font/text measurement behavior. A long `document.fonts.load(`${fontSize}px ${resolvedFontFamily}`)` shorthand can reject on iOS WebKit; returning from that catch prevented xterm from reapplying `fontFamily`/`fontSize`, running `fitAddon.fit()`, publishing resize, and refreshing rows. Separately, the xterm measurement subtree lacked `-webkit-text-size-adjust: 100%`, allowing iOS Safari text inflation to perturb cell metrics.
|
||||
|
||||
A second pitfall is custom paste handling. If an `attachCustomKeyEventHandler` Cmd/Ctrl+V branch reads `navigator.clipboard.readText()` and forwards that text to the PTY while the browser also performs the native paste into xterm's helper textarea, the same payload reaches `terminal.onData` and is sent twice.
|
||||
|
||||
## Solution
|
||||
@@ -41,11 +50,12 @@ Keep one canonical paste path and remeasure after font resolution.
|
||||
|
||||
- Prefer xterm's native helper-textarea paste for Cmd/Ctrl+V; return `true` from the custom key handler so the browser/xterm path runs, and do not read/send clipboard text manually.
|
||||
- Preserve custom copy behavior only for selected text, where suppressing terminal input is intentional.
|
||||
- After `terminal.open()`, call `document.fonts.load()` for the terminal font stack and await `document.fonts.ready` when the FontFaceSet API exists.
|
||||
- After `terminal.open()`, treat FontFaceSet loading as best-effort: try the full stack, fall back to concrete individual families only if the full shorthand rejects, await `document.fonts.ready`, and never let an iOS shorthand rejection skip the later remeasure.
|
||||
- Guard async remeasure work with the expected session id and current terminal/addon refs so stale font-load promises cannot mutate a disposed or switched terminal.
|
||||
- Reapply font options, run `fitAddon.fit()`, publish the resized cols/rows, and refresh visible rows once the web font has resolved.
|
||||
- Reapply font options, run `fitAddon.fit()`, publish the resized cols/rows, and refresh visible rows once the FontFaceSet has settled.
|
||||
- Pin `-webkit-text-size-adjust: 100%` / `text-size-adjust: 100%` on the xterm host subtree (`.terminal-xterm` and `.cli-session-terminal__viewport`) so iOS Safari cannot inflate DOM/canvas measurement nodes.
|
||||
|
||||
`SessionTerminal` is unaffected by the custom-font symptom because it uses a system monospace stack, and unaffected by paste duplication because it does not install a custom paste handler; native xterm paste is its only input path.
|
||||
`SessionTerminal` is unaffected by paste duplication because it does not install a custom paste handler; native xterm paste is its only input path. It is affected by the font/cell-measurement invariant because it constructs xterm with the same user-selectable font presets and mobile DOM/canvas renderer path, so it must share both the best-effort font-load remeasure and the text-size-adjust pin.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
@@ -54,6 +64,8 @@ Cover the invariant across terminal surfaces and input paths:
|
||||
- Keyboard paste on macOS (`metaKey`) and non-mac (`ctrlKey`) returns `true`, does not call `clipboard.readText()`, and sends exactly one PTY input frame via xterm `onData`.
|
||||
- Native helper-textarea paste without the shortcut handler sends exactly once, covering mobile/iOS context-menu paste.
|
||||
- A controlled `document.fonts.load()` promise resolving after `terminal.open()` triggers a post-font-load fit, resize, and refresh.
|
||||
- `SessionTerminal` asserts it uses the system monospace stack, does not attach a custom key handler, and sends one native xterm paste input frame.
|
||||
- A controlled `document.fonts.load()` rejection (the real-iOS shorthand failure mode) still triggers font option reapply, fit/resize, and refresh for both `TerminalModal` and `SessionTerminal`.
|
||||
- CSS contract tests assert both xterm host subtrees pin `text-size-adjust` to 100%.
|
||||
- `SessionTerminal` asserts it uses the shared terminal font presets, does not attach a custom key handler, and sends one native xterm paste input frame.
|
||||
|
||||
This avoids downstream byte de-duplication and fixes the two root causes at their renderer/input seams.
|
||||
|
||||
@@ -14,6 +14,24 @@ function findHelperTextareaRule(): string {
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
function findTerminalTextSizingRule(): string {
|
||||
const match = css.match(/\.terminal-xterm\s*,\s*\.terminal-xterm \*\s*\{([^}]*)\}/);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
function findSessionTerminalTextSizingRule(): string {
|
||||
const match = css.match(
|
||||
/\.cli-session-terminal__viewport\s*,\s*\.cli-session-terminal__viewport \*\s*\{([^}]*)\}/,
|
||||
);
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
function expectTextSizeAdjustPinned(ruleBody: string): void {
|
||||
expect(ruleBody).not.toBe("");
|
||||
expect(ruleBody).toMatch(/-webkit-text-size-adjust\s*:\s*100%\s*;/);
|
||||
expect(ruleBody).toMatch(/text-size-adjust\s*:\s*100%\s*;/);
|
||||
}
|
||||
|
||||
function findTerminalSymbolsFontFaceRule(): string {
|
||||
const fontFaceRules = css.match(/@font-face\s*\{[^}]*\}/g) ?? [];
|
||||
return (
|
||||
@@ -77,6 +95,14 @@ describe("terminal helper textarea CSS contract", () => {
|
||||
const ruleBody = findHelperTextareaRule();
|
||||
expect(ruleBody).toMatch(/opacity:\s*0\.01\b/);
|
||||
});
|
||||
|
||||
it("pins iOS text-size adjustment across the xterm measurement subtree", () => {
|
||||
expectTextSizeAdjustPinned(findTerminalTextSizingRule());
|
||||
});
|
||||
|
||||
it("pins iOS text-size adjustment on the SessionTerminal xterm viewport", () => {
|
||||
expectTextSizeAdjustPinned(findSessionTerminalTextSizingRule());
|
||||
});
|
||||
});
|
||||
|
||||
describe("FN-6424 terminal symbols font CSS contract", () => {
|
||||
|
||||
@@ -122,6 +122,16 @@
|
||||
background: var(--terminal-bg, var(--bg));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-18-07:34:
|
||||
SessionTerminal hosts the same xterm DOM/canvas measurement subtree as TerminalModal under a different wrapper. Apply the FN-6638 real-iOS text-size-adjust invariant here too so CLI-agent attach terminals do not inherit Safari-inflated ASCII cell metrics while still using the shared font-load remeasure path for every preset and desktop WebGL.
|
||||
*/
|
||||
.cli-session-terminal__viewport,
|
||||
.cli-session-terminal__viewport * {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.cli-session-terminal__advance-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
readTerminalPreferences,
|
||||
resolveTerminalFontFamily,
|
||||
waitForTerminalFontMetrics,
|
||||
} from "../utils/terminalPreferences";
|
||||
|
||||
/**
|
||||
@@ -401,6 +402,34 @@ export function SessionTerminal({
|
||||
/* container not measurable yet */
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const fontMetricsSettled = await waitForTerminalFontMetrics(
|
||||
terminalPreferences.fontSize,
|
||||
resolvedFontFamily,
|
||||
);
|
||||
if (
|
||||
!fontMetricsSettled ||
|
||||
disposed ||
|
||||
xtermRef.current !== term ||
|
||||
fitAddonRef.current !== fitAddon
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
/*
|
||||
FNXC:Terminal 2026-06-18-07:15:
|
||||
SessionTerminal shares TerminalModal's real-iOS DOM/canvas measurement path and the same user-selectable font presets. FN-6638 ruled out stack ordering with the 66.76px-identical diagnostic, so this attach surface must also reapply font options and refit after best-effort FontFaceSet settlement even when iOS rejects the multi-family shorthand; WebGL desktop remains safe because the same invalidation path refreshes renderer metrics without changing renderer selection.
|
||||
*/
|
||||
term.options.fontFamily = resolvedFontFamily;
|
||||
term.options.fontSize = terminalPreferences.fontSize;
|
||||
(fitAddon as unknown as { fit: () => void }).fit();
|
||||
sendResize(term.cols, term.rows);
|
||||
term.refresh(0, Math.max(0, term.rows - 1));
|
||||
} catch {
|
||||
/* ignore teardown or transient measure failures */
|
||||
}
|
||||
})();
|
||||
|
||||
// term.onData → input frames (skip entirely when read-only).
|
||||
if (!readOnly) {
|
||||
term.onData((data: string) => {
|
||||
|
||||
@@ -545,6 +545,16 @@ FN-6603 found that unicode-range scoping is not enough when the symbols face is
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-18-07:04:
|
||||
Real iOS Safari recurrence #4 kept wide ASCII cells even after unicode-range scoping and symbols-last ordering; the supplied harness measured AGENTS.md at the same 66.76px for every stack, so ordering is ruled out. Pin text-size adjustment across xterm's measurement subtree so mobile WebKit cannot inflate DOM/canvas cell metrics, while WebGL/desktop keep the same 100% text scale and every terminal preset still resolves through terminalPreferences.
|
||||
*/
|
||||
.terminal-xterm,
|
||||
.terminal-xterm * {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* xterm fit may apply inline pixel heights on the `.xterm` root after
|
||||
* row/line-height recomputation (e.g. after font-size changes). Keep the root
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
clampTerminalFontSize,
|
||||
readTerminalPreferences,
|
||||
resolveTerminalFontFamily,
|
||||
waitForTerminalFontMetrics,
|
||||
writeTerminalPreferences,
|
||||
type TerminalPreferences,
|
||||
type TerminalRenderer,
|
||||
@@ -490,16 +491,12 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
terminal: XTerm,
|
||||
fitAddon: InstanceType<typeof import("@xterm/addon-fit").FitAddon>,
|
||||
) => {
|
||||
if (typeof document === "undefined" || !document.fonts?.load) {
|
||||
return;
|
||||
}
|
||||
const fontMetricsSettled = await waitForTerminalFontMetrics(
|
||||
fontSizeRef.current,
|
||||
resolvedFontFamilyRef.current,
|
||||
);
|
||||
|
||||
try {
|
||||
await document.fonts.load(`${fontSizeRef.current}px ${resolvedFontFamilyRef.current}`);
|
||||
await document.fonts.ready;
|
||||
} catch {
|
||||
// Font loading support is best-effort; keep the terminal usable if the
|
||||
// browser rejects due to permissions, syntax, or unsupported APIs.
|
||||
if (!fontMetricsSettled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -512,11 +509,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
}
|
||||
|
||||
try {
|
||||
// xterm measures cell geometry at open() time. The terminal Nerd Font
|
||||
// is loaded with font-display: swap, so a cold load can replace the
|
||||
// fallback font after open(); re-applying font options and fitting after
|
||||
// FontFaceSet resolution forces the DOM/canvas and WebGL renderers to
|
||||
// remeasure against the actual glyph metrics.
|
||||
/*
|
||||
FNXC:Terminal 2026-06-18-07:23:
|
||||
FN-6638 recurrence #4 showed the previous symbols-last stack-order fix was inert: the supplied diagnostic measured AGENTS.md at the same 66.76px for symbols-first, symbols-last, and system-mono stacks while real iOS Safari still widened ASCII cells. xterm measures cell geometry at open() time, so after best-effort FontFaceSet settlement we must always reapply the active preset's font options, fit, resize, and refresh; that invalidates stale DOM/canvas metrics on real iOS when the full shorthand is rejected and keeps desktop WebGL using the same renderer-neutral metric refresh.
|
||||
*/
|
||||
terminal.options.fontFamily = resolvedFontFamilyRef.current;
|
||||
terminal.options.fontSize = fontSizeRef.current;
|
||||
fitAddon.fit();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
// ── Mock xterm + addon dynamic imports (jsdom has no canvas/WebGL) ──────────
|
||||
const mockFitAddon = { fit: vi.fn() };
|
||||
@@ -9,6 +9,7 @@ const mockTerm = {
|
||||
onData: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
write: vi.fn((_data: string, cb?: () => void) => cb?.()),
|
||||
refresh: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
unicode: { activeVersion: "6" },
|
||||
options: {} as Record<string, unknown>,
|
||||
@@ -85,8 +86,13 @@ beforeEach(() => {
|
||||
mockTerm.onData.mockReset();
|
||||
mockTerm.attachCustomKeyEventHandler.mockClear();
|
||||
mockTerm.write.mockClear();
|
||||
mockTerm.refresh.mockClear();
|
||||
mockTerm.dispose.mockClear();
|
||||
mockTerm.options = {};
|
||||
Object.defineProperty(document, "fonts", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
mockFitAddon.fit.mockClear();
|
||||
apiMock.mockReset();
|
||||
apiMock.mockResolvedValue({ ticket: "tkt-1", expiresAt: "", readOnly: false });
|
||||
@@ -154,6 +160,39 @@ describe("SessionTerminal", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("refits after font settlement even when iOS rejects the font-load shorthand", async () => {
|
||||
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
|
||||
Object.defineProperty(document, "fonts", {
|
||||
value: {
|
||||
load,
|
||||
ready: Promise.resolve(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const fitCallBaseline = mockFitAddon.fit.mock.calls.length;
|
||||
|
||||
render(<SessionTerminal sessionId="s1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(FakeWS.instances.length).toBe(1);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Fusion Terminal Nerd Font Symbols"),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerm.options.fontFamily).toBe(resolveTerminalFontFamily("nerd-font"));
|
||||
expect(mockTerm.options.fontSize).toBe(DEFAULT_TERMINAL_PREFERENCES.fontSize);
|
||||
expect(mockFitAddon.fit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
|
||||
expect(mockTerm.refresh).toHaveBeenCalledWith(0, mockTerm.rows - 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies validated terminal preferences at xterm init", async () => {
|
||||
const { Terminal } = await import("@xterm/xterm");
|
||||
window.localStorage.setItem(
|
||||
|
||||
@@ -4694,6 +4694,44 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("still refits xterm when iOS rejects the multi-family font-load shorthand", async () => {
|
||||
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
|
||||
Object.defineProperty(document, "fonts", {
|
||||
value: {
|
||||
load,
|
||||
ready: Promise.resolve(),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const fitCallBaseline = mockFitAddonFit.mock.calls.length;
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Fusion Terminal Nerd Font Symbols"),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.options.fontFamily).toBe(XTERM_FONT_FAMILY);
|
||||
expect(mockTerminalInstance.options.fontSize).toBe(DEFAULT_TERMINAL_PREFERENCES.fontSize);
|
||||
expect(mockFitAddonFit.mock.calls.length).toBeGreaterThan(fitCallBaseline);
|
||||
expect(mockResize).toHaveBeenCalledWith(
|
||||
mockTerminalInstance.cols,
|
||||
mockTerminalInstance.rows,
|
||||
);
|
||||
expect(mockTerminalInstance.refresh).toHaveBeenCalledWith(0, mockTerminalInstance.rows - 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unrelated key handling untouched", async () => {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_TERMINAL_PREFERENCES,
|
||||
LEGACY_TERMINAL_FONT_SIZE_KEY,
|
||||
TERMINAL_PREFERENCES_KEY,
|
||||
XTERM_FONT_FAMILY,
|
||||
readTerminalPreferences,
|
||||
waitForTerminalFontMetrics,
|
||||
writeTerminalPreferences,
|
||||
} from "../terminalPreferences";
|
||||
|
||||
@@ -87,4 +89,29 @@ describe("terminalPreferences", () => {
|
||||
expect(readTerminalPreferences()).toEqual(written);
|
||||
expect(localStorage.getItem(LEGACY_TERMINAL_FONT_SIZE_KEY)).toBe("22");
|
||||
});
|
||||
|
||||
it("keeps terminal font metrics wait best-effort when iOS rejects the full stack shorthand", async () => {
|
||||
let readyAwaited = false;
|
||||
const load = vi.fn((font: string) => {
|
||||
if (font.includes(",")) {
|
||||
return Promise.reject(new DOMException("Invalid font shorthand"));
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
const ready = Promise.resolve().then(() => {
|
||||
readyAwaited = true;
|
||||
});
|
||||
|
||||
await expect(
|
||||
waitForTerminalFontMetrics(12, XTERM_FONT_FAMILY, {
|
||||
load,
|
||||
ready,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(load).toHaveBeenCalledWith(expect.stringContaining("MesloLGS NF"));
|
||||
expect(load).toHaveBeenCalledWith("12px \"MesloLGS NF\"");
|
||||
expect(load).toHaveBeenCalledWith("12px \"Fusion Terminal Nerd Font Symbols\"");
|
||||
expect(readyAwaited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,113 @@ export function resolveTerminalFontFamily(fontFamily: TerminalFontFamily): strin
|
||||
);
|
||||
}
|
||||
|
||||
const CSS_GENERIC_FONT_FAMILIES = new Set([
|
||||
"serif",
|
||||
"sans-serif",
|
||||
"monospace",
|
||||
"cursive",
|
||||
"fantasy",
|
||||
"system-ui",
|
||||
"ui-serif",
|
||||
"ui-sans-serif",
|
||||
"ui-monospace",
|
||||
"ui-rounded",
|
||||
"emoji",
|
||||
"math",
|
||||
"fangsong",
|
||||
]);
|
||||
|
||||
type TerminalFontFaceSet = {
|
||||
load?: (font: string, text?: string) => PromiseLike<unknown>;
|
||||
ready?: PromiseLike<unknown>;
|
||||
};
|
||||
|
||||
export function splitTerminalFontFamilies(stack: string): string[] {
|
||||
return stack
|
||||
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
|
||||
.map((family) => family.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeFontFamilyName(family: string): string {
|
||||
const trimmed = family.trim();
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isLoadableConcreteFontFamily(family: string): boolean {
|
||||
const normalized = normalizeFontFamilyName(family).toLowerCase();
|
||||
return normalized !== "" && !CSS_GENERIC_FONT_FAMILIES.has(normalized);
|
||||
}
|
||||
|
||||
function getDocumentFonts(): TerminalFontFaceSet | undefined {
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
return document.fonts;
|
||||
}
|
||||
|
||||
async function settleFontLoad(fonts: TerminalFontFaceSet, font: string): Promise<boolean> {
|
||||
if (!fonts.load) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await fonts.load(font);
|
||||
return true;
|
||||
} catch {
|
||||
// Best-effort: strict iOS FontFaceSet parsing can reject one shorthand while
|
||||
// later declarations or fonts.ready still give xterm a safe remeasure point.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForTerminalFontMetrics(
|
||||
fontSize: number,
|
||||
fontFamily: string,
|
||||
fonts: TerminalFontFaceSet | undefined = getDocumentFonts(),
|
||||
): Promise<boolean> {
|
||||
if (!fonts?.load) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fontSizeCss = `${fontSize}px`;
|
||||
const declarations = [
|
||||
`${fontSizeCss} ${fontFamily}`,
|
||||
...splitTerminalFontFamilies(fontFamily)
|
||||
.filter(isLoadableConcreteFontFamily)
|
||||
.map((family) => `${fontSizeCss} ${family}`),
|
||||
];
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-06-18-07:02:
|
||||
FN-6638 recurrence #4 showed font-stack order was inert: the supplied diagnostic measured AGENTS.md at the same 66.76px with symbols-first, symbols-last, and system-mono stacks while real iOS Safari still rendered wide ASCII cells. Treat FontFaceSet loading as best-effort and always leave callers free to reapply xterm font options; strict iOS WebKit can reject the long multi-family shorthand, and that rejection must not suppress DOM/canvas or WebGL metric invalidation for any preset.
|
||||
*/
|
||||
const [fullStackDeclaration, ...individualDeclarations] = declarations;
|
||||
const fullStackLoaded = fullStackDeclaration
|
||||
? await settleFontLoad(fonts, fullStackDeclaration)
|
||||
: false;
|
||||
|
||||
if (!fullStackLoaded) {
|
||||
for (const declaration of individualDeclarations) {
|
||||
await settleFontLoad(fonts, declaration);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fonts.ready;
|
||||
} catch {
|
||||
// Continue to xterm remeasure even if the FontFaceSet settles rejected.
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user