diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index bb21c0d0..a8fdb7c6 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4,6 +4,7 @@ import AppKit import Metal import QuartzCore import Combine +import CoreText import Darwin import Sentry import Bonsplit @@ -1365,15 +1366,12 @@ class GhosttyApp { /// 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 without overriding - /// user-managed fallback chains. + /// user-managed fallback chains or configured fonts that already cover + /// the affected CJK ranges. /// /// See: https://github.com/manaflow-ai/cmux/pull/1017 private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) { - let userFontConfig = Self.userFontConfigSummary() - if userFontConfig.containsCodepointMap { return } - if userFontConfig.hasExplicitFontFamilyFallbackChain { return } - - guard let mappings = Self.cjkFontMappings() else { return } + guard let mappings = Self.autoInjectedCJKFontMappings() else { return } let lines = mappings.map { range, font in "font-codepoint-map = \(range)=\(font)" @@ -1409,6 +1407,21 @@ class GhosttyApp { "U+30A0-U+30FF", // Katakana ] + /// Representative scalars used to detect whether the configured primary + /// font already covers the ranges cmux would otherwise auto-map. + private static let cjkCoverageSampleCharactersByRange: [String: [UniChar]] = [ + "U+3000-U+303F": [0x3001, 0x300C], + "U+4E00-U+9FFF": [0x4E00, 0x65E5, 0x6C34], + "U+F900-U+FAFF": [0xF900], + "U+FF00-U+FFEF": [0xFF10, 0xFF21], + "U+3400-U+4DBF": [0x3400], + "U+1100-U+11FF": [0x1100, 0x1161], + "U+3130-U+318F": [0x3131, 0x314F], + "U+3040-U+309F": [0x3042, 0x3093], + "U+30A0-U+30FF": [0x30A2, 0x30F3], + "U+AC00-U+D7AF": [0xAC00, 0xD55C], + ] + private struct UserFontConfigSummary { var containsCodepointMap = false var effectiveFontFamilies: [String] = [] @@ -1485,6 +1498,38 @@ class GhosttyApp { return mappings.isEmpty ? nil : mappings } + /// Returns only the CJK mappings cmux should auto-inject after respecting + /// explicit user overrides and the glyph coverage of the configured + /// primary font family. + static func autoInjectedCJKFontMappings( + preferredLanguages: [String] = Locale.preferredLanguages, + configPaths: [String] = loadedCJKScanPaths(), + rangeCoverageProbe: ((String, String) -> Bool)? = nil + ) -> [(String, String)]? { + guard var mappings = cjkFontMappings(preferredLanguages: preferredLanguages) else { return nil } + + let summary = userFontConfigSummary(configPaths: configPaths) + if summary.containsCodepointMap || summary.hasExplicitFontFamilyFallbackChain { + return nil + } + + guard let configuredFontFamily = summary.effectiveFontFamilies.first else { + return mappings + } + + if let rangeCoverageProbe { + mappings.removeAll { range, _ in + rangeCoverageProbe(configuredFontFamily, range) + } + } else if let configuredFont = configuredCTFont(named: configuredFontFamily) { + mappings.removeAll { range, _ in + fontContainsGlyphs(configuredFont, forRange: range) + } + } + + 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. @@ -1502,11 +1547,58 @@ class GhosttyApp { static func shouldInjectCJKFontFallback( preferredLanguages: [String] = Locale.preferredLanguages, - configPaths: [String] = loadedCJKScanPaths() + configPaths: [String] = loadedCJKScanPaths(), + rangeCoverageProbe: ((String, String) -> Bool)? = nil ) -> Bool { - guard cjkFontMappings(preferredLanguages: preferredLanguages) != nil else { return false } - let summary = userFontConfigSummary(configPaths: configPaths) - return !summary.containsCodepointMap && !summary.hasExplicitFontFamilyFallbackChain + autoInjectedCJKFontMappings( + preferredLanguages: preferredLanguages, + configPaths: configPaths, + rangeCoverageProbe: rangeCoverageProbe + ) != nil + } + + private static func configuredCTFont( + named name: String, + size: CGFloat = 12 + ) -> CTFont? { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let font = CTFontCreateWithName(trimmed as CFString, size, nil) + let normalizedRequestedName = normalizedFontName(trimmed) + let resolvedNames = [ + kCTFontFamilyNameKey, + kCTFontFullNameKey, + kCTFontPostScriptNameKey, + ].compactMap { CTFontCopyName(font, $0) as String? } + + guard resolvedNames.contains(where: { normalizedFontName($0) == normalizedRequestedName }) else { + return nil + } + + return font + } + + private static func fontContainsGlyphs( + _ font: CTFont, + forRange range: String + ) -> Bool { + guard let characters = cjkCoverageSampleCharactersByRange[range] else { + return false + } + + var glyphs = Array(repeating: CGGlyph(), count: characters.count) + let hasGlyphs = CTFontGetGlyphsForCharacters(font, characters, &glyphs, characters.count) + return hasGlyphs && !glyphs.contains(0) + } + + private static func normalizedFontName(_ name: String) -> String { + name + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + .folding(options: [.diacriticInsensitive, .widthInsensitive], locale: Locale(identifier: "en_US_POSIX")) + .lowercased() } private static func userFontConfigSummary( diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index d7820ce3..f3cbdc07 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2138,6 +2138,54 @@ final class GhosttyMouseFocusTests: XCTestCase { XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino") } + // MARK: autoInjectedCJKFontMappings + + func testAutoInjectedCJKFontMappingsSkipsRangesCoveredByConfiguredPrimaryFont() throws { + let coveredRanges: Set = [ + "U+3000-U+303F", + "U+4E00-U+9FFF", + "U+F900-U+FAFF", + "U+FF00-U+FFEF", + "U+3400-U+4DBF", + ] + + try withTempConfig("font-family = Sarasa Mono K\n") { path in + XCTAssertNil( + GhosttyApp.autoInjectedCJKFontMappings( + preferredLanguages: ["zh-Hans-CN"], + configPaths: [path], + rangeCoverageProbe: { fontFamily, range in + XCTAssertEqual(fontFamily, "Sarasa Mono K") + return coveredRanges.contains(range) + } + ) + ) + } + } + + func testAutoInjectedCJKFontMappingsKeepsOnlyUncoveredRanges() throws { + let coveredRanges: Set = [ + "U+3000-U+303F", + "U+4E00-U+9FFF", + "U+F900-U+FAFF", + "U+FF00-U+FFEF", + "U+3400-U+4DBF", + ] + + try withTempConfig("font-family = Example CJK Mono\n") { path in + let mappings = GhosttyApp.autoInjectedCJKFontMappings( + preferredLanguages: ["ja-JP"], + configPaths: [path], + rangeCoverageProbe: { _, range in + coveredRanges.contains(range) + } + )! + + XCTAssertEqual(Set(mappings.map(\.0)), Set(["U+3040-U+309F", "U+30A0-U+30FF"])) + XCTAssertEqual(Set(mappings.map(\.1)), Set(["Hiragino Sans"])) + } + } + // MARK: userConfigContainsCJKCodepointMap func testUserConfigContainsCJKCodepointMapDetectsPresence() throws { @@ -2386,6 +2434,29 @@ final class GhosttyMouseFocusTests: XCTestCase { } } + func testShouldInjectCJKFontFallbackSkipsConfiguredFontThatAlreadyCoversMappedRanges() throws { + let coveredRanges: Set = [ + "U+3000-U+303F", + "U+4E00-U+9FFF", + "U+F900-U+FAFF", + "U+FF00-U+FFEF", + "U+3400-U+4DBF", + ] + + try withTempConfig("font-family = Sarasa Mono K\n") { path in + XCTAssertFalse( + GhosttyApp.shouldInjectCJKFontFallback( + preferredLanguages: ["zh-Hans-CN"], + configPaths: [path], + rangeCoverageProbe: { fontFamily, range in + XCTAssertEqual(fontFamily, "Sarasa Mono K") + return coveredRanges.contains(range) + } + ) + ) + } + } + func testLoadedCJKScanPathsSkipsReleaseAppSupportWhenTaggedConfigExists() throws { let appSupport = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-test-cjk-app-support-\(UUID().uuidString)")