Skip to content

Commit 9f89110

Browse files
committed
fix: align CJK fallback scan with Ghostty config semantics
1 parent 12c1377 commit 9f89110

2 files changed

Lines changed: 136 additions & 43 deletions

File tree

Sources/GhosttyTerminalView.swift

Lines changed: 99 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,19 @@ class GhosttyApp {
10991099
effectiveFontFamilies.count > 1
11001100
}
11011101

1102+
mutating func applyFontCodepointMap(_ value: String) {
1103+
if value.isEmpty {
1104+
containsCodepointMap = false
1105+
return
1106+
}
1107+
1108+
guard value.contains("=") else {
1109+
return
1110+
}
1111+
1112+
containsCodepointMap = true
1113+
}
1114+
11021115
mutating func recordFontFamily(_ value: String) {
11031116
if value.isEmpty {
11041117
effectiveFontFamilies.removeAll()
@@ -1185,18 +1198,30 @@ class GhosttyApp {
11851198
configPaths: [String] = loadedCJKScanPaths()
11861199
) -> UserFontConfigSummary {
11871200
var summary = UserFontConfigSummary()
1188-
var visited = Set<String>()
1189-
var pendingPaths = configPaths.map { NSString(string: $0).expandingTildeInPath }
1190-
var index = 0
1201+
var recursiveConfigPaths: [String] = []
11911202

1192-
while index < pendingPaths.count {
1193-
let includePaths = scanFontConfigFile(
1194-
atPath: pendingPaths[index],
1195-
visited: &visited,
1196-
summary: &summary
1203+
for path in configPaths.map({ NSString(string: $0).expandingTildeInPath }) {
1204+
scanFontConfigFile(
1205+
atPath: path,
1206+
summary: &summary,
1207+
recursiveConfigPaths: &recursiveConfigPaths
11971208
)
1198-
pendingPaths.append(contentsOf: includePaths)
1209+
}
1210+
1211+
var loadedRecursivePaths = Set<String>()
1212+
var index = 0
1213+
while index < recursiveConfigPaths.count {
1214+
let path = recursiveConfigPaths[index]
11991215
index += 1
1216+
let resolved = (path as NSString).standardizingPath
1217+
guard !loadedRecursivePaths.contains(resolved) else { continue }
1218+
loadedRecursivePaths.insert(resolved)
1219+
1220+
scanFontConfigFile(
1221+
atPath: path,
1222+
summary: &summary,
1223+
recursiveConfigPaths: &recursiveConfigPaths
1224+
)
12001225
}
12011226

12021227
return summary
@@ -1266,61 +1291,93 @@ class GhosttyApp {
12661291
return size.intValue
12671292
}
12681293

1269-
/// Scans a single config file (and any files it includes) for
1270-
/// font settings relevant to cmux's injected CJK fallback. Tracks visited
1271-
/// paths to prevent infinite recursion on cyclic includes.
1294+
/// Scans a single config file for font settings relevant to cmux's
1295+
/// injected CJK fallback and updates the pending recursive config-file
1296+
/// queue using Ghostty's repeatable path semantics.
12721297
private static func scanFontConfigFile(
12731298
atPath path: String,
1274-
visited: inout Set<String>,
1275-
summary: inout UserFontConfigSummary
1276-
) -> [String] {
1299+
summary: inout UserFontConfigSummary,
1300+
recursiveConfigPaths: inout [String]
1301+
) {
12771302
let resolved = (path as NSString).standardizingPath
1278-
guard !visited.contains(resolved) else { return [] }
1279-
visited.insert(resolved)
1280-
12811303
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
1282-
return []
1304+
return
12831305
}
12841306
let parentDir = (resolved as NSString).deletingLastPathComponent
1285-
var includePaths: [String] = []
12861307

12871308
for line in contents.components(separatedBy: .newlines) {
1288-
let trimmed = line.trimmingCharacters(in: .whitespaces)
1289-
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
1290-
if trimmed.hasPrefix("font-codepoint-map") {
1291-
summary.containsCodepointMap = true
1292-
}
1293-
if let fontFamily = configValue(for: "font-family", in: trimmed) {
1294-
summary.recordFontFamily(fontFamily)
1295-
}
1296-
if let includePath = configIncludePath(from: trimmed, parentDir: parentDir) {
1297-
includePaths.append(includePath)
1309+
guard let entry = parsedConfigEntry(from: line) else { continue }
1310+
1311+
switch entry.key {
1312+
case "font-codepoint-map":
1313+
guard let value = entry.value else { continue }
1314+
summary.applyFontCodepointMap(value)
1315+
case "font-family":
1316+
guard let value = entry.value else { continue }
1317+
summary.recordFontFamily(value)
1318+
case "config-file":
1319+
guard let value = entry.value else { continue }
1320+
applyConfigFileDirective(
1321+
value,
1322+
parentDir: parentDir,
1323+
recursiveConfigPaths: &recursiveConfigPaths
1324+
)
1325+
default:
1326+
continue
12981327
}
12991328
}
1300-
1301-
return includePaths
13021329
}
13031330

1304-
private static func configValue(for key: String, in line: String) -> String? {
1305-
guard let separatorIndex = line.firstIndex(of: "=") else { return nil }
1331+
private static func parsedConfigEntry(
1332+
from rawLine: String
1333+
) -> (key: String, value: String?)? {
1334+
var trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
1335+
if trimmed.hasPrefix("\u{FEFF}") {
1336+
trimmed.removeFirst()
1337+
}
1338+
if trimmed.isEmpty || trimmed.hasPrefix("#") { return nil }
13061339

1307-
let parsedKey = line[..<separatorIndex].trimmingCharacters(in: .whitespacesAndNewlines)
1308-
guard parsedKey == key else { return nil }
1340+
guard let separatorIndex = trimmed.firstIndex(of: "=") else {
1341+
return (trimmed.trimmingCharacters(in: .whitespacesAndNewlines), nil)
1342+
}
13091343

1310-
return line[line.index(after: separatorIndex)...]
1344+
let key = trimmed[..<separatorIndex].trimmingCharacters(in: .whitespacesAndNewlines)
1345+
var value = trimmed[trimmed.index(after: separatorIndex)...]
13111346
.trimmingCharacters(in: .whitespacesAndNewlines)
1312-
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
1347+
1348+
if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") {
1349+
value.removeFirst()
1350+
value.removeLast()
1351+
}
1352+
1353+
return (String(key), String(value))
13131354
}
13141355

1315-
private static func configIncludePath(from line: String, parentDir: String) -> String? {
1316-
guard var includePath = configValue(for: "config-file", in: line) else { return nil }
1317-
if includePath.hasSuffix("?") {
1356+
private static func applyConfigFileDirective(
1357+
_ value: String,
1358+
parentDir: String,
1359+
recursiveConfigPaths: inout [String]
1360+
) {
1361+
if value.isEmpty {
1362+
recursiveConfigPaths.removeAll()
1363+
return
1364+
}
1365+
1366+
var includePath = value
1367+
if includePath.hasPrefix("?") {
1368+
includePath.removeFirst()
1369+
}
1370+
if includePath.count >= 2, includePath.hasPrefix("\""), includePath.hasSuffix("\"") {
1371+
includePath.removeFirst()
13181372
includePath.removeLast()
13191373
}
1374+
guard !includePath.isEmpty else { return }
1375+
13201376
let expanded = NSString(string: includePath).expandingTildeInPath
1321-
return (expanded as NSString).isAbsolutePath
1377+
let absolute = (expanded as NSString).isAbsolutePath
13221378
? expanded
13231379
: (parentDir as NSString).appendingPathComponent(expanded)
1380+
recursiveConfigPaths.append(absolute)
13241381
}
13251382

13261383
static func shouldLoadLegacyGhosttyConfig(

cmuxTests/GhosttyConfigTests.swift

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1436,7 +1436,7 @@ final class GhosttyMouseFocusTests: XCTestCase {
14361436
.write(to: included, atomically: true, encoding: .utf8)
14371437

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

14421442
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
@@ -1459,6 +1459,17 @@ final class GhosttyMouseFocusTests: XCTestCase {
14591459
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
14601460
}
14611461

1462+
func testUserConfigContainsCJKCodepointMapRespectsReset() throws {
1463+
try withTempConfig("""
1464+
font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans
1465+
font-codepoint-map =
1466+
""") { path in
1467+
XCTAssertFalse(
1468+
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
1469+
)
1470+
}
1471+
}
1472+
14621473
// MARK: userConfigHasExplicitFontFamilyFallbackChain
14631474

14641475
func testUserConfigHasExplicitFontFamilyFallbackChainDetectsMultipleEntries() throws {
@@ -1549,6 +1560,31 @@ final class GhosttyMouseFocusTests: XCTestCase {
15491560
)
15501561
}
15511562

1563+
func testUserConfigHasExplicitFontFamilyFallbackChainRespectsConfigFileReset() throws {
1564+
let dir = FileManager.default.temporaryDirectory
1565+
.appendingPathComponent("cmux-test-cjk-font-family-config-file-reset-\(UUID().uuidString)")
1566+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
1567+
defer { try? FileManager.default.removeItem(at: dir) }
1568+
1569+
let included = dir.appendingPathComponent("fonts.conf")
1570+
try "font-family = LXGW WenKai Mono TC\n"
1571+
.write(to: included, atomically: true, encoding: .utf8)
1572+
1573+
let main = dir.appendingPathComponent("config")
1574+
try "font-family = JetBrains Mono\nconfig-file = \(included.path)\n"
1575+
.write(to: main, atomically: true, encoding: .utf8)
1576+
1577+
let reset = dir.appendingPathComponent("config.ghostty")
1578+
try "config-file =\n"
1579+
.write(to: reset, atomically: true, encoding: .utf8)
1580+
1581+
XCTAssertFalse(
1582+
GhosttyApp.userConfigHasExplicitFontFamilyFallbackChain(
1583+
configPaths: [main.path, reset.path]
1584+
)
1585+
)
1586+
}
1587+
15521588
// MARK: shouldInjectCJKFontFallback
15531589

15541590
func testShouldInjectCJKFontFallbackSkipsExplicitMultiFontFallbackChain() throws {

0 commit comments

Comments
 (0)