diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a74ec21e..23f24205 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1000,9 +1000,197 @@ class GhosttyApp { loadReleaseAppSupportGhosttyConfigIfNeeded(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) + 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() + 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 + ) -> 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() + } + 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? diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 53119273..e229b761 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1293,4 +1293,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]) + ) + } + + 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])) + } }