import Foundation import AppKit struct 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 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")! // 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() -> 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 } 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) } 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 "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 } default: break } } } } mutating func loadTheme(_ name: String) { let bundleThemePath = Bundle.main.resourceURL? .appendingPathComponent("ghostty/themes/\(name)") .path 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 } } } 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 ) } }