fix: refresh terminal renderer after fonts load
This commit is contained in:
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { loadMonoFont } from '@/lib/fontLoader';
|
||||||
|
import type { MonoFontOption } from '@/lib/fontOptions';
|
||||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||||
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
||||||
import {
|
import {
|
||||||
@@ -27,20 +29,24 @@ const loadGhostty = (): Promise<GhosttyRuntime> =>
|
|||||||
ghostty: await module.Ghostty.load(),
|
ghostty: await module.Ghostty.load(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The web entry defers its ~2 MB Nerd Font download until a terminal actually
|
// Wait briefly for both the selected mono font and the web entry's deferred
|
||||||
// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for
|
// Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block
|
||||||
// it with a short bound so a cached font is in place before the glyph atlas is
|
// opening the terminal, so the renderer starts after the bound and is rebuilt
|
||||||
// built, while a cold CDN fetch never blocks the terminal from opening; the
|
// once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once.
|
||||||
// runtimes without the hook (VS Code, mobile) resolve immediately.
|
const TERMINAL_FONT_WAIT_MS = 2000;
|
||||||
const NERD_FONT_WAIT_MS = 2000;
|
const loadNerdFonts = (): Promise<void> =>
|
||||||
const ensureNerdFonts = (): Promise<void> => {
|
Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined);
|
||||||
if (typeof window === 'undefined') return Promise.resolve();
|
|
||||||
const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise<void> }).__openchamberEnsureNerdFonts;
|
const waitForTerminalFonts = (font: MonoFontOption) => {
|
||||||
if (typeof loader !== 'function') return Promise.resolve();
|
const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined);
|
||||||
return Promise.race([
|
const loadedBeforeTimeout = new Promise<boolean>((resolve) => {
|
||||||
Promise.resolve(loader()).catch(() => undefined),
|
const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS);
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)),
|
void loaded.then(() => {
|
||||||
]).then(() => undefined);
|
clearTimeout(timeout);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { loaded, loadedBeforeTimeout };
|
||||||
};
|
};
|
||||||
|
|
||||||
type TerminalSize = { cols: number; rows: number };
|
type TerminalSize = { cols: number; rows: number };
|
||||||
@@ -91,6 +97,7 @@ type Props = {
|
|||||||
onInput: (data: string) => void;
|
onInput: (data: string) => void;
|
||||||
onResize: (cols: number, rows: number) => void;
|
onResize: (cols: number, rows: number) => void;
|
||||||
theme: TerminalTheme;
|
theme: TerminalTheme;
|
||||||
|
monoFont: MonoFontOption;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -100,7 +107,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
||||||
sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className,
|
sessionKey, chunks, onInput, onResize, theme, monoFont, fontFamily, fontSize, className,
|
||||||
enableTouchScroll = false, autoFocus = true, isVisible = true,
|
enableTouchScroll = false, autoFocus = true, isVisible = true,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -236,7 +243,8 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
window.addEventListener('focus', handleWindowFocus);
|
window.addEventListener('focus', handleWindowFocus);
|
||||||
window.addEventListener('blur', handleWindowBlur);
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
|
||||||
Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => {
|
const fonts = waitForTerminalFonts(monoFont);
|
||||||
|
Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
terminal = new module.Terminal({
|
terminal = new module.Terminal({
|
||||||
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
||||||
@@ -264,6 +272,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
const safeReset = safeResetRef.current;
|
const safeReset = safeResetRef.current;
|
||||||
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
|
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
|
||||||
fitFrame = requestAnimationFrame(fit);
|
fitFrame = requestAnimationFrame(fit);
|
||||||
|
if (!fontsLoaded) {
|
||||||
|
void fonts.loaded.then(() => {
|
||||||
|
if (!disposed && terminalRef.current === terminal) {
|
||||||
|
setRendererGeneration((value) => value + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -301,7 +316,7 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
writeEpochRef.current += 1;
|
writeEpochRef.current += 1;
|
||||||
rendererReadyRef.current = false;
|
rendererReadyRef.current = false;
|
||||||
};
|
};
|
||||||
}, [fit, fontFamily, fontSize, rendererGeneration, theme]);
|
}, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const terminal = terminalRef.current;
|
const terminal = terminalRef.current;
|
||||||
|
|||||||
@@ -1140,6 +1140,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
|||||||
onInput={handleViewportInput}
|
onInput={handleViewportInput}
|
||||||
onResize={handleViewportResize}
|
onResize={handleViewportResize}
|
||||||
theme={xtermTheme}
|
theme={xtermTheme}
|
||||||
|
monoFont={monoFont}
|
||||||
fontFamily={resolvedFontStack}
|
fontFamily={resolvedFontStack}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
enableTouchScroll={useTouchTerminalInput}
|
enableTouchScroll={useTouchTerminalInput}
|
||||||
|
|||||||
@@ -100,4 +100,13 @@ describe('terminal viewport remount guard', () => {
|
|||||||
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
|
expect(terminalViewportSource).toContain('resizeRef.current(size.cols, size.rows)');
|
||||||
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
|
expect(terminalViewportSource).toContain('...(provisionalSizeRef.current ?? {})');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rebuilds the canvas renderer when terminal fonts finish loading after the startup bound', () => {
|
||||||
|
expect(terminalViewportSource).toContain('loadMonoFont(font)');
|
||||||
|
expect(terminalViewportSource).toContain('Promise.all([loadMonoFont(font), loadNerdFonts()])');
|
||||||
|
expect(terminalViewportSource).toContain('Promise.all([loadGhostty(), fonts.loadedBeforeTimeout])');
|
||||||
|
expect(terminalViewportSource).toContain('if (!fontsLoaded)');
|
||||||
|
expect(terminalViewportSource).toContain('void fonts.loaded.then(() => {');
|
||||||
|
expect(terminalViewportSource).toContain('setRendererGeneration((value) => value + 1)');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+1
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
__openchamberEnsureNerdFonts?: () => Promise<void>;
|
||||||
__opencodeDebug?: {
|
__opencodeDebug?: {
|
||||||
getLastAssistantMessage: () => unknown;
|
getLastAssistantMessage: () => unknown;
|
||||||
getAllMessages: (truncate?: boolean) => unknown[];
|
getAllMessages: (truncate?: boolean) => unknown[];
|
||||||
|
|||||||
Reference in New Issue
Block a user