diff --git a/bun.lock b/bun.lock index ba254b83..04210aaf 100644 --- a/bun.lock +++ b/bun.lock @@ -98,6 +98,7 @@ "@tanstack/react-query": "^5.90.12", "@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-router": "^1.143.6", + "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", @@ -1032,6 +1033,8 @@ "@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="], + "@xterm/addon-canvas": ["@xterm/addon-canvas@0.7.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], diff --git a/web/package.json b/web/package.json index 1ec71e34..2d221fa0 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@tanstack/react-query": "^5.90.12", "@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-router": "^1.143.6", + "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", diff --git a/web/src/components/Terminal/TerminalView.tsx b/web/src/components/Terminal/TerminalView.tsx index c1f11f78..deecbe6a 100644 --- a/web/src/components/Terminal/TerminalView.tsx +++ b/web/src/components/Terminal/TerminalView.tsx @@ -1,8 +1,10 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' +import { CanvasAddon } from '@xterm/addon-canvas' import '@xterm/xterm/css/xterm.css' +import { createFontProvider, type ITerminalFontProvider } from '@/lib/terminalFont' function resolveThemeColors(): { background: string; foreground: string; selectionBackground: string } { const styles = getComputedStyle(document.documentElement) @@ -20,6 +22,12 @@ export function TerminalView(props: { const containerRef = useRef(null) const onMountRef = useRef(props.onMount) const onResizeRef = useRef(props.onResize) + const [fontProvider, setFontProvider] = useState(null) + + // Initialize font provider + useEffect(() => { + createFontProvider('default').then(setFontProvider) + }, []) useEffect(() => { onMountRef.current = props.onMount @@ -31,14 +39,14 @@ export function TerminalView(props: { useEffect(() => { const container = containerRef.current - if (!container) { + if (!container || !fontProvider) { return } const { background, foreground, selectionBackground } = resolveThemeColors() const terminal = new Terminal({ cursorBlink: true, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontFamily: fontProvider.getFontFamily(), fontSize: 13, theme: { background, @@ -46,13 +54,16 @@ export function TerminalView(props: { cursor: foreground, selectionBackground }, - convertEol: true + convertEol: true, + customGlyphs: true }) const fitAddon = new FitAddon() const webLinksAddon = new WebLinksAddon() + const canvasAddon = new CanvasAddon() terminal.loadAddon(fitAddon) terminal.loadAddon(webLinksAddon) + terminal.loadAddon(canvasAddon) terminal.open(container) const resizeTerminal = () => { @@ -72,7 +83,7 @@ export function TerminalView(props: { observer.disconnect() terminal.dispose() } - }, []) + }, [fontProvider]) return (
+} + +/** + * Default font provider with hardcoded Nerd Font fallback list + * + * Includes common Nerd Fonts and system fallbacks. + * Zero runtime overhead - uses browser's native font fallback mechanism. + */ +export class DefaultFontProvider implements ITerminalFontProvider { + private static readonly NERD_FONTS = [ + // Common Nerd Fonts (prioritized by popularity) + 'JetBrainsMono Nerd Font', + 'JetBrainsMonoNerdFont', + 'FiraCode Nerd Font', + 'FiraCodeNerdFont', + 'Hack Nerd Font', + 'HackNerdFont', + 'MapleMono NF', + 'Maple Mono NF', + 'Iosevka Nerd Font', + 'IosevkaNerdFont', + 'CaskaydiaCove Nerd Font', + 'MesloLGS Nerd Font', + 'SourceCodePro Nerd Font', + 'UbuntuMono Nerd Font' + ] + + private static readonly SYSTEM_FALLBACKS = [ + 'ui-monospace', + 'SFMono-Regular', + 'Menlo', + 'Monaco', + 'Consolas', + '"Liberation Mono"', + '"Courier New"', + 'monospace' + ] + + getFontFamily(): string { + return [ + ...DefaultFontProvider.NERD_FONTS.map(f => `"${f}"`), + ...DefaultFontProvider.SYSTEM_FALLBACKS + ].join(', ') + } +} + +/** + * Factory function to create font provider + * + * @param mode - Provider mode (currently only 'default', extensible for future) + * @returns Font provider instance + */ +export async function createFontProvider( + mode: 'default' = 'default' +): Promise { + switch (mode) { + case 'default': + default: + return new DefaultFontProvider() + } +}