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:
parent
6f9146e895
commit
7b2675cd1e
10 changed files with 629 additions and 40 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
142
cmuxTests/GhosttyConfigTests.swift
Normal file
142
cmuxTests/GhosttyConfigTests.swift
Normal 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
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 748d9c0fe12edebd5448b946ce2c23d7549cd073
|
||||
Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87
|
||||
Loading…
Add table
Add a link
Reference in a new issue