diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index c923f6ac..1c5e71ea 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -73,6 +73,7 @@ F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; + F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -193,6 +194,7 @@ F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = ""; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -396,6 +398,7 @@ F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, + F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -594,6 +597,7 @@ F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, + F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b3d2b256..da91ac9e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -390,7 +390,6 @@ func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) { struct ContentView: View { @ObservedObject var updateViewModel: UpdateViewModel let windowId: UUID - @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var sidebarState: SidebarState @@ -411,6 +410,7 @@ struct ContentView: View { @State private var retiringWorkspaceId: UUID? @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? + @State private var titlebarThemeGeneration: UInt64 = 0 private var sidebarView: some View { VerticalTabsSidebar( @@ -533,18 +533,26 @@ struct ContentView: View { @State private var titlebarLeadingInset: CGFloat = 12 private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } private var fakeTitlebarBackground: Color { - if colorScheme == .light { - return Color(nsColor: .windowBackgroundColor) - } + _ = titlebarThemeGeneration let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor - let alpha: CGFloat = ghosttyBackground.isLightColor ? 0.94 : 0.86 - return Color(nsColor: ghosttyBackground.withAlphaComponent(alpha)) + let configuredOpacity = CGFloat(max(0, min(1, GhosttyApp.shared.defaultBackgroundOpacity))) + let minimumChromeOpacity: CGFloat = ghosttyBackground.isLightColor ? 0.90 : 0.84 + let chromeOpacity = max(minimumChromeOpacity, configuredOpacity) + return Color(nsColor: ghosttyBackground.withAlphaComponent(chromeOpacity)) } private var fakeTitlebarTextColor: Color { - colorScheme == .light ? Color(nsColor: .labelColor).opacity(0.78) : .secondary + _ = titlebarThemeGeneration + let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor + return ghosttyBackground.isLightColor + ? Color.black.opacity(0.78) + : Color.white.opacity(0.82) } private var fakeTitlebarSeparatorColor: Color { - Color(nsColor: .separatorColor).opacity(colorScheme == .light ? 0.68 : 0.34) + _ = titlebarThemeGeneration + let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor + return ghosttyBackground.isLightColor + ? Color.black.opacity(0.18) + : Color.white.opacity(0.22) } private var fullscreenControls: some View { @@ -729,6 +737,12 @@ struct ContentView: View { completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus") updateTitlebarText() } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in + titlebarThemeGeneration &+= 1 + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + titlebarThemeGeneration &+= 1 + } .onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 893d44e0..0e8fafa9 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -2,6 +2,11 @@ import Foundation import AppKit struct GhosttyConfig { + enum ColorSchemePreference { + case light + case dark + } + var fontFamily: String = "Menlo" var fontSize: CGFloat = 12 var theme: String? @@ -145,24 +150,214 @@ struct GhosttyConfig { } mutating func loadTheme(_ name: String) { - let bundleThemePath = Bundle.main.resourceURL? - .appendingPathComponent("ghostty/themes/\(name)") - .path + loadTheme( + name, + environment: ProcessInfo.processInfo.environment, + bundleResourceURL: Bundle.main.resourceURL + ) + } - let themePaths = [ - bundleThemePath, - "/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(name)", - NSString(string: "~/.config/ghostty/themes/\(name)").expandingTildeInPath, - ].compactMap { $0 } - - for path in themePaths { - if let contents = try? String(contentsOfFile: path, encoding: .utf8) { - parse(contents) - return + mutating func loadTheme( + _ name: String, + environment: [String: String], + bundleResourceURL: URL?, + preferredColorScheme: ColorSchemePreference? = nil + ) { + let resolvedThemeName = Self.resolveThemeName( + from: name, + preferredColorScheme: preferredColorScheme ?? Self.currentColorSchemePreference() + ) + for candidateName in Self.themeNameCandidates(from: resolvedThemeName) { + for path in Self.themeSearchPaths( + forThemeName: candidateName, + environment: environment, + bundleResourceURL: bundleResourceURL + ) { + if let contents = try? String(contentsOfFile: path, encoding: .utf8) { + parse(contents) + return + } } } } + static func currentColorSchemePreference( + appAppearance: NSAppearance? = NSApp?.effectiveAppearance + ) -> ColorSchemePreference { + let bestMatch = appAppearance?.bestMatch(from: [.darkAqua, .aqua]) + return bestMatch == .darkAqua ? .dark : .light + } + + static func resolveThemeName( + from rawThemeValue: String, + preferredColorScheme: ColorSchemePreference + ) -> String { + var fallbackTheme: String? + var lightTheme: String? + var darkTheme: String? + + for token in rawThemeValue.split(separator: ",").map(String.init) { + let entry = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !entry.isEmpty else { continue } + + let parts = entry.split(separator: ":", maxSplits: 1).map(String.init) + if parts.count != 2 { + if fallbackTheme == nil { + fallbackTheme = entry + } + continue + } + + let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { continue } + + switch key { + case "light": + if lightTheme == nil { + lightTheme = value + } + case "dark": + if darkTheme == nil { + darkTheme = value + } + default: + if fallbackTheme == nil { + fallbackTheme = value + } + } + } + + switch preferredColorScheme { + case .light: + if let lightTheme { + return lightTheme + } + case .dark: + if let darkTheme { + return darkTheme + } + } + + if let fallbackTheme { + return fallbackTheme + } + if let darkTheme { + return darkTheme + } + if let lightTheme { + return lightTheme + } + return rawThemeValue.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func themeNameCandidates(from rawName: String) -> [String] { + var candidates: [String] = [] + let compatibilityAliases: [String: [String]] = [ + "solarized light": ["iTerm2 Solarized Light"], + "solarized dark": ["iTerm2 Solarized Dark"], + ] + + func appendCandidate(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if !candidates.contains(trimmed) { + candidates.append(trimmed) + } + + if let aliases = compatibilityAliases[trimmed.lowercased()] { + for alias in aliases { + if !candidates.contains(alias) { + candidates.append(alias) + } + } + } + } + + var queue: [String] = [rawName] + while let current = queue.popLast() { + let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + appendCandidate(trimmed) + + let lower = trimmed.lowercased() + if lower.hasPrefix("builtin ") { + let stripped = String(trimmed.dropFirst("builtin ".count)) + appendCandidate(stripped) + queue.append(stripped) + } + + if let range = trimmed.range( + of: #"\s*\(builtin\)\s*$"#, + options: [.regularExpression, .caseInsensitive] + ) { + let stripped = String(trimmed[.. [String] { + var paths: [String] = [] + + func appendUniquePath(_ path: String?) { + guard let path else { return } + let expanded = NSString(string: path).expandingTildeInPath + guard !expanded.isEmpty else { return } + if !paths.contains(expanded) { + paths.append(expanded) + } + } + + func appendThemePath(in resourcesRoot: String?) { + guard let resourcesRoot else { return } + let expanded = NSString(string: resourcesRoot).expandingTildeInPath + guard !expanded.isEmpty else { return } + appendUniquePath( + URL(fileURLWithPath: expanded) + .appendingPathComponent("themes/\(themeName)") + .path + ) + } + + // 1) Explicit resources dir used by the running Ghostty embedding. + appendThemePath(in: environment["GHOSTTY_RESOURCES_DIR"]) + + // 2) App bundle resources. + appendUniquePath( + bundleResourceURL? + .appendingPathComponent("ghostty/themes/\(themeName)") + .path + ) + + // 3) Data dirs (Ghostty installs themes under share/ghostty/themes). + if let xdgDataDirs = environment["XDG_DATA_DIRS"] { + for dataDir in xdgDataDirs.split(separator: ":").map(String.init) { + guard !dataDir.isEmpty else { continue } + appendUniquePath( + URL(fileURLWithPath: dataDir) + .appendingPathComponent("ghostty/themes/\(themeName)") + .path + ) + } + } + + // 4) Common system/user fallback locations. + appendUniquePath("/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(themeName)") + appendUniquePath("~/.config/ghostty/themes/\(themeName)") + appendUniquePath("~/Library/Application Support/com.mitchellh.ghostty/themes/\(themeName)") + + return paths + } + private static func readConfigFile(at path: String) -> String? { let fileManager = FileManager.default guard fileManager.fileExists(atPath: path) else { return nil } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ba484de9..c0f0f33c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -418,6 +418,7 @@ class GhosttyApp { guard let app = self?.app else { return } ghostty_app_set_focus(app, false) }) + #endif } @@ -523,6 +524,8 @@ class GhosttyApp { private func updateDefaultBackground(from config: ghostty_config_t?) { guard let config else { return } + let previousHex = defaultBackgroundColor.hexString() + let previousOpacity = defaultBackgroundOpacity var color = ghostty_config_color_s() let bgKey = "background" @@ -539,11 +542,35 @@ class GhosttyApp { let opacityKey = "background-opacity" _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) defaultBackgroundOpacity = opacity + let hasChanged = previousHex != defaultBackgroundColor.hexString() || + abs(previousOpacity - defaultBackgroundOpacity) > 0.0001 + if hasChanged { + notifyDefaultBackgroundDidChange() + } if backgroundLogEnabled { logBackground("default background updated color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity))") } } + private func notifyDefaultBackgroundDidChange() { + let userInfo: [AnyHashable: Any] = [ + GhosttyNotificationKey.backgroundColor: defaultBackgroundColor, + GhosttyNotificationKey.backgroundOpacity: defaultBackgroundOpacity + ] + let post = { + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: userInfo + ) + } + if Thread.isMainThread { + post() + } else { + DispatchQueue.main.async(execute: post) + } + } + private func performOnMain(_ work: @MainActor () -> T) -> T { if Thread.isMainThread { return MainActor.assumeIsolated { work() } @@ -635,6 +662,7 @@ class GhosttyApp { if backgroundLogEnabled { logBackground("OSC background change (app target) color=\(defaultBackgroundColor)") } + notifyDefaultBackgroundDidChange() DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } @@ -1537,6 +1565,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var onFocus: (() -> Void)? var onTriggerFlash: (() -> Void)? var backgroundColor: NSColor? + private var appliedColorScheme: ghostty_color_scheme_e? private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] #if DEBUG @@ -1647,11 +1676,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } func attachSurface(_ surface: TerminalSurface) { + appliedColorScheme = nil terminalSurface = surface tabId = surface.tabId surface.attachToView(self) updateSurfaceSize() applySurfaceBackground() + applySurfaceColorScheme(force: true) } override func viewDidMoveToWindow() { @@ -1698,9 +1729,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { }() updateSurfaceSize(size: targetSize) applySurfaceBackground() + applySurfaceColorScheme(force: true) applyWindowBackgroundIfActive() } + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + applySurfaceColorScheme() + } + fileprivate func updateOcclusionState() { // Intentionally no-op: we don't drive libghostty occlusion from AppKit occlusion state. // This avoids transient clears during reparenting and keeps rendering logic minimal. @@ -1833,6 +1870,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { terminalSurface?.surface } + private func applySurfaceColorScheme(force: Bool = false) { + guard let surface else { return } + let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) + let scheme: ghostty_color_scheme_e = bestMatch == .darkAqua + ? GHOSTTY_COLOR_SCHEME_DARK + : GHOSTTY_COLOR_SCHEME_LIGHT + if !force, appliedColorScheme == scheme { + return + } + ghostty_surface_set_color_scheme(surface, scheme) + appliedColorScheme = scheme + } + @discardableResult private func ensureSurfaceReadyForInput() -> ghostty_surface_t? { if let surface = surface { @@ -1841,6 +1891,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { guard window != nil else { return nil } terminalSurface?.attachToView(self) updateSurfaceSize(size: bounds.size) + applySurfaceColorScheme(force: true) return surface } @@ -2732,6 +2783,8 @@ enum GhosttyNotificationKey { static let tabId = "ghostty.tabId" static let surfaceId = "ghostty.surfaceId" static let title = "ghostty.title" + static let backgroundColor = "ghostty.backgroundColor" + static let backgroundOpacity = "ghostty.backgroundOpacity" } extension Notification.Name { @@ -2739,6 +2792,7 @@ extension Notification.Name { static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize") static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus") static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload") + static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange") } // MARK: - Scroll View Wrapper (Ghostty-style scrollbar) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 654cfbf3..8d4af861 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import AppKit import Bonsplit import Combine @@ -101,6 +102,29 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Initialization + private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { + bonsplitAppearance(from: config.backgroundColor) + } + + private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { + BonsplitConfiguration.Appearance( + enableAnimations: false, + chromeColors: .init(backgroundHex: backgroundColor.hexString()) + ) + } + + func applyGhosttyChrome(from config: GhosttyConfig) { + applyGhosttyChrome(backgroundColor: config.backgroundColor) + } + + func applyGhosttyChrome(backgroundColor: NSColor) { + let nextHex = backgroundColor.hexString() + if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextHex { + return + } + bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex + } + init(title: String = "Terminal", workingDirectory: String? = nil) { self.id = UUID() self.processTitle = title @@ -115,9 +139,7 @@ final class Workspace: Identifiable, ObservableObject { // Configure bonsplit with keepAllAlive to preserve terminal state // Disable split animations for instant response - let appearance = BonsplitConfiguration.Appearance( - enableAnimations: false - ) + let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load()) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 68724456..8c8cfbab 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -1,5 +1,6 @@ import SwiftUI import Foundation +import AppKit import Bonsplit /// View that renders a Workspace's content using BonsplitView @@ -9,6 +10,7 @@ struct WorkspaceContentView: View { let isWorkspaceInputActive: Bool let workspacePortalPriority: Int @State private var config = GhosttyConfig.load() + @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore var body: some View { @@ -82,12 +84,24 @@ struct WorkspaceContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { syncBonsplitNotificationBadges() + workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) } .onChange(of: notificationStore.notifications) { _, _ in syncBonsplitNotificationBadges() } .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in - config = GhosttyConfig.load() + refreshGhosttyAppearanceConfig() + } + .onChange(of: colorScheme) { _, _ in + // Keep split overlay color/opacity in sync with light/dark theme transitions. + refreshGhosttyAppearanceConfig() + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in + if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor { + workspace.applyGhosttyChrome(backgroundColor: backgroundColor) + } else { + workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) + } } } @@ -108,6 +122,12 @@ struct WorkspaceContentView: View { } } } + + private func refreshGhosttyAppearanceConfig() { + let next = GhosttyConfig.load() + config = next + workspace.applyGhosttyChrome(from: next) + } } extension WorkspaceContentView { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 681a152a..9eb4d274 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4,12 +4,12 @@ import Darwin @main struct cmuxApp: App { - @StateObject private var tabManager = TabManager() + @StateObject private var tabManager: TabManager @StateObject private var notificationStore = TerminalNotificationStore.shared @StateObject private var sidebarState = SidebarState() @StateObject private var sidebarSelectionState = SidebarSelectionState() private let primaryWindowId = UUID() - @AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue + @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @@ -18,7 +18,11 @@ struct cmuxApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { - configureGhosttyEnvironment() + Self.configureGhosttyEnvironment() + + let startupAppearance = AppearanceSettings.resolvedMode() + Self.applyAppearance(startupAppearance) + _tabManager = StateObject(wrappedValue: TabManager()) // Migrate legacy and old-format socket mode values to the new enum. let defaults = UserDefaults.standard if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) { @@ -37,7 +41,7 @@ struct cmuxApp: App { appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) } - private func configureGhosttyEnvironment() { + private static func configureGhosttyEnvironment() { let fileManager = FileManager.default let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty" let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty") @@ -82,7 +86,7 @@ struct cmuxApp: App { } } - private func appendEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) { + private static func appendEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) { if path.isEmpty { return } var current = getenv(key).flatMap { String(cString: $0) } ?? "" if current.isEmpty, let defaultValue { @@ -496,18 +500,23 @@ struct cmuxApp: App { } private func applyAppearance() { - guard let mode = AppearanceMode(rawValue: appearanceMode) else { return } + let mode = AppearanceSettings.mode(for: appearanceMode) + if appearanceMode != mode.rawValue { + appearanceMode = mode.rawValue + } + Self.applyAppearance(mode) + } + + private static func applyAppearance(_ mode: AppearanceMode) { switch mode { case .system: - NSApp.appearance = nil + NSApplication.shared.appearance = nil case .light: - NSApp.appearance = NSAppearance(named: .aqua) + NSApplication.shared.appearance = NSAppearance(named: .aqua) case .dark: - NSApp.appearance = NSAppearance(named: .darkAqua) + NSApplication.shared.appearance = NSAppearance(named: .darkAqua) case .auto: - // Legacy value; treat like system and migrate. - NSApp.appearance = nil - appearanceMode = AppearanceMode.system.rawValue + NSApplication.shared.appearance = nil } } @@ -2229,11 +2238,36 @@ enum AppearanceMode: String, CaseIterable, Identifiable { } } +enum AppearanceSettings { + static let appearanceModeKey = "appearanceMode" + static let defaultMode: AppearanceMode = .system + + static func mode(for rawValue: String?) -> AppearanceMode { + guard let rawValue, let mode = AppearanceMode(rawValue: rawValue) else { + return defaultMode + } + if mode == .auto { + return .system + } + return mode + } + + @discardableResult + static func resolvedMode(defaults: UserDefaults = .standard) -> AppearanceMode { + let stored = defaults.string(forKey: appearanceModeKey) + let resolved = mode(for: stored) + if stored != resolved.rawValue { + defaults.set(resolved.rawValue, forKey: appearanceModeKey) + } + return resolved + } +} + struct SettingsView: View { private let contentTopInset: CGFloat = 8 private let pickerColumnWidth: CGFloat = 196 - @AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue + @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage("claudeCodeHooksEnabled") private var claudeCodeHooksEnabled = true @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @@ -2525,7 +2559,7 @@ struct SettingsView: View { } private func resetAllSettings() { - appearanceMode = AppearanceMode.dark.rawValue + appearanceMode = AppearanceSettings.defaultMode.rawValue socketControlMode = SocketControlSettings.defaultMode.rawValue claudeCodeHooksEnabled = true browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8982078a..7673a12b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -339,6 +339,110 @@ final class WorkspacePlacementSettingsTests: XCTestCase { } } +final class AppearanceSettingsTests: XCTestCase { + func testResolvedModeDefaultsToSystemWhenUnset() { + let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: AppearanceSettings.appearanceModeKey) + + let resolved = AppearanceSettings.resolvedMode(defaults: defaults) + XCTAssertEqual(resolved, .system) + XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue) + } + + func testResolvedModeMigratesLegacyAndInvalidValuesToSystem() { + let suiteName = "AppearanceSettingsTests.Migrate.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(AppearanceMode.auto.rawValue, forKey: AppearanceSettings.appearanceModeKey) + XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system) + XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue) + + defaults.set("invalid-value", forKey: AppearanceSettings.appearanceModeKey) + XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system) + XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue) + } + + func testResolvedModePreservesExplicitLightAndDark() { + let suiteName = "AppearanceSettingsTests.Preserve.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(AppearanceMode.light.rawValue, forKey: AppearanceSettings.appearanceModeKey) + XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .light) + XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.light.rawValue) + + defaults.set(AppearanceMode.dark.rawValue, forKey: AppearanceSettings.appearanceModeKey) + XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .dark) + XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.dark.rawValue) + } +} + +final class UpdateChannelSettingsTests: XCTestCase { + func testDefaultNightlyPreferenceIsDisabled() { + XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) + } + + func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() { + let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults) + XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL) + XCTAssertFalse(resolved.isNightly) + XCTAssertTrue(resolved.usedFallback) + } + + func testResolvedFeedUsesInfoFeedForStableChannel() { + let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let infoFeed = "https://example.com/custom/appcast.xml" + let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults) + XCTAssertEqual(resolved.url, infoFeed) + XCTAssertFalse(resolved.isNightly) + XCTAssertFalse(resolved.usedFallback) + } + + func testResolvedFeedUsesNightlyWhenPreferenceEnabled() { + let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey) + let resolved = UpdateChannelSettings.resolvedFeedURLString( + infoFeedURL: "https://example.com/custom/appcast.xml", + defaults: defaults + ) + XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL) + XCTAssertTrue(resolved.isNightly) + XCTAssertFalse(resolved.usedFallback) + } +} + final class WorkspaceReorderTests: XCTestCase { @MainActor func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift new file mode 100644 index 00000000..b6f13d4d --- /dev/null +++ b/cmuxTests/GhosttyConfigTests.swift @@ -0,0 +1,142 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class GhosttyConfigTests: XCTestCase { + private struct RGB: Equatable { + let red: Int + let green: Int + let blue: Int + } + + func testResolveThemeNamePrefersLightEntryForPairedTheme() { + let resolved = GhosttyConfig.resolveThemeName( + from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", + preferredColorScheme: .light + ) + + XCTAssertEqual(resolved, "Builtin Solarized Light") + } + + func testResolveThemeNamePrefersDarkEntryForPairedTheme() { + let resolved = GhosttyConfig.resolveThemeName( + from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark", + preferredColorScheme: .dark + ) + + XCTAssertEqual(resolved, "Builtin Solarized Dark") + } + + func testThemeNameCandidatesIncludeBuiltinAliasForms() { + let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Light") + XCTAssertEqual(candidates.first, "Builtin Solarized Light") + XCTAssertTrue(candidates.contains("Solarized Light")) + XCTAssertTrue(candidates.contains("iTerm2 Solarized Light")) + } + + func testThemeNameCandidatesMapSolarizedDarkToITerm2Alias() { + let candidates = GhosttyConfig.themeNameCandidates(from: "Builtin Solarized Dark") + XCTAssertTrue(candidates.contains("Solarized Dark")) + XCTAssertTrue(candidates.contains("iTerm2 Solarized Dark")) + } + + func testThemeSearchPathsIncludeXDGDataDirsThemes() { + let pathA = "/tmp/cmux-theme-a" + let pathB = "/tmp/cmux-theme-b" + let paths = GhosttyConfig.themeSearchPaths( + forThemeName: "Solarized Light", + environment: ["XDG_DATA_DIRS": "\(pathA):\(pathB)"], + bundleResourceURL: nil + ) + + XCTAssertTrue(paths.contains("\(pathA)/ghostty/themes/Solarized Light")) + XCTAssertTrue(paths.contains("\(pathB)/ghostty/themes/Solarized Light")) + } + + func testLoadThemeResolvesPairedThemeValueByColorScheme() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ghostty-theme-pair-\(UUID().uuidString)") + let themesDir = root.appendingPathComponent("themes") + try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try """ + background = #fdf6e3 + foreground = #657b83 + """.write( + to: themesDir.appendingPathComponent("Light Theme"), + atomically: true, + encoding: .utf8 + ) + + try """ + background = #002b36 + foreground = #93a1a1 + """.write( + to: themesDir.appendingPathComponent("Dark Theme"), + atomically: true, + encoding: .utf8 + ) + + var lightConfig = GhosttyConfig() + lightConfig.loadTheme( + "light:Light Theme,dark:Dark Theme", + environment: ["GHOSTTY_RESOURCES_DIR": root.path], + bundleResourceURL: nil, + preferredColorScheme: .light + ) + XCTAssertEqual(rgb255(lightConfig.backgroundColor), RGB(red: 253, green: 246, blue: 227)) + + var darkConfig = GhosttyConfig() + darkConfig.loadTheme( + "light:Light Theme,dark:Dark Theme", + environment: ["GHOSTTY_RESOURCES_DIR": root.path], + bundleResourceURL: nil, + preferredColorScheme: .dark + ) + XCTAssertEqual(rgb255(darkConfig.backgroundColor), RGB(red: 0, green: 43, blue: 54)) + } + + func testLoadThemeResolvesBuiltinAliasFromGhosttyResourcesDir() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ghostty-themes-\(UUID().uuidString)") + let themesDir = root.appendingPathComponent("themes") + try FileManager.default.createDirectory(at: themesDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let themePath = themesDir.appendingPathComponent("Solarized Light") + let themeContents = """ + background = #fdf6e3 + foreground = #657b83 + """ + try themeContents.write(to: themePath, atomically: true, encoding: .utf8) + + var config = GhosttyConfig() + config.loadTheme( + "Builtin Solarized Light", + environment: ["GHOSTTY_RESOURCES_DIR": root.path], + bundleResourceURL: nil + ) + + XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227)) + } + + private func rgb255(_ color: NSColor) -> RGB { + let srgb = color.usingColorSpace(.sRGB)! + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + srgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return RGB( + red: Int(round(red * 255)), + green: Int(round(green * 255)), + blue: Int(round(blue * 255)) + ) + } +} diff --git a/vendor/bonsplit b/vendor/bonsplit index 748d9c0f..ae234a22 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 748d9c0fe12edebd5448b946ce2c23d7549cd073 +Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87