cmux/Sources/GhosttyConfig.swift
Ragnar Rova a7cb968a55
Add configurable sidebar tint color with light/dark mode support (#1465)
- Config: sidebar-background supports plain hex (#336699) or
  light/dark syntax (light:#fbf3db,dark:#103c48)
- Config: sidebar-tint-opacity overrides tint opacity
- Settings UI: per-scheme color pickers, opacity slider (0-70%), reset
- SidebarBackdrop resolves light/dark hex based on @Environment colorScheme
- applySidebarAppearanceToUserDefaults guards on rawSidebarBackground presence
  so UI picks survive appearance toggles when no config is set
- Stale light/dark UserDefaults keys cleared when config switches from
  dual-mode to single or sidebar-background is removed
- applyPreset() and Reset Tint clear per-scheme overrides
- Debug snapshot (combinedPayload + copySidebarConfig) includes new keys
- ColorPicker labels use String(localized:) per localization policy
- Opacity slider capped at 0.7 to match debug view vibrancy constraint

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:48:57 -07:00

592 lines
21 KiB
Swift

import Foundation
import AppKit
struct GhosttyConfig {
enum ColorSchemePreference: Hashable {
case light
case dark
}
private static let cmuxReleaseBundleIdentifier = "com.cmuxterm.app"
private static let loadCacheLock = NSLock()
private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:]
var fontFamily: String = "Menlo"
var fontSize: CGFloat = 12
var theme: String?
var workingDirectory: String?
var scrollbackLimit: Int = 10000
var unfocusedSplitOpacity: Double = 0.7
var unfocusedSplitFill: NSColor?
var splitDividerColor: NSColor?
// Colors (from theme or config)
var backgroundColor: NSColor = NSColor(hex: "#272822")!
var backgroundOpacity: Double = 1.0
var foregroundColor: NSColor = NSColor(hex: "#fdfff1")!
var cursorColor: NSColor = NSColor(hex: "#c0c1b5")!
var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")!
var selectionBackground: NSColor = NSColor(hex: "#57584f")!
var selectionForeground: NSColor = NSColor(hex: "#fdfff1")!
// Sidebar appearance
var rawSidebarBackground: String?
var sidebarBackground: NSColor?
var sidebarBackgroundLight: NSColor?
var sidebarBackgroundDark: NSColor?
var sidebarTintOpacity: Double?
// Palette colors (0-15)
var palette: [Int: NSColor] = [:]
var unfocusedSplitOverlayOpacity: Double {
let clamped = min(1.0, max(0.15, unfocusedSplitOpacity))
return min(1.0, max(0.0, 1.0 - clamped))
}
var unfocusedSplitOverlayFill: NSColor {
unfocusedSplitFill ?? backgroundColor
}
var resolvedSplitDividerColor: NSColor {
if let splitDividerColor {
return splitDividerColor
}
let isLightBackground = backgroundColor.isLightColor
return backgroundColor.darken(by: isLightBackground ? 0.08 : 0.4)
}
static func load(
preferredColorScheme: ColorSchemePreference? = nil,
useCache: Bool = true,
loadFromDisk: (_ preferredColorScheme: ColorSchemePreference) -> GhosttyConfig = Self.loadFromDisk
) -> GhosttyConfig {
let resolvedColorScheme = preferredColorScheme ?? currentColorSchemePreference()
if useCache, let cached = cachedLoad(for: resolvedColorScheme) {
return cached
}
let loaded = loadFromDisk(resolvedColorScheme)
if useCache {
storeCachedLoad(loaded, for: resolvedColorScheme)
}
return loaded
}
static func invalidateLoadCache() {
loadCacheLock.lock()
cachedConfigsByColorScheme.removeAll()
loadCacheLock.unlock()
}
private static func cachedLoad(for colorScheme: ColorSchemePreference) -> GhosttyConfig? {
loadCacheLock.lock()
defer { loadCacheLock.unlock() }
return cachedConfigsByColorScheme[colorScheme]
}
private static func storeCachedLoad(
_ config: GhosttyConfig,
for colorScheme: ColorSchemePreference
) {
loadCacheLock.lock()
cachedConfigsByColorScheme[colorScheme] = config
loadCacheLock.unlock()
}
private static func cmuxConfigPaths(
fileManager: FileManager = .default,
currentBundleIdentifier: String? = Bundle.main.bundleIdentifier
) -> [String] {
guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return []
}
func paths(for bundleIdentifier: String) -> [String] {
let directory = appSupport.appendingPathComponent(bundleIdentifier, isDirectory: true)
return [
directory.appendingPathComponent("config", isDirectory: false).path,
directory.appendingPathComponent("config.ghostty", isDirectory: false).path,
]
}
func hasConfig(_ paths: [String]) -> Bool {
paths.contains { path in
guard let attributes = try? fileManager.attributesOfItem(atPath: path),
let type = attributes[.type] as? FileAttributeType,
type == .typeRegular,
let size = attributes[.size] as? NSNumber else {
return false
}
return size.intValue > 0
}
}
let releasePaths = paths(for: cmuxReleaseBundleIdentifier)
guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else {
return releasePaths
}
if currentBundleIdentifier == cmuxReleaseBundleIdentifier {
return releasePaths
}
let currentPaths = paths(for: currentBundleIdentifier)
if hasConfig(currentPaths) {
return currentPaths
}
if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) {
return releasePaths
}
return []
}
mutating func resolveSidebarBackground(preferredColorScheme: ColorSchemePreference) {
guard let raw = rawSidebarBackground else { return }
let lightResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .light)
let darkResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .dark)
let hasDualMode = lightResolved != darkResolved
if hasDualMode {
sidebarBackgroundLight = NSColor(hex: lightResolved)
sidebarBackgroundDark = NSColor(hex: darkResolved)
}
let resolved = Self.resolveThemeName(from: raw, preferredColorScheme: preferredColorScheme)
if let color = NSColor(hex: resolved) {
sidebarBackground = color
}
}
func applySidebarAppearanceToUserDefaults() {
guard rawSidebarBackground != nil else {
if let opacity = sidebarTintOpacity {
UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity")
}
return
}
let defaults = UserDefaults.standard
if let light = sidebarBackgroundLight {
defaults.set(light.hexString(), forKey: "sidebarTintHexLight")
} else {
defaults.removeObject(forKey: "sidebarTintHexLight")
}
if let dark = sidebarBackgroundDark {
defaults.set(dark.hexString(), forKey: "sidebarTintHexDark")
} else {
defaults.removeObject(forKey: "sidebarTintHexDark")
}
if let color = sidebarBackground {
defaults.set(color.hexString(), forKey: "sidebarTintHex")
} else {
defaults.removeObject(forKey: "sidebarTintHex")
}
if let opacity = sidebarTintOpacity {
defaults.set(opacity, forKey: "sidebarTintOpacity")
}
}
private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig {
var config = GhosttyConfig()
// Match Ghostty's default load order on macOS.
let 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()
for path in configPaths {
if let contents = readConfigFile(at: path) {
config.parse(contents)
}
}
// Load theme if specified
if let themeName = config.theme {
config.loadTheme(
themeName,
environment: ProcessInfo.processInfo.environment,
bundleResourceURL: Bundle.main.resourceURL,
preferredColorScheme: preferredColorScheme
)
}
config.resolveSidebarBackground(preferredColorScheme: preferredColorScheme)
config.applySidebarAppearanceToUserDefaults()
return config
}
mutating func parse(_ contents: String) {
let lines = contents.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") {
continue
}
let parts = trimmed.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
let key = parts[0].trimmingCharacters(in: .whitespaces)
let value = parts[1].trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
switch key {
case "font-family":
fontFamily = value
case "font-size":
if let size = Double(value) {
fontSize = CGFloat(size)
}
case "theme":
theme = value
case "working-directory":
workingDirectory = value
case "scrollback-limit":
if let limit = Int(value) {
scrollbackLimit = limit
}
case "background":
if let color = NSColor(hex: value) {
backgroundColor = color
}
case "background-opacity":
if let opacity = Double(value) {
backgroundOpacity = opacity
}
case "foreground":
if let color = NSColor(hex: value) {
foregroundColor = color
}
case "cursor-color":
if let color = NSColor(hex: value) {
cursorColor = color
}
case "cursor-text":
if let color = NSColor(hex: value) {
cursorTextColor = color
}
case "selection-background":
if let color = NSColor(hex: value) {
selectionBackground = color
}
case "selection-foreground":
if let color = NSColor(hex: value) {
selectionForeground = color
}
case "palette":
// Parse palette entries like "0=#272822"
let paletteParts = value.split(separator: "=", maxSplits: 1)
if paletteParts.count == 2,
let index = Int(paletteParts[0]),
let color = NSColor(hex: String(paletteParts[1])) {
palette[index] = color
}
case "unfocused-split-opacity":
if let opacity = Double(value) {
unfocusedSplitOpacity = opacity
}
case "unfocused-split-fill":
if let color = NSColor(hex: value) {
unfocusedSplitFill = color
}
case "split-divider-color":
if let color = NSColor(hex: value) {
splitDividerColor = color
}
case "sidebar-background":
rawSidebarBackground = value
case "sidebar-tint-opacity":
if let opacity = Double(value) {
sidebarTintOpacity = min(max(opacity, 0), 1)
}
default:
break
}
}
}
}
mutating func loadTheme(_ name: String) {
loadTheme(
name,
environment: ProcessInfo.processInfo.environment,
bundleResourceURL: Bundle.main.resourceURL
)
}
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 compatibilityAliasGroups: [[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)
}
for group in compatibilityAliasGroups {
if group.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) {
for alias in group where alias.caseInsensitiveCompare(trimmed) != .orderedSame {
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 }
if let attributes = try? fileManager.attributesOfItem(atPath: path) {
if let type = attributes[.type] as? FileAttributeType, type != .typeRegular {
return nil
}
if let size = attributes[.size] as? NSNumber, size.intValue == 0 {
return nil
}
}
return try? String(contentsOfFile: path, encoding: .utf8)
}
}
extension NSColor {
convenience init?(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
return nil
}
let r, g, b: CGFloat
if hexSanitized.count == 6 {
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
b = CGFloat(rgb & 0x0000FF) / 255.0
} else {
return nil
}
self.init(red: r, green: g, blue: b, alpha: 1.0)
}
var isLightColor: Bool {
luminance > 0.5
}
var luminance: Double {
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
guard let rgb = usingColorSpace(.sRGB) else { return 0 }
rgb.getRed(&r, green: &g, blue: &b, alpha: &a)
return (0.299 * r) + (0.587 * g) + (0.114 * b)
}
func darken(by amount: CGFloat) -> NSColor {
var h: CGFloat = 0
var s: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
getHue(&h, saturation: &s, brightness: &b, alpha: &a)
return NSColor(
hue: h,
saturation: s,
brightness: min(b * (1 - amount), 1),
alpha: a
)
}
}