cmux/Sources/GhosttyConfig.swift
2026-01-28 21:19:48 -08:00

233 lines
7.8 KiB
Swift

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