diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 2674a3af8ab8..667ac64fb608 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -27,13 +27,29 @@ struct GhosttyConfig { // Colors (from theme or config) var backgroundColor: NSColor = NSColor(hex: "#272822")! + var hasBackgroundColorDirective = false + var hasParsedBackgroundColor = false var backgroundOpacity: Double = 1.0 + var hasBackgroundOpacityDirective = false + var hasParsedBackgroundOpacity = false var backgroundBlur: GhosttyBackgroundBlur = .disabled + var hasBackgroundBlurDirective = false + var hasParsedBackgroundBlur = false var foregroundColor: NSColor = NSColor(hex: "#fdfff1")! + var hasForegroundColorDirective = false + var hasParsedForegroundColor = false var cursorColor: NSColor = NSColor(hex: "#c0c1b5")! + var hasCursorColorDirective = false + var hasParsedCursorColor = false var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")! + var hasCursorTextColorDirective = false + var hasParsedCursorTextColor = false var selectionBackground: NSColor = NSColor(hex: "#57584f")! + var hasSelectionBackgroundDirective = false + var hasParsedSelectionBackground = false var selectionForeground: NSColor = NSColor(hex: "#fdfff1")! + var hasSelectionForegroundDirective = false + var hasParsedSelectionForeground = false // Sidebar appearance var rawSidebarBackground: String? @@ -168,24 +184,34 @@ struct GhosttyConfig { var config = GhosttyConfig() // Match Ghostty's default load order on macOS. - let configPaths = [ + let appSupportGhosttyDirectory = NSString( + string: "~/Library/Application Support/com.mitchellh.ghostty" + ).expandingTildeInPath + let appSupportConfigGhostty = (appSupportGhosttyDirectory as NSString) + .appendingPathComponent("config.ghostty") + let appSupportLegacyConfig = (appSupportGhosttyDirectory as NSString) + .appendingPathComponent("config") + var configPaths = [ "~/.config/ghostty/config", "~/.config/ghostty/config.ghostty", - "~/Library/Application Support/com.mitchellh.ghostty/config", - "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", - ].map { NSString(string: $0).expandingTildeInPath } + cmuxConfigPaths() + ].map { NSString(string: $0).expandingTildeInPath } + configPaths.append(appSupportConfigGhostty) + if shouldIncludeLegacyGhosttyConfigInResolvedLoad( + newConfigFileSize: configFileSize(at: appSupportConfigGhostty), + legacyConfigFileSize: configFileSize(at: appSupportLegacyConfig) + ) { + configPaths.append(appSupportLegacyConfig) + } + configPaths.append(contentsOf: cmuxConfigPaths()) #if DEBUG let startupPreviewProfile = GhosttyStartupAppearancePreviewState.profile if startupPreviewProfile.loadsRealUserConfig { - for path in configPaths { - if let contents = readConfigFile(at: path) { - config.parse( - contents, - loadingThemesImmediatelyFor: preferredColorScheme - ) - } - } + loadConfigFiles( + configPaths, + into: &config, + preferredColorScheme: preferredColorScheme + ) if config.theme == nil, GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: configPaths) { @@ -204,14 +230,11 @@ struct GhosttyConfig { ) } #else - for path in configPaths { - if let contents = readConfigFile(at: path) { - config.parse( - contents, - loadingThemesImmediatelyFor: preferredColorScheme - ) - } - } + loadConfigFiles( + configPaths, + into: &config, + preferredColorScheme: preferredColorScheme + ) if config.theme == nil, GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: configPaths) { @@ -388,36 +411,68 @@ struct GhosttyConfig { scrollbackLimit = limit } case "background": + hasBackgroundColorDirective = true if let color = NSColor(hex: value) { backgroundColor = color + hasParsedBackgroundColor = true + } else { + hasParsedBackgroundColor = false } case "background-opacity": + hasBackgroundOpacityDirective = true if let opacity = Double(value) { - backgroundOpacity = opacity + backgroundOpacity = min(1.0, max(0.0, opacity)) + hasParsedBackgroundOpacity = true + } else { + hasParsedBackgroundOpacity = false } case "background-blur": + hasBackgroundBlurDirective = true if let parsedBlur = Self.parseBackgroundBlur(value) { backgroundBlur = parsedBlur + hasParsedBackgroundBlur = true + } else { + hasParsedBackgroundBlur = false } case "foreground": + hasForegroundColorDirective = true if let color = NSColor(hex: value) { foregroundColor = color + hasParsedForegroundColor = true + } else { + hasParsedForegroundColor = false } case "cursor-color": + hasCursorColorDirective = true if let color = NSColor(hex: value) { cursorColor = color + hasParsedCursorColor = true + } else { + hasParsedCursorColor = false } case "cursor-text": + hasCursorTextColorDirective = true if let color = NSColor(hex: value) { cursorTextColor = color + hasParsedCursorTextColor = true + } else { + hasParsedCursorTextColor = false } case "selection-background": + hasSelectionBackgroundDirective = true if let color = NSColor(hex: value) { selectionBackground = color + hasParsedSelectionBackground = true + } else { + hasParsedSelectionBackground = false } case "selection-foreground": + hasSelectionForegroundDirective = true if let color = NSColor(hex: value) { selectionForeground = color + hasParsedSelectionForeground = true + } else { + hasParsedSelectionForeground = false } case "palette": // Parse palette entries like "0=#272822" @@ -452,6 +507,144 @@ struct GhosttyConfig { } } + private static func loadConfigFiles( + _ paths: [String], + into config: inout GhosttyConfig, + preferredColorScheme: ColorSchemePreference + ) { + var recursiveConfigPaths: [String] = [] + var loadedConfigPaths = Set() + + for path in paths.map({ NSString(string: $0).expandingTildeInPath }) { + loadConfigFile( + at: path, + into: &config, + preferredColorScheme: preferredColorScheme, + recursiveConfigPaths: &recursiveConfigPaths, + loadedConfigPaths: &loadedConfigPaths, + markLoadedPath: false + ) + } + + while !recursiveConfigPaths.isEmpty { + let path = recursiveConfigPaths.removeFirst() + loadConfigFile( + at: path, + into: &config, + preferredColorScheme: preferredColorScheme, + recursiveConfigPaths: &recursiveConfigPaths, + loadedConfigPaths: &loadedConfigPaths, + markLoadedPath: true + ) + } + } + + private static func loadConfigFile( + at path: String, + into config: inout GhosttyConfig, + preferredColorScheme: ColorSchemePreference, + recursiveConfigPaths: inout [String], + loadedConfigPaths: inout Set, + markLoadedPath: Bool + ) { + let resolved = (path as NSString).standardizingPath + if markLoadedPath { + guard !loadedConfigPaths.contains(resolved) else { return } + } + guard let contents = readConfigFile(at: resolved) else { return } + if markLoadedPath { + loadedConfigPaths.insert(resolved) + } + + config.parse( + contents, + loadingThemesImmediatelyFor: preferredColorScheme + ) + + let parentDir = (resolved as NSString).deletingLastPathComponent + collectRecursiveConfigPaths( + from: contents, + parentDir: parentDir, + recursiveConfigPaths: &recursiveConfigPaths + ) + } + + private static func collectRecursiveConfigPaths( + from contents: String, + parentDir: String, + recursiveConfigPaths: inout [String] + ) { + for line in contents.components(separatedBy: .newlines) { + guard let entry = parsedConfigEntry(from: line), + entry.key == "config-file" else { + continue + } + guard let value = entry.value else { continue } + applyConfigFileDirective( + value, + valueWasQuoted: entry.valueWasQuoted, + parentDir: parentDir, + recursiveConfigPaths: &recursiveConfigPaths + ) + } + } + + private static func parsedConfigEntry( + from rawLine: String + ) -> (key: String, value: String?, valueWasQuoted: Bool)? { + var trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("\u{FEFF}") { + trimmed.removeFirst() + } + if trimmed.isEmpty || trimmed.hasPrefix("#") { return nil } + + guard let separatorIndex = trimmed.firstIndex(of: "=") else { + return (trimmed.trimmingCharacters(in: .whitespacesAndNewlines), nil, false) + } + + let key = trimmed[..= 2 && value.hasPrefix("\"") && value.hasSuffix("\"") + + if valueWasQuoted { + value.removeFirst() + value.removeLast() + } + + return (String(key), String(value), valueWasQuoted) + } + + private static func applyConfigFileDirective( + _ value: String, + valueWasQuoted: Bool, + parentDir: String, + recursiveConfigPaths: inout [String] + ) { + if value.isEmpty { + recursiveConfigPaths.removeAll() + return + } + + var includePath = value + if !valueWasQuoted, includePath.hasPrefix("?") { + includePath.removeFirst() + if includePath.count >= 2, + includePath.hasPrefix("\""), + includePath.hasSuffix("\"") { + includePath.removeFirst() + includePath.removeLast() + } + } + guard !includePath.isEmpty else { return } + + let expanded = NSString(string: includePath).expandingTildeInPath + let absolute = (expanded as NSString).isAbsolutePath + ? expanded + : (parentDir as NSString).appendingPathComponent(expanded) + recursiveConfigPaths.append(absolute) + } + private static func parseIntegerLiteral(_ value: String) -> Int? { // Strip digit-group separators (for example 10_000_000). // Hex and float literals are intentionally unsupported here. @@ -498,6 +691,13 @@ struct GhosttyConfig { from: name, preferredColorScheme: preferredColorScheme ?? Self.currentColorSchemePreference() ) + let expandedThemePath = NSString(string: resolvedThemeName).expandingTildeInPath + if (expandedThemePath as NSString).isAbsolutePath, + let contents = try? String(contentsOfFile: expandedThemePath, encoding: .utf8) { + parse(contents) + return + } + for candidateName in Self.themeNameCandidates(from: resolvedThemeName) { for path in Self.themeSearchPaths( forThemeName: candidateName, @@ -750,6 +950,23 @@ struct GhosttyConfig { return try? String(contentsOfFile: path, encoding: .utf8) } + + private static func configFileSize(at path: String) -> Int? { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let size = attributes[.size] as? NSNumber else { + return nil + } + return size.intValue + } + + private static func shouldIncludeLegacyGhosttyConfigInResolvedLoad( + newConfigFileSize: Int?, + legacyConfigFileSize: Int? + ) -> Bool { + guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false } + guard let newConfigFileSize else { return true } + return newConfigFileSize == 0 + } } extension NSColor { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 84a004e4e69f..7668c8ffa2d0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2005,6 +2005,12 @@ class GhosttyApp { source: "initialize.primaryConfig", forceNotify: primaryRenderingModeChanged ) + updateDefaultBackgroundFromResolvedGhosttyConfig( + source: "initialize.primaryConfig", + preferredColorScheme: initialColorScheme, + baselineConfig: primaryConfig, + forceNotify: primaryRenderingModeChanged + ) // Create runtime config with callbacks var runtimeConfig = ghostty_runtime_config_s() @@ -2154,6 +2160,13 @@ class GhosttyApp { source: "initialize.fallbackConfig", forceNotify: fallbackRenderingModeChanged ) + updateDefaultBackgroundFromResolvedGhosttyConfig( + source: "initialize.fallbackConfig", + preferredColorScheme: initialColorScheme, + baselineConfig: fallbackConfig, + useOnDiskResolvedConfig: false, + forceNotify: fallbackRenderingModeChanged + ) guard let created = ghostty_app_new(&runtimeConfig, fallbackConfig) else { #if DEBUG @@ -2807,8 +2820,15 @@ class GhosttyApp { guard let appSupportDirectory else { return paths } let ghosttyDir = appSupportDirectory.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) - paths.append(ghosttyDir.appendingPathComponent("config", isDirectory: false).path) - paths.append(ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false).path) + let nativeLegacyConfig = ghosttyDir.appendingPathComponent("config", isDirectory: false) + let nativeConfig = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) + paths.append(nativeConfig.path) + if shouldIncludeLegacyGhosttyConfigInScanPaths( + newConfigFileSize: configFileSize(at: nativeConfig), + legacyConfigFileSize: configFileSize(at: nativeLegacyConfig) + ) { + paths.append(nativeLegacyConfig.path) + } guard let bundleId = currentBundleIdentifier, !bundleId.isEmpty else { return paths } @@ -2826,7 +2846,7 @@ class GhosttyApp { let releaseConfigSize = configFileSize(at: releaseConfig) let releaseLegacyConfigSize = configFileSize(at: releaseLegacyConfig) - if shouldLoadLegacyGhosttyConfig( + if shouldIncludeLegacyGhosttyConfigInScanPaths( newConfigFileSize: releaseConfigSize, legacyConfigFileSize: releaseLegacyConfigSize ), !paths.contains(releaseLegacyConfig.path) { @@ -2971,6 +2991,12 @@ class GhosttyApp { var includePath = value if !valueWasQuoted, includePath.hasPrefix("?") { includePath.removeFirst() + if includePath.count >= 2, + includePath.hasPrefix("\""), + includePath.hasSuffix("\"") { + includePath.removeFirst() + includePath.removeLast() + } } guard !includePath.isEmpty else { return } @@ -2985,8 +3011,35 @@ class GhosttyApp { newConfigFileSize: Int?, legacyConfigFileSize: Int? ) -> Bool { - guard let newConfigFileSize, newConfigFileSize == 0 else { return false } guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false } + return newConfigFileSize == 0 + } + + static func shouldIncludeLegacyGhosttyConfigInScanPaths( + newConfigFileSize: Int?, + legacyConfigFileSize: Int? + ) -> Bool { + guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false } + guard let newConfigFileSize else { return true } + return newConfigFileSize == 0 + } + + static func shouldIgnoreNativeLegacyBaselineForUnparsedAppearance( + appSupportDirectory: URL? = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first + ) -> Bool { + guard let appSupportDirectory else { return false } + let ghosttyDir = appSupportDirectory.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) + let nativeLegacyConfig = ghosttyDir.appendingPathComponent("config", isDirectory: false) + let nativeConfig = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) + guard let legacyConfigSize = configFileSize(at: nativeLegacyConfig), legacyConfigSize > 0 else { + return false + } + guard let nativeConfigSize = configFileSize(at: nativeConfig), nativeConfigSize > 0 else { + return false + } return true } @@ -3177,8 +3230,8 @@ class GhosttyApp { private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real - // settings in the legacy `config` file. If the new file exists but is empty, - // load the legacy file as a compatibility fallback. + // settings in the legacy `config` file. Use legacy only when `config.ghostty` + // is absent or empty, so stale legacy files do not override current config. let fm = FileManager.default guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) @@ -3307,6 +3360,14 @@ class GhosttyApp { scope: .unscoped, forceNotify: renderingModeChanged ) + GhosttyConfig.invalidateLoadCache() + updateDefaultBackgroundFromResolvedGhosttyConfig( + source: "reloadConfiguration(source=\(source))", + preferredColorScheme: reloadColorScheme, + baselineConfig: newConfig, + scope: .unscoped, + forceNotify: renderingModeChanged + ) let effectiveReloadColorScheme = effectiveTerminalColorSchemePreference synchronizeGhosttyRuntimeColorScheme(effectiveReloadColorScheme, source: "reloadConfiguration:\(source):resolved") ghostty_app_update_config(app, newConfig) @@ -3318,7 +3379,6 @@ class GhosttyApp { } config = newConfig lastAppearanceColorScheme = reloadColorScheme - GhosttyConfig.invalidateLoadCache() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) scheduleSurfaceRefreshAfterConfigurationReload( source: source, @@ -3496,7 +3556,48 @@ class GhosttyApp { ) { guard let config else { return } + let resolved = defaultBackgroundValues(from: config) + applyDefaultBackground( + color: resolved.backgroundColor, + opacity: resolved.backgroundOpacity, + backgroundBlur: resolved.backgroundBlur, + foregroundColor: resolved.foregroundColor, + cursorColor: resolved.cursorColor, + cursorTextColor: resolved.cursorTextColor, + selectionBackground: resolved.selectionBackground, + selectionForeground: resolved.selectionForeground, + source: source, + scope: scope, + forceNotify: forceNotify + ) + } + + private struct DefaultBackgroundValues { + var backgroundColor: NSColor + var backgroundOpacity: Double + var backgroundBlur: GhosttyBackgroundBlur + var foregroundColor: NSColor + var cursorColor: NSColor + var cursorTextColor: NSColor + var selectionBackground: NSColor + var selectionForeground: NSColor + } + + private func defaultBackgroundValues(from config: ghostty_config_t?) -> DefaultBackgroundValues { let baseline = Self.fallbackAppearanceConfig + guard let config else { + return DefaultBackgroundValues( + backgroundColor: baseline.backgroundColor, + backgroundOpacity: baseline.backgroundOpacity, + backgroundBlur: baseline.backgroundBlur, + foregroundColor: baseline.foregroundColor, + cursorColor: baseline.cursorColor, + cursorTextColor: baseline.cursorTextColor, + selectionBackground: baseline.selectionBackground, + selectionForeground: baseline.selectionForeground + ) + } + let resolvedColor = ghosttyColorValue(from: config, key: "background", fallback: baseline.backgroundColor) let resolvedForeground = ghosttyColorValue(from: config, key: "foreground", fallback: baseline.foregroundColor) let resolvedCursor = ghosttyColorValue(from: config, key: "cursor-color", fallback: baseline.cursorColor) @@ -3508,16 +3609,121 @@ class GhosttyApp { _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) opacity = min(1.0, max(0.0, opacity)) let backgroundBlur = defaultBackgroundBlurValue(from: config) - applyDefaultBackground( - color: resolvedColor, - opacity: opacity, + return DefaultBackgroundValues( + backgroundColor: resolvedColor, + backgroundOpacity: opacity, backgroundBlur: backgroundBlur, foregroundColor: resolvedForeground, cursorColor: resolvedCursor, cursorTextColor: resolvedCursorText, selectionBackground: resolvedSelectionBackground, - selectionForeground: resolvedSelectionForeground, - source: source, + selectionForeground: resolvedSelectionForeground + ) + } + + private func resolvedAppearanceValue( + parsedValue: T, + baselineValue: T, + unspecifiedFallbackValue: T, + hasParsedDirective: Bool, + hasDirective: Bool + ) -> T { + if hasParsedDirective { + return parsedValue + } + if hasDirective { + return baselineValue + } + return unspecifiedFallbackValue + } + + private func updateDefaultBackgroundFromResolvedGhosttyConfig( + source: String, + preferredColorScheme: GhosttyConfig.ColorSchemePreference, + baselineConfig: ghostty_config_t?, + scope: GhosttyDefaultBackgroundUpdateScope = .unscoped, + useOnDiskResolvedConfig: Bool = true, + forceNotify: Bool = false + ) { + let baseline = defaultBackgroundValues(from: baselineConfig) + guard useOnDiskResolvedConfig else { + applyDefaultBackground( + color: baseline.backgroundColor, + opacity: baseline.backgroundOpacity, + backgroundBlur: baseline.backgroundBlur, + foregroundColor: baseline.foregroundColor, + cursorColor: baseline.cursorColor, + cursorTextColor: baseline.cursorTextColor, + selectionBackground: baseline.selectionBackground, + selectionForeground: baseline.selectionForeground, + source: source, + scope: scope, + forceNotify: forceNotify + ) + return + } + let resolved = GhosttyConfig.load(preferredColorScheme: preferredColorScheme, useCache: false) + let fallbackForUnspecified = Self.shouldIgnoreNativeLegacyBaselineForUnparsedAppearance() + ? defaultBackgroundValues(from: nil) + : baseline + applyDefaultBackground( + color: resolvedAppearanceValue( + parsedValue: resolved.backgroundColor, + baselineValue: baseline.backgroundColor, + unspecifiedFallbackValue: fallbackForUnspecified.backgroundColor, + hasParsedDirective: resolved.hasParsedBackgroundColor, + hasDirective: resolved.hasBackgroundColorDirective + ), + opacity: resolvedAppearanceValue( + parsedValue: resolved.backgroundOpacity, + baselineValue: baseline.backgroundOpacity, + unspecifiedFallbackValue: fallbackForUnspecified.backgroundOpacity, + hasParsedDirective: resolved.hasParsedBackgroundOpacity, + hasDirective: resolved.hasBackgroundOpacityDirective + ), + backgroundBlur: resolvedAppearanceValue( + parsedValue: resolved.backgroundBlur, + baselineValue: baseline.backgroundBlur, + unspecifiedFallbackValue: fallbackForUnspecified.backgroundBlur, + hasParsedDirective: resolved.hasParsedBackgroundBlur, + hasDirective: resolved.hasBackgroundBlurDirective + ), + foregroundColor: resolvedAppearanceValue( + parsedValue: resolved.foregroundColor, + baselineValue: baseline.foregroundColor, + unspecifiedFallbackValue: fallbackForUnspecified.foregroundColor, + hasParsedDirective: resolved.hasParsedForegroundColor, + hasDirective: resolved.hasForegroundColorDirective + ), + cursorColor: resolvedAppearanceValue( + parsedValue: resolved.cursorColor, + baselineValue: baseline.cursorColor, + unspecifiedFallbackValue: fallbackForUnspecified.cursorColor, + hasParsedDirective: resolved.hasParsedCursorColor, + hasDirective: resolved.hasCursorColorDirective + ), + cursorTextColor: resolvedAppearanceValue( + parsedValue: resolved.cursorTextColor, + baselineValue: baseline.cursorTextColor, + unspecifiedFallbackValue: fallbackForUnspecified.cursorTextColor, + hasParsedDirective: resolved.hasParsedCursorTextColor, + hasDirective: resolved.hasCursorTextColorDirective + ), + selectionBackground: resolvedAppearanceValue( + parsedValue: resolved.selectionBackground, + baselineValue: baseline.selectionBackground, + unspecifiedFallbackValue: fallbackForUnspecified.selectionBackground, + hasParsedDirective: resolved.hasParsedSelectionBackground, + hasDirective: resolved.hasSelectionBackgroundDirective + ), + selectionForeground: resolvedAppearanceValue( + parsedValue: resolved.selectionForeground, + baselineValue: baseline.selectionForeground, + unspecifiedFallbackValue: fallbackForUnspecified.selectionForeground, + hasParsedDirective: resolved.hasParsedSelectionForeground, + hasDirective: resolved.hasSelectionForegroundDirective + ), + source: "\(source).resolvedGhosttyConfig", scope: scope, forceNotify: forceNotify ) @@ -4026,11 +4232,9 @@ class GhosttyApp { } if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE { - updateDefaultBackground( - from: action.action.config_change.config, - source: "action.config_change.app", - scope: .app - ) + // Theme picker preview reloads are resolved through reloadConfiguration. + // Ghostty's config-change payload can still contain stale app defaults, + // so it must not own the window chrome appearance. synchronizeGhosttyRuntimeColorScheme( effectiveTerminalColorSchemePreference, source: "action.config_change.app:resolved" @@ -4302,11 +4506,8 @@ class GhosttyApp { surfaceView.applyWindowBackgroundIfActive() } } - updateDefaultBackground( - from: action.action.config_change.config, - source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", - scope: .surface - ) + // Keep surface config-change handling scoped to the surface. The app-level + // default background is owned by reloadConfiguration's resolved GhosttyConfig. let effectiveConfigChangeColorScheme = effectiveTerminalColorSchemePreference synchronizeGhosttyRuntimeColorScheme( effectiveConfigChangeColorScheme, diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index d00b8a34e944..af735db3ced7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -233,6 +233,268 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(loaded.fontSize, CGFloat(15), accuracy: 0.0001) } + func testColorParseFlagsOnlyTrackValuesResolvedBySwiftParser() { + var namedColorConfig = GhosttyConfig() + namedColorConfig.parse("background = black\nforeground = #ddeeff\n") + + XCTAssertFalse(namedColorConfig.hasParsedBackgroundColor) + XCTAssertTrue(namedColorConfig.hasParsedForegroundColor) + XCTAssertEqual(namedColorConfig.foregroundColor.hexString(), "#DDEEFF") + + var hexColorConfig = GhosttyConfig() + hexColorConfig.parse("background = #aabbcc\n") + + XCTAssertTrue(hexColorConfig.hasParsedBackgroundColor) + XCTAssertEqual(hexColorConfig.backgroundColor.hexString(), "#AABBCC") + + var namedOverrideConfig = GhosttyConfig() + namedOverrideConfig.parse("background = #334455\nbackground = black\nforeground = #ddeeff\nforeground = white\n") + + XCTAssertFalse(namedOverrideConfig.hasParsedBackgroundColor) + XCTAssertFalse(namedOverrideConfig.hasParsedForegroundColor) + + var invalidScalarOverrideConfig = GhosttyConfig() + invalidScalarOverrideConfig.parse("background-opacity = 0.42\nbackground-opacity = invalid\nbackground-blur = true\nbackground-blur = maybe\n") + + XCTAssertFalse(invalidScalarOverrideConfig.hasParsedBackgroundOpacity) + XCTAssertFalse(invalidScalarOverrideConfig.hasParsedBackgroundBlur) + + var highOpacityConfig = GhosttyConfig() + highOpacityConfig.parse("background-opacity = 2\n") + + XCTAssertTrue(highOpacityConfig.hasParsedBackgroundOpacity) + XCTAssertEqual(highOpacityConfig.backgroundOpacity, 1.0, accuracy: 0.0001) + + var lowOpacityConfig = GhosttyConfig() + lowOpacityConfig.parse("background-opacity = -1\n") + + XCTAssertTrue(lowOpacityConfig.hasParsedBackgroundOpacity) + XCTAssertEqual(lowOpacityConfig.backgroundOpacity, 0.0, accuracy: 0.0001) + } + + func testLoadReadsBackgroundFromRecursiveConfigFile() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-config-recursive-background-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let originalFixedHome = getenv("CFFIXED_USER_HOME").map { String(cString: $0) } + setenv("CFFIXED_USER_HOME", root.path, 1) + defer { + if let originalFixedHome { + setenv("CFFIXED_USER_HOME", originalFixedHome, 1) + } else { + unsetenv("CFFIXED_USER_HOME") + } + GhosttyConfig.invalidateLoadCache() + } + + let ghosttyConfigDir = root.appendingPathComponent(".config/ghostty", isDirectory: true) + try fileManager.createDirectory(at: ghosttyConfigDir, withIntermediateDirectories: true) + + try "background = #123456\nforeground = #abcdef\n".write( + to: ghosttyConfigDir.appendingPathComponent("appearance.conf", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + try "config-file = appearance.conf\n".write( + to: ghosttyConfigDir.appendingPathComponent("config.ghostty", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + + let loaded = GhosttyConfig.load(preferredColorScheme: .dark, useCache: false) + + XCTAssertEqual(loaded.backgroundColor.hexString(), "#123456") + XCTAssertEqual(loaded.foregroundColor.hexString(), "#ABCDEF") + } + + func testLoadDoesNotReparseTopLevelConfigReferencedByConfigFile() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-config-top-level-cycle-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let originalFixedHome = getenv("CFFIXED_USER_HOME").map { String(cString: $0) } + setenv("CFFIXED_USER_HOME", root.path, 1) + defer { + if let originalFixedHome { + setenv("CFFIXED_USER_HOME", originalFixedHome, 1) + } else { + unsetenv("CFFIXED_USER_HOME") + } + GhosttyConfig.invalidateLoadCache() + } + + let ghosttyConfigDir = root.appendingPathComponent(".config/ghostty", isDirectory: true) + try fileManager.createDirectory(at: ghosttyConfigDir, withIntermediateDirectories: true) + let configFile = ghosttyConfigDir.appendingPathComponent("config.ghostty", isDirectory: false) + + try """ + background = #111111 + config-file = \(configFile.path) + foreground = #222222 + """ + .write(to: configFile, atomically: true, encoding: .utf8) + + let loaded = GhosttyConfig.load(preferredColorScheme: .dark, useCache: false) + + XCTAssertEqual(loaded.backgroundColor.hexString(), "#111111") + XCTAssertEqual(loaded.foregroundColor.hexString(), "#222222") + } + + func testLoadAllowsRecursiveConfigFileToReloadTopLevelConfigAsFinalOverride() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-config-top-level-reload-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let originalFixedHome = getenv("CFFIXED_USER_HOME").map { String(cString: $0) } + setenv("CFFIXED_USER_HOME", root.path, 1) + defer { + if let originalFixedHome { + setenv("CFFIXED_USER_HOME", originalFixedHome, 1) + } else { + unsetenv("CFFIXED_USER_HOME") + } + GhosttyConfig.invalidateLoadCache() + } + + let ghosttyConfigDir = root.appendingPathComponent(".config/ghostty", isDirectory: true) + try fileManager.createDirectory(at: ghosttyConfigDir, withIntermediateDirectories: true) + let legacyConfig = ghosttyConfigDir.appendingPathComponent("config", isDirectory: false) + try "background = #111111\n".write(to: legacyConfig, atomically: true, encoding: .utf8) + try """ + background = #222222 + config-file = \(legacyConfig.path) + """ + .write( + to: ghosttyConfigDir.appendingPathComponent("config.ghostty", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + + let loaded = GhosttyConfig.load(preferredColorScheme: .dark, useCache: false) + + XCTAssertEqual(loaded.backgroundColor.hexString(), "#111111") + } + + func testLoadReadsOptionalQuotedConfigFilePath() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-config-optional-quoted-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let originalFixedHome = getenv("CFFIXED_USER_HOME").map { String(cString: $0) } + setenv("CFFIXED_USER_HOME", root.path, 1) + defer { + if let originalFixedHome { + setenv("CFFIXED_USER_HOME", originalFixedHome, 1) + } else { + unsetenv("CFFIXED_USER_HOME") + } + GhosttyConfig.invalidateLoadCache() + } + + let ghosttyConfigDir = root.appendingPathComponent(".config/ghostty", isDirectory: true) + try fileManager.createDirectory(at: ghosttyConfigDir, withIntermediateDirectories: true) + + try "background = #334455\nforeground = #ddeeff\n".write( + to: ghosttyConfigDir.appendingPathComponent("appearance theme.conf", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + try "config-file = ?\"appearance theme.conf\"\n".write( + to: ghosttyConfigDir.appendingPathComponent("config.ghostty", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + + let loaded = GhosttyConfig.load(preferredColorScheme: .dark, useCache: false) + + XCTAssertEqual(loaded.backgroundColor.hexString(), "#334455") + XCTAssertEqual(loaded.foregroundColor.hexString(), "#DDEEFF") + } + + func testLoadIgnoresLegacyAppSupportConfigWhenConfigGhosttyIsNonEmpty() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-app-support-legacy-skip-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let originalFixedHome = getenv("CFFIXED_USER_HOME").map { String(cString: $0) } + setenv("CFFIXED_USER_HOME", root.path, 1) + defer { + if let originalFixedHome { + setenv("CFFIXED_USER_HOME", originalFixedHome, 1) + } else { + unsetenv("CFFIXED_USER_HOME") + } + GhosttyConfig.invalidateLoadCache() + } + + let ghosttyConfigDir = root + .appendingPathComponent("Library/Application Support/com.mitchellh.ghostty", isDirectory: true) + try fileManager.createDirectory(at: ghosttyConfigDir, withIntermediateDirectories: true) + try "background = #112233\n".write( + to: ghosttyConfigDir.appendingPathComponent("config", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + try "font-size = 13\n".write( + to: ghosttyConfigDir.appendingPathComponent("config.ghostty", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + + let loaded = GhosttyConfig.load(preferredColorScheme: .dark, useCache: false) + + XCTAssertEqual(loaded.fontSize, CGFloat(13), accuracy: 0.0001) + XCTAssertNotEqual(loaded.backgroundColor.hexString(), "#112233") + } + + func testLoadUsesLegacyAppSupportConfigWhenConfigGhosttyIsEmpty() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-app-support-legacy-fallback-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let originalFixedHome = getenv("CFFIXED_USER_HOME").map { String(cString: $0) } + setenv("CFFIXED_USER_HOME", root.path, 1) + defer { + if let originalFixedHome { + setenv("CFFIXED_USER_HOME", originalFixedHome, 1) + } else { + unsetenv("CFFIXED_USER_HOME") + } + GhosttyConfig.invalidateLoadCache() + } + + let ghosttyConfigDir = root + .appendingPathComponent("Library/Application Support/com.mitchellh.ghostty", isDirectory: true) + try fileManager.createDirectory(at: ghosttyConfigDir, withIntermediateDirectories: true) + try "background = #112233\n".write( + to: ghosttyConfigDir.appendingPathComponent("config", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + try "".write( + to: ghosttyConfigDir.appendingPathComponent("config.ghostty", isDirectory: false), + atomically: true, + encoding: .utf8 + ) + + let loaded = GhosttyConfig.load(preferredColorScheme: .dark, useCache: false) + + XCTAssertEqual(loaded.backgroundColor.hexString(), "#112233") + } + func testLoadAppliesThemeBeforeLaterCursorColorOverride() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -275,6 +537,32 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(rgb255(loaded.cursorTextColor), RGB(red: 17, green: 17, blue: 17)) } + func testLoadThemeReadsAbsoluteThemeFilePath() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-ghostty-absolute-theme-\(UUID().uuidString)") + try fileManager.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let themeFile = root.appendingPathComponent("theme.conf", isDirectory: false) + try "background = #223344\nforeground = #ddeeff\n".write( + to: themeFile, + atomically: true, + encoding: .utf8 + ) + + var config = GhosttyConfig() + config.loadTheme( + themeFile.path, + environment: [:], + bundleResourceURL: nil, + preferredColorScheme: .dark + ) + + XCTAssertEqual(config.backgroundColor.hexString(), "#223344") + XCTAssertEqual(config.foregroundColor.hexString(), "#DDEEFF") + } + func testLoadThemeResolvesPairedThemeValueByColorScheme() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-ghostty-theme-pair-\(UUID().uuidString)") @@ -489,13 +777,25 @@ final class GhosttyConfigTests: XCTestCase { ) } - func testLegacyConfigFallbackSkipsWhenNewFileMissingOrLegacyEmpty() { + func testLegacyConfigFallbackDoesNotReloadLegacyFileWhenConfigGhosttyIsMissing() { XCTAssertFalse( GhosttyApp.shouldLoadLegacyGhosttyConfig( newConfigFileSize: nil, legacyConfigFileSize: 42 ) ) + } + + func testLegacyConfigScanPathsIncludeLegacyFileWhenConfigGhosttyIsMissing() { + XCTAssertTrue( + GhosttyApp.shouldIncludeLegacyGhosttyConfigInScanPaths( + newConfigFileSize: nil, + legacyConfigFileSize: 42 + ) + ) + } + + func testLegacyConfigFallbackSkipsWhenNewFileHasContentsOrLegacyEmpty() { XCTAssertFalse( GhosttyApp.shouldLoadLegacyGhosttyConfig( newConfigFileSize: 10, @@ -516,6 +816,65 @@ final class GhosttyConfigTests: XCTestCase { ) } + func testUnparsedAppearanceFallbackIgnoresNativeLegacyBaselineWhenCurrentConfigExists() throws { + let appSupport = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-native-legacy-baseline-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: appSupport) } + + let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) + try FileManager.default.createDirectory(at: ghosttyDir, withIntermediateDirectories: true) + try "background = #112233\n" + .write(to: ghosttyDir.appendingPathComponent("config", isDirectory: false), atomically: true, encoding: .utf8) + try "background = black\n" + .write(to: ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false), atomically: true, encoding: .utf8) + + XCTAssertTrue( + GhosttyApp.shouldIgnoreNativeLegacyBaselineForUnparsedAppearance( + appSupportDirectory: appSupport + ) + ) + } + + func testUnparsedAppearanceDirectiveIsTrackedSeparatelyFromParsedHexColor() { + var config = GhosttyConfig() + + config.parse("background = black\nforeground = #ddeeff\n") + + XCTAssertTrue(config.hasBackgroundColorDirective) + XCTAssertFalse(config.hasParsedBackgroundColor) + XCTAssertTrue(config.hasForegroundColorDirective) + XCTAssertTrue(config.hasParsedForegroundColor) + XCTAssertEqual(config.foregroundColor.hexString(), "#DDEEFF") + } + + func testUnparsedAppearanceFallbackKeepsNativeLegacyBaselineWhenCurrentConfigIsMissingOrEmpty() throws { + let appSupport = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-native-legacy-baseline-empty-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: appSupport) } + + let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) + try FileManager.default.createDirectory(at: ghosttyDir, withIntermediateDirectories: true) + let currentConfig = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) + try "background = #112233\n" + .write(to: ghosttyDir.appendingPathComponent("config", isDirectory: false), atomically: true, encoding: .utf8) + + XCTAssertFalse( + GhosttyApp.shouldIgnoreNativeLegacyBaselineForUnparsedAppearance( + appSupportDirectory: appSupport + ) + ) + + try "".write(to: currentConfig, atomically: true, encoding: .utf8) + + XCTAssertFalse( + GhosttyApp.shouldIgnoreNativeLegacyBaselineForUnparsedAppearance( + appSupportDirectory: appSupport + ) + ) + } + func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() { let cases: [(GhosttyDefaultBackgroundUpdateScope, GhosttyDefaultBackgroundUpdateScope, Bool)] = [ (.unscoped, .app, true), @@ -3618,8 +3977,10 @@ final class GhosttyMouseFocusTests: XCTestCase { let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) try FileManager.default.createDirectory(at: ghosttyDir, withIntermediateDirectories: true) let nativeConfig = ghosttyDir.appendingPathComponent("config", isDirectory: false) + let currentConfig = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) try "theme = Dracula\n" .write(to: nativeConfig, atomically: true, encoding: .utf8) + try "".write(to: currentConfig, atomically: true, encoding: .utf8) let paths = GhosttyApp.loadedGhosttyConfigScanPaths( currentBundleIdentifier: "com.example.cmux-dev", @@ -3630,6 +3991,31 @@ final class GhosttyMouseFocusTests: XCTestCase { XCTAssertFalse(GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: paths)) } + func testLoadedGhosttyConfigScanPathsSkipsNativeLegacyConfigWhenCurrentConfigIsNonEmpty() throws { + let appSupport = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-appearance-app-support-current-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: appSupport) } + + let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) + try FileManager.default.createDirectory(at: ghosttyDir, withIntermediateDirectories: true) + let legacyConfig = ghosttyDir.appendingPathComponent("config", isDirectory: false) + let currentConfig = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) + try "theme = Dracula\n" + .write(to: legacyConfig, atomically: true, encoding: .utf8) + try "font-size = 13\n" + .write(to: currentConfig, atomically: true, encoding: .utf8) + + let paths = GhosttyApp.loadedGhosttyConfigScanPaths( + currentBundleIdentifier: "com.example.cmux-dev", + appSupportDirectory: appSupport + ) + + XCTAssertTrue(paths.contains(currentConfig.path)) + XCTAssertFalse(paths.contains(legacyConfig.path)) + XCTAssertTrue(GhosttyApp.shouldApplyManagedDefaultAppearance(configPaths: paths)) + } + // MARK: shouldApplyManagedDefaultAppearance func testShouldApplyManagedDefaultAppearanceAllowsNonAppearanceConfig() throws {