Fix theme initialization to respect configured/system appearance (#161)

* Respect configured appearance before Ghostty initialization

* Apply Ghostty theme colors to cmux titlebar and tab chrome

* Fix builtin theme resolution and tab strip icon contrast

* Alias builtin Solarized names to current Ghostty themes

* Sync tab chrome with active Ghostty theme

* Fix system appearance theme switching for surfaces

* Refresh titlebar on background theme updates

* Refresh split overlay config on appearance change

* Update bonsplit for chrome color review fixes
This commit is contained in:
Lawrence Chen 2026-02-20 04:51:01 -08:00 committed by GitHub
parent 6f9146e895
commit 7b2675cd1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 629 additions and 40 deletions

View file

@ -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 = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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;
};

View file

@ -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<Void, Never>?
@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 }

View file

@ -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[..<range.lowerBound])
appendCandidate(stripped)
queue.append(stripped)
}
}
return candidates
}
static func themeSearchPaths(
forThemeName themeName: String,
environment: [String: String],
bundleResourceURL: URL?
) -> [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 }

View file

@ -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<T>(_ 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)

View file

@ -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,

View file

@ -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 {

View file

@ -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

View file

@ -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() {

View file

@ -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))
)
}
}

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 748d9c0fe12edebd5448b946ce2c23d7549cd073
Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87