Skip to content
Closed
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
116 changes: 81 additions & 35 deletions config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import {
collectLeafIdsInOrder,
collectLeafIdsInReplayCreationOrder
} from './layout-serialization'
import { DEFAULT_TERMINAL_FONT_FAMILY } from '@/lib/terminal-font-family'
import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options'

// ---------------------------------------------------------------------------
// Helper to create mock elements
Expand All @@ -73,7 +75,7 @@ const LEAF_4 = '44444444-4444-4444-8444-444444444444'
// buildFontFamily
// ---------------------------------------------------------------------------
const FULL_FALLBACK =
'"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace'
'"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "D2Coding", "NanumGothicCoding", "나눔고딕코딩", "Sarasa Mono K", "Noto Sans Mono CJK KR", "Apple SD Gothic Neo", "Apple SD 산돌고딕 Neo", "Malgun Gothic", "맑은 고딕", "MS Gothic", "MS ゴシック", "Hiragino Sans", "ヒラギノ角ゴシック", monospace'

describe('buildFontFamily', () => {
it('puts custom font first with full cross-platform fallback chain', () => {
Expand All @@ -84,7 +86,7 @@ describe('buildFontFamily', () => {
it('does not duplicate SF Mono when it is the input', () => {
const result = buildFontFamily('SF Mono')
expect(result).toBe(
'"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace'
'"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "D2Coding", "NanumGothicCoding", "나눔고딕코딩", "Sarasa Mono K", "Noto Sans Mono CJK KR", "Apple SD Gothic Neo", "Apple SD 산돌고딕 Neo", "Malgun Gothic", "맑은 고딕", "MS Gothic", "MS ゴシック", "Hiragino Sans", "ヒラギノ角ゴシック", monospace'
)
})

Expand All @@ -101,30 +103,49 @@ describe('buildFontFamily', () => {
it('does not duplicate when font name contains "sf mono" (case-insensitive)', () => {
const result = buildFontFamily('My SF Mono Custom')
expect(result).toBe(
'"My SF Mono Custom", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace'
'"My SF Mono Custom", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "D2Coding", "NanumGothicCoding", "나눔고딕코딩", "Sarasa Mono K", "Noto Sans Mono CJK KR", "Apple SD Gothic Neo", "Apple SD 산돌고딕 Neo", "Malgun Gothic", "맑은 고딕", "MS Gothic", "MS ゴシック", "Hiragino Sans", "ヒラギノ角ゴシック", monospace'
)
})

it('does not duplicate Consolas when it is the input', () => {
const result = buildFontFamily('Consolas')
expect(result).toBe(
'"Consolas", "SF Mono", "Menlo", "Monaco", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace'
'"Consolas", "SF Mono", "Menlo", "Monaco", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "D2Coding", "NanumGothicCoding", "나눔고딕코딩", "Sarasa Mono K", "Noto Sans Mono CJK KR", "Apple SD Gothic Neo", "Apple SD 산돌고딕 Neo", "Malgun Gothic", "맑은 고딕", "MS Gothic", "MS ゴシック", "Hiragino Sans", "ヒラギノ角ゴシック", monospace'
)
})

it('does not duplicate MesloLGS Nerd Font when it is the input', () => {
const result = buildFontFamily('MesloLGS Nerd Font')
expect(result).toBe(
'"MesloLGS Nerd Font", "SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace'
'"MesloLGS Nerd Font", "SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", "JetBrainsMono Nerd Font", "Hack Nerd Font", "D2Coding", "NanumGothicCoding", "나눔고딕코딩", "Sarasa Mono K", "Noto Sans Mono CJK KR", "Apple SD Gothic Neo", "Apple SD 산돌고딕 Neo", "Malgun Gothic", "맑은 고딕", "MS Gothic", "MS ゴシック", "Hiragino Sans", "ヒラギノ角ゴシック", monospace'
)
})

it('does not duplicate the bundled Nerd Font symbol fallback', () => {
const result = buildFontFamily('Orca Nerd Font Symbols')
expect(result).toBe(
'"Orca Nerd Font Symbols", "SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace'
'"Orca Nerd Font Symbols", "SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "D2Coding", "NanumGothicCoding", "나눔고딕코딩", "Sarasa Mono K", "Noto Sans Mono CJK KR", "Apple SD Gothic Neo", "Apple SD 산돌고딕 Neo", "Malgun Gothic", "맑은 고딕", "MS Gothic", "MS ゴシック", "Hiragino Sans", "ヒラギノ角ゴシック", monospace'
)
})

// Without one of these the browser substitutes a proportional face for Hangul,
// whose advance is not two cells wide, and CJK output drifts out of the grid.
it.each(['D2Coding', 'Noto Sans Mono CJK KR', 'Malgun Gothic', 'Apple SD Gothic Neo'])(
'carries the CJK-capable fallback %s',
(font) => {
expect(buildFontFamily('')).toContain(`"${font}"`)
}
)

it('keeps every CJK fallback behind the Latin monospace fonts', () => {
const chain = buildFontFamily('')
expect(chain.indexOf('"Hack Nerd Font"')).toBeLessThan(chain.indexOf('"D2Coding"'))
})

it('is the same chain the default pane options use', () => {
expect(DEFAULT_TERMINAL_FONT_FAMILY).toBe(buildFontFamily(''))
expect(buildDefaultTerminalOptions().fontFamily).toBe(buildFontFamily(''))
})
})

// ---------------------------------------------------------------------------
Expand Down
37 changes: 4 additions & 33 deletions src/renderer/src/components/terminal-pane/layout-serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,45 +22,16 @@ export {
normalizeTerminalLayoutSnapshot
} from './terminal-layout-leaf-ids'

// Re-exported from lib so lib-layer pane defaults can share the one chain without
// importing back into components.
export { buildFontFamily } from '@/lib/terminal-font-family'

export const EMPTY_LAYOUT: TerminalLayoutSnapshot = {
root: null,
activeLeafId: null,
expandedLeafId: null
}

// Cross-platform monospace chain: browsers skip fonts absent on the current OS, so listing all is safe.
// Nerd Fonts come last to cover PUA glyphs (U+E000–U+F8FF) from OMP/Powerline that standard monospace fonts lack.
const FALLBACK_FONTS = [
'SF Mono', // macOS 10.12+
'Menlo', // macOS (older)
'Monaco', // macOS (legacy)
'Cascadia Mono', // Windows 11+
'Consolas', // Windows Vista+
'DejaVu Sans Mono', // Linux (common)
'Liberation Mono', // Linux (common)
'Orca Nerd Font Symbols', // bundled PUA fallback for OMP/Powerline glyphs
'Symbols Nerd Font Mono', // purpose-built Nerd Fonts symbols-only fallback
'MesloLGS Nerd Font', // p10k's recommended font; very common on zsh setups
'JetBrainsMono Nerd Font', // widely installed; Ghostty ships a JBM-derived font
'Hack Nerd Font', // common Nerd Font among Linux developers
'monospace' // ultimate generic fallback
] as const

export function buildFontFamily(fontFamily: string): string {
const trimmed = fontFamily.trim()
const parts = trimmed ? [`"${trimmed}"`] : []
const lowerParts = parts.map((p) => p.toLowerCase())
// Append each fallback unless already present (case-insensitive) to avoid duplicates.
for (const fallback of FALLBACK_FONTS) {
const lower = fallback.toLowerCase()
if (!lowerParts.some((p) => p.includes(lower))) {
// Generic keywords like "monospace" are unquoted; named fonts are quoted.
parts.push(fallback === 'monospace' ? fallback : `"${fallback}"`)
}
}
return parts.join(', ')
}

export function getLayoutChildNodes(split: HTMLElement): HTMLElement[] {
return Array.from(split.children).filter(
(child): child is HTMLElement =>
Expand Down
5 changes: 2 additions & 3 deletions src/renderer/src/lib/pane-manager/pane-terminal-options.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ITerminalOptions } from '@xterm/xterm'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../../../shared/terminal-scrollback-policy'
import { LIGHT_BG_MIN_CONTRAST } from '@/lib/terminal-contrast-correction'
import { DEFAULT_TERMINAL_FONT_FAMILY } from '@/lib/terminal-font-family'

type TerminalCursorStyle = NonNullable<ITerminalOptions['cursorStyle']>
type TerminalCursorInactiveStyle = NonNullable<ITerminalOptions['cursorInactiveStyle']>
Expand Down Expand Up @@ -37,9 +38,7 @@ export function buildDefaultTerminalOptions(): ITerminalOptions {
cursorStyle,
cursorInactiveStyle: resolveTerminalCursorInactiveStyle(cursorStyle),
fontSize: 14,
// Cross-platform fallback chain; keep in sync with FALLBACK_FONTS in layout-serialization.ts.
fontFamily:
'"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", "MesloLGS Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", monospace',
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontWeight: '300',
fontWeightBold: '500',
scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT,
Expand Down
39 changes: 39 additions & 0 deletions src/renderer/src/lib/pane-manager/xterm-webgl-atlas-patch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'

/**
* Orca's fixes for the WebGL glyph-atlas thrash live in
* config/patches/@xterm__addon-webgl@*.patch, hand-applied to the shipped
* bundles. A version bump that regenerates the patch drops them silently and the
* only symptom is garbled CJK panes in production, so gate them here.
*/
const require = createRequire(import.meta.url)
const packageRoot = dirname(require.resolve('@xterm/addon-webgl/package.json'))
const BUNDLES = ['lib/addon-webgl.mjs', 'lib/addon-webgl.js'] as const

function readBundle(relativePath: string): string {
return readFileSync(join(packageRoot, relativePath), 'utf8')
}

describe.each(BUNDLES)('@xterm/addon-webgl bundle %s', (relativePath) => {
it('still carries the merge-retry loop the exhaustion guard hangs off', () => {
expect(readBundle(relativePath)).toMatch(
/for\(;this\._charAtlas&&\w+\+\+<32&&this\._glyphRenderer\.value\.beginFrame\(\);\)/
)
})

it('requests another frame when the merge-retry budget is exhausted', () => {
// Without this the model stays half-built against page indexes the last
// merge invalidated, and only an unrelated resize ever repairs the pane.
expect(readBundle(relativePath)).toMatch(
/\w+>32\?\(this\._exhaustedMergeFrames=\(this\._exhaustedMergeFrames\|\|0\)\+1\)<=4&&this\._requestRedrawViewport\(\):this\._exhaustedMergeFrames=0/
)
})

it('allocates 1024px atlas pages so a CJK viewport fits the texture budget', () => {
expect(readBundle(relativePath)).toContain('_textureSize=1024')
expect(readBundle(relativePath)).not.toContain('_textureSize=512')
})
})
61 changes: 61 additions & 0 deletions src/renderer/src/lib/terminal-font-family.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Cross-platform monospace chain: browsers skip fonts absent on the current OS, so listing all is safe.
// Nerd Fonts come last to cover PUA glyphs (U+E000–U+F8FF) from OMP/Powerline that standard monospace fonts lack.
const FALLBACK_FONTS = [
'SF Mono', // macOS 10.12+
'Menlo', // macOS (older)
'Monaco', // macOS (legacy)
'Cascadia Mono', // Windows 11+
'Consolas', // Windows Vista+
'DejaVu Sans Mono', // Linux (common)
'Liberation Mono', // Linux (common)
'Orca Nerd Font Symbols', // bundled PUA fallback for OMP/Powerline glyphs
'Symbols Nerd Font Mono', // purpose-built Nerd Fonts symbols-only fallback
'MesloLGS Nerd Font', // p10k's recommended font; very common on zsh setups
'JetBrainsMono Nerd Font', // widely installed; Ghostty ships a JBM-derived font
'Hack Nerd Font', // common Nerd Font among Linux developers
// None of the Latin fonts above carry Hangul/Kana/Han. Without a CJK entry the
// browser substitutes a proportional system face whose advance is not two cells
// wide, so CJK text renders with gaps and drifts out of the grid.
//
// Coding faces first: their Hangul is exactly two Latin advances wide. Then
// platform defaults, native before foreign — none has exact dual-width metrics,
// so a font that merely happens to be installed (e.g. Malgun via Office on a
// Mac) must not outrank the one the OS ships, or two machines of the same
// platform render Hangul differently.
//
// Platform defaults are listed under their English and localized family names:
// a CJK-locale OS registers them under the localized name only, and an entry
// the platform does not know costs nothing in a CSS font stack.
'D2Coding', // Korean coding font; Hangul is exactly 2x the Latin advance
'NanumGothicCoding', // Korean coding font, common on Linux
'나눔고딕코딩',
'Sarasa Mono K', // CJK monospace built for exact dual-width metrics
'Noto Sans Mono CJK KR', // Linux (common)
'Apple SD Gothic Neo', // macOS Korean default — always present on macOS
'Apple SD 산돌고딕 Neo',
'Malgun Gothic', // Windows Korean default
'맑은 고딕',
'MS Gothic', // Windows; dual-width, but Japanese-first
'MS ゴシック',
'Hiragino Sans', // macOS Japanese default
'ヒラギノ角ゴシック',
'monospace' // ultimate generic fallback
] as const

export function buildFontFamily(fontFamily: string): string {
const trimmed = fontFamily.trim()
const parts = trimmed ? [`"${trimmed}"`] : []
const lowerParts = parts.map((p) => p.toLowerCase())
// Append each fallback unless already present (case-insensitive) to avoid duplicates.
for (const fallback of FALLBACK_FONTS) {
const lower = fallback.toLowerCase()
if (!lowerParts.some((p) => p.includes(lower))) {
// Generic keywords like "monospace" are unquoted; named fonts are quoted.
parts.push(fallback === 'monospace' ? fallback : `"${fallback}"`)
}
}
return parts.join(', ')
}

/** The chain with no user font in front — the default for panes that carry no font setting. */
export const DEFAULT_TERMINAL_FONT_FAMILY = buildFontFamily('')
Loading