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
188 changes: 188 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1000,9 +1000,197 @@ class GhosttyApp {
loadReleaseAppSupportGhosttyConfigIfNeeded(config)
loadLegacyGhosttyConfigIfNeeded(config)
ghostty_config_load_recursive_files(config)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
loadCJKFontFallbackIfNeeded(config)
ghostty_config_finalize(config)
}

/// When the user has not configured `font-codepoint-map` for CJK ranges,
/// Ghostty's `CTFontCollection` scoring may pick an inappropriate fallback
/// font for Hiragana, Katakana, and CJK symbols. The scoring prioritizes
/// monospace fonts, so decorative fonts with monospace attributes (e.g.
/// AB_appare from Adobe CC, or LingWai) can be selected depending on what
/// is installed. This injects a sensible default based on the system's
/// preferred languages.
///
/// See: https://github.com/manaflow-ai/cmux/pull/1017
private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) {
if Self.userConfigContainsCJKCodepointMap() { return }

guard let mappings = Self.cjkFontMappings() else { return }

let lines = mappings.map { range, font in
"font-codepoint-map = \(range)=\(font)"
}.joined(separator: "\n")

let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf")
do {
try lines.write(to: tmpURL, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpURL) }
tmpURL.path.withCString { path in
ghostty_config_load_file(config, path)
}
} catch {
#if DEBUG
Self.initLog("failed to write CJK font fallback config: \(error)")
#endif
}
}

/// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms).
private static let sharedCJKRanges = [
"U+3000-U+303F", // CJK Symbols and Punctuation
"U+4E00-U+9FFF", // CJK Unified Ideographs
"U+F900-U+FAFF", // CJK Compatibility Ideographs
"U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms
"U+3400-U+4DBF", // CJK Unified Ideographs Extension A
]

/// Unicode ranges specific to Japanese (kana).
private static let japaneseRanges = [
"U+3040-U+309F", // Hiragana
"U+30A0-U+30FF", // Katakana
]

/// Unicode ranges specific to Korean (Hangul).
private static let koreanRanges = [
"U+AC00-U+D7AF", // Hangul Syllables
"U+1100-U+11FF", // Hangul Jamo
]

/// Returns (range, font) pairs for CJK font fallback based on the system's
/// preferred languages, or nil if no CJK language is detected. Each language
/// only maps its own script ranges to avoid assigning glyphs to a font that
/// lacks coverage (e.g. Hangul to Hiragino Sans).
static func cjkFontMappings(
preferredLanguages: [String] = Locale.preferredLanguages
) -> [(String, String)]? {
var mappings: [(String, String)] = []
var coveredShared = false

for lang in preferredLanguages {
let lower = lang.lowercased()
let font: String
var langRanges: [String] = []

if lower.hasPrefix("ja") {
font = "Hiragino Sans"
langRanges = japaneseRanges
} else if lower.hasPrefix("ko") {
font = "Apple SD Gothic Neo"
langRanges = koreanRanges
} else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") {
font = "PingFang TC"
} else if lower.hasPrefix("zh") {
font = "PingFang SC"
} else {
continue
}

if !coveredShared {
for range in sharedCJKRanges {
mappings.append((range, font))
}
coveredShared = true
}

for range in langRanges {
mappings.append((range, font))
}
}

return mappings.isEmpty ? nil : mappings
}

/// Checks whether the user's Ghostty config files already contain
/// a `font-codepoint-map` entry covering CJK ranges. Also checks
/// application-support config paths that cmux may load at runtime.
static func userConfigContainsCJKCodepointMap(
configPaths: [String] = defaultCJKScanPaths()
) -> Bool {
var visited = Set<String>()
for rawPath in configPaths {
let path = NSString(string: rawPath).expandingTildeInPath
if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) {
return true
}
}
return false
}

/// Returns the default set of config paths to scan for existing
/// `font-codepoint-map` entries. Includes both the standard Ghostty
/// config locations and any app-support paths that cmux may load.
private static func defaultCJKScanPaths() -> [String] {
var paths = [
"~/.config/ghostty/config",
"~/.config/ghostty/config.ghostty",
"~/Library/Application Support/com.mitchellh.ghostty/config",
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
]
if let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first {
let releaseDir = appSupport.appendingPathComponent(releaseBundleIdentifier)
paths.append(releaseDir.appendingPathComponent("config").path)
paths.append(releaseDir.appendingPathComponent("config.ghostty").path)

if let bundleId = Bundle.main.bundleIdentifier, bundleId != releaseBundleIdentifier {
let currentDir = appSupport.appendingPathComponent(bundleId)
paths.append(currentDir.appendingPathComponent("config").path)
paths.append(currentDir.appendingPathComponent("config.ghostty").path)
}
}
return paths
}

/// Scans a single config file (and any files it includes) for
/// `font-codepoint-map` entries. Tracks visited paths to prevent
/// infinite recursion on cyclic includes.
private static func configFileContainsCodepointMap(
atPath path: String,
visited: inout Set<String>
) -> Bool {
let resolved = (path as NSString).standardizingPath
guard !visited.contains(resolved) else { return false }
visited.insert(resolved)

guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
return false
}
let parentDir = (resolved as NSString).deletingLastPathComponent

for line in contents.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("#") { continue }
if trimmed.hasPrefix("font-codepoint-map") {
return true
}
if trimmed.hasPrefix("config-file") {
let parts = trimmed.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
var includePath = parts[1]
.trimmingCharacters(in: .whitespaces)
// Ghostty supports optional includes with a trailing '?'
if includePath.hasSuffix("?") {
includePath.removeLast()
}
Comment thread
atani marked this conversation as resolved.
includePath = includePath
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
let expanded = NSString(string: includePath).expandingTildeInPath
let absolute = (expanded as NSString).isAbsolutePath
? expanded
: (parentDir as NSString).appendingPathComponent(expanded)
if configFileContainsCodepointMap(atPath: absolute, visited: &visited) {
return true
}
}
}
}
return false
}

static func shouldLoadLegacyGhosttyConfig(
newConfigFileSize: Int?,
legacyConfigFileSize: Int?
Expand Down
165 changes: 165 additions & 0 deletions cmuxTests/GhosttyConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1339,4 +1339,169 @@ final class GhosttyMouseFocusTests: XCTestCase {
)
)
}

// MARK: - CJK Font Fallback

private func withTempConfig(
_ contents: String,
body: (String) -> Void
) throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }

let file = dir.appendingPathComponent("config")
try contents.write(to: file, atomically: true, encoding: .utf8)
body(file.path)
}

// MARK: cjkFontMappings

func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])!
let fonts = Set(mappings.map(\.1))
let ranges = mappings.map(\.0)

XCTAssertTrue(fonts.contains("Hiragino Sans"))
XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana")
XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana")
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul")
}

func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])!
let fonts = Set(mappings.map(\.1))
let ranges = mappings.map(\.0)

XCTAssertTrue(fonts.contains("Apple SD Gothic Neo"))
XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables")
XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo")
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana")
}

func testCJKFontMappingsReturnsPingFangForChinese() {
let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])!
XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" })

let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])!
XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" })

let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])!
XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" })
}

func testCJKFontMappingsReturnsNilForNonCJKLanguages() {
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"]))
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: []))
}

func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() {
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])!

let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0)
let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0)

XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino")
XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font")
XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo")
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
}

// MARK: userConfigContainsCJKCodepointMap

func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}

func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws {
try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}

func testUserConfigContainsCJKCodepointMapIgnoresComments() throws {
try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
}
}

func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config"
XCTAssertFalse(
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
)
Comment thread
atani marked this conversation as resolved.
}

func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }

let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)

let main = dir.appendingPathComponent("config")
try "font-family = Menlo\nconfig-file = \(included.path)\n"
.write(to: main, atomically: true, encoding: .utf8)

XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}

func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }

let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)

let main = dir.appendingPathComponent("config")
try "config-file = fonts.conf\n"
.write(to: main, atomically: true, encoding: .utf8)

XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}

func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }

let included = dir.appendingPathComponent("fonts.conf")
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
.write(to: included, atomically: true, encoding: .utf8)

let main = dir.appendingPathComponent("config")
try "config-file = \(included.path)?\n"
.write(to: main, atomically: true, encoding: .utf8)

XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
}

func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }

let fileA = dir.appendingPathComponent("a.conf")
let fileB = dir.appendingPathComponent("b.conf")
try "config-file = \(fileB.path)\n"
.write(to: fileA, atomically: true, encoding: .utf8)
try "config-file = \(fileA.path)\n"
.write(to: fileB, atomically: true, encoding: .utf8)

// Should not hang; should return false since neither file has font-codepoint-map
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
}
}