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
124 changes: 124 additions & 0 deletions apps/desktop/src/app/right-sidebar/terminal/terminal-font.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest'

import {
applyTerminalFontFamily,
DEFAULT_TERMINAL_FONT_FAMILY,
prepareTerminalFontFamily,
resolveTerminalFontFamily,
warmTerminalFontFamily
} from './terminal-font'

describe('terminal font resolution', () => {
it('keeps the bundled stack when no preference is configured', () => {
expect(resolveTerminalFontFamily('')).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
expect(resolveTerminalFontFamily(undefined)).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
})

it('quotes a friendly family name and appends the bundled fallback stack', () => {
expect(resolveTerminalFontFamily(' MesloLGS NF ')).toBe(`'MesloLGS NF', ${DEFAULT_TERMINAL_FONT_FAMILY}`)
})

it('preserves an authored CSS stack before the bundled fallbacks', () => {
expect(resolveTerminalFontFamily("'Hack Nerd Font', monospace")).toBe(
`'Hack Nerd Font', monospace, ${DEFAULT_TERMINAL_FONT_FAMILY}`
)
})
})

describe('terminal font lifecycle', () => {
it('warms regular, bold, and italic faces using the effective stack', async () => {
const load = vi.fn().mockResolvedValue([])

await warmTerminalFontFamily("'MesloLGS NF', monospace", { load } as Pick<FontFaceSet, 'load'>)

expect(load.mock.calls.map(([descriptor]) => descriptor)).toEqual([
"400 11px 'MesloLGS NF', monospace",
"700 11px 'MesloLGS NF', monospace",
"italic 400 11px 'MesloLGS NF', monospace"
])
})

it('restarts initial warming when config arrives late', async () => {
let latest = 'fallback'

const warm = vi.fn(async (fontFamily: string) => {
if (fontFamily === 'fallback') {
latest = 'MesloLGS NF'
}
})

await expect(
prepareTerminalFontFamily(
() => latest,
() => true,
warm
)
).resolves.toBe('MesloLGS NF')
expect(warm.mock.calls.map(([font]) => font)).toEqual(['fallback', 'MesloLGS NF'])
})

it('cancels a stale initial font request before xterm mounts', async () => {
let current = true

const warm = vi.fn(async () => {
current = false
})

await expect(
prepareTerminalFontFamily(
() => 'MesloLGS NF',
() => current,
warm
)
).resolves.toBeNull()
})

it('updates a mounted terminal without replacing it', async () => {
const term = {
options: { fontFamily: 'fallback' },
rows: 24,
refresh: vi.fn()
}

const fit = vi.fn()
const clearTextureAtlas = vi.fn()

await expect(
applyTerminalFontFamily({
clearTextureAtlas,
fit,
fontFamily: 'MesloLGS NF',
isCurrent: () => true,
term,
warm: vi.fn().mockResolvedValue(undefined)
})
).resolves.toBe(true)

expect(term.options.fontFamily).toBe('MesloLGS NF')
expect(fit).toHaveBeenCalledOnce()
expect(clearTextureAtlas).toHaveBeenCalledOnce()
expect(term.refresh).toHaveBeenCalledWith(0, 23)
})

it('does not paint a stale live font request', async () => {
const term = {
options: { fontFamily: 'newer' },
rows: 24,
refresh: vi.fn()
}

await expect(
applyTerminalFontFamily({
clearTextureAtlas: vi.fn(),
fit: vi.fn(),
fontFamily: 'stale',
isCurrent: () => false,
term,
warm: vi.fn().mockResolvedValue(undefined)
})
).resolves.toBe(false)

expect(term.options.fontFamily).toBe('newer')
expect(term.refresh).not.toHaveBeenCalled()
})
})
138 changes: 138 additions & 0 deletions apps/desktop/src/app/right-sidebar/terminal/terminal-font.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { atom } from 'nanostores'

export const DEFAULT_TERMINAL_FONT_FAMILY = "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace"

export const TERMINAL_FONT_SUGGESTIONS = [
'MesloLGS NF',
'JetBrainsMono Nerd Font',
'CaskaydiaCove Nerd Font',
'FiraCode Nerd Font',
'Hack Nerd Font',
'SauceCodePro Nerd Font',
'JetBrains Mono',
'SF Mono',
'Menlo',
'Cascadia Code'
] as const

/** The profile-backed value as written in config.yaml. Empty means bundled default. */
export const $terminalFontFamily = atom('')

export function normalizeTerminalFontFamily(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}

function quoteSingleFamily(value: string): string {
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
}

/** Accept a friendly single family name or an authored CSS font stack. */
export function resolveTerminalFontFamily(value: unknown): string {
const configured = normalizeTerminalFontFamily(value)

if (!configured) {
return DEFAULT_TERMINAL_FONT_FAMILY
}

const preferred = configured.includes(',') || /['"]/.test(configured) ? configured : quoteSingleFamily(configured)

return `${preferred}, ${DEFAULT_TERMINAL_FONT_FAMILY}`
}

export function setTerminalFontFamilyFromConfig(value: unknown): void {
$terminalFontFamily.set(normalizeTerminalFontFamily(value))
}

type FontFaceLoader = Pick<FontFaceSet, 'load'>

function browserFontSet(): FontFaceLoader | undefined {
return typeof document === 'undefined' ? undefined : document.fonts
}

/** Warm every face xterm uses before WebGL builds its glyph texture atlas. */
export async function warmTerminalFontFamily(
fontFamily: string,
fontSet: FontFaceLoader | undefined = browserFontSet()
): Promise<void> {
if (!fontSet?.load) {
return
}

await Promise.allSettled(
['400', '700', 'italic 400'].map(descriptor =>
Promise.resolve().then(() => fontSet.load(`${descriptor} 11px ${fontFamily}`))
)
)
}

/**
* Wait for the newest requested family before mounting xterm. Config can arrive
* after the terminal component renders; this loop prevents opening WebGL with
* stale fallback metrics and then immediately rebuilding it.
*/
export async function prepareTerminalFontFamily(
getLatest: () => string,
isCurrent: () => boolean,
warm: (fontFamily: string) => Promise<void> = warmTerminalFontFamily
): Promise<string | null> {
let candidate = getLatest()

while (isCurrent()) {
await warm(candidate)

if (!isCurrent()) {
return null
}

const latest = getLatest()

if (latest === candidate) {
return candidate
}

candidate = latest
}

return null
}

export interface TerminalFontTarget {
options: { fontFamily?: string }
rows: number
refresh: (start: number, end: number) => void
}

interface ApplyTerminalFontOptions {
clearTextureAtlas: () => void
fit: () => void
fontFamily: string
isCurrent: () => boolean
term: TerminalFontTarget
warm?: (fontFamily: string) => Promise<void>
}

/** Apply a live font change without recreating the xterm instance or its PTY. */
export async function applyTerminalFontFamily({
clearTextureAtlas,
fit,
fontFamily,
isCurrent,
term,
warm = warmTerminalFontFamily
}: ApplyTerminalFontOptions): Promise<boolean> {
await warm(fontFamily)

if (!isCurrent()) {
return false
}

term.options.fontFamily = fontFamily
fit()
clearTextureAtlas()

if (term.rows > 0) {
term.refresh(0, term.rows - 1)
}

return true
}
Loading
Loading