diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 620fe3ee..49d634f0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -227,6 +227,26 @@ enum WindowGlassEffect { } } +/// CALayer-backed titlebar background. Uses layer-level opacity (not per-pixel alpha) +/// to match how the terminal's Metal surface composites its background. +struct TitlebarLayerBackground: NSViewRepresentable { + var backgroundColor: NSColor + var opacity: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + view.layer?.opacity = Float(opacity) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + nsView.layer?.opacity = Float(opacity) + } +} + final class SidebarState: ObservableObject { @Published var isVisible: Bool @Published var persistedWidth: CGFloat @@ -1831,19 +1851,11 @@ struct ContentView: View { // Background glass settings @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 - @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false @AppStorage("debugTitlebarLeadingExtra") private var debugTitlebarLeadingExtra: Double = 0 @State private var titlebarLeadingInset: CGFloat = 12 private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } - private var fakeTitlebarBackground: Color { - _ = titlebarThemeGeneration - let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor - 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 { _ = titlebarThemeGeneration let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor @@ -1902,7 +1914,17 @@ struct ContentView: View { .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) - .background(fakeTitlebarBackground) + .background({ + // The terminal area has two stacked semi-transparent layers: the Bonsplit + // container chrome background plus Ghostty's own Metal-rendered background. + // Compute the effective composited opacity so the titlebar matches visually. + let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) + let effective = alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2) + return TitlebarLayerBackground( + backgroundColor: GhosttyApp.shared.defaultBackgroundColor, + opacity: effective + ) + }()) .overlay(alignment: .bottom) { Rectangle() .fill(Color(nsColor: .separatorColor)) @@ -2435,23 +2457,31 @@ struct ContentView: View { // Background glass: skip on macOS 26+ where NSGlassEffectView can cause blank // or incorrectly tinted SwiftUI content. Keep native window rendering there so // Ghostty theme colors remain authoritative. - if sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue + let currentThemeBackground = GhosttyBackgroundTheme.currentColor() + let shouldApplyWindowGlassFallback = + sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue && bgGlassEnabled - && !WindowGlassEffect.isAvailable { + && !WindowGlassEffect.isAvailable + let shouldForceTransparentHosting = + shouldApplyWindowGlassFallback || currentThemeBackground.alphaComponent < 0.999 + + if shouldForceTransparentHosting { window.isOpaque = false - window.backgroundColor = .clear - // Configure contentView and all subviews for transparency + // Keep the window clear whenever translucency is active. Relying only on + // terminal focus-driven updates can leave stale opaque window fills. + window.backgroundColor = NSColor.white.withAlphaComponent(0.001) + // Configure contentView hierarchy for transparency. if let contentView = window.contentView { - contentView.wantsLayer = true - contentView.layer?.backgroundColor = NSColor.clear.cgColor - contentView.layer?.isOpaque = false - // Make SwiftUI hosting view transparent - for subview in contentView.subviews { - subview.wantsLayer = true - subview.layer?.backgroundColor = NSColor.clear.cgColor - subview.layer?.isOpaque = false - } + makeViewHierarchyTransparent(contentView) } + } else { + // Browser-focused workspaces may not have an active terminal panel to refresh + // the NSWindow background. Keep opaque theme changes applied here as well. + window.backgroundColor = currentThemeBackground + window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 + } + + if shouldApplyWindowGlassFallback { // Apply liquid glass effect to the window with tint from settings let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) WindowGlassEffect.apply(to: window, tintColor: tintColor) @@ -2519,6 +2549,16 @@ struct ContentView: View { sidebarSelectionState.selection = .tabs } + private func makeViewHierarchyTransparent(_ root: NSView) { + var stack: [NSView] = [root] + while let view = stack.popLast() { + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.clear.cgColor + view.layer?.isOpaque = false + stack.append(contentsOf: view.subviews) + } + } + private func updateWindowGlassTint() { // Find this view's main window by identifier (keyWindow might be a debug panel/settings). guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == windowIdentifier }) else { return } @@ -9008,18 +9048,20 @@ enum SidebarPresetOption: String, CaseIterable, Identifiable { } extension NSColor { - func hexString() -> String { + func hexString(includeAlpha: Bool = false) -> String { let color = usingColorSpace(.sRGB) ?? self var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - return String( - format: "#%02X%02X%02X", - min(255, max(0, Int(red * 255))), - min(255, max(0, Int(green * 255))), - min(255, max(0, Int(blue * 255))) - ) + let redByte = min(255, max(0, Int(red * 255))) + let greenByte = min(255, max(0, Int(green * 255))) + let blueByte = min(255, max(0, Int(blue * 255))) + if includeAlpha { + let alphaByte = min(255, max(0, Int(alpha * 255))) + return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) + } + return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) } } diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index a3516ae2..1e3aae49 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -21,6 +21,7 @@ struct GhosttyConfig { // 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")! @@ -148,6 +149,10 @@ struct GhosttyConfig { 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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 46f7b101..cd120bb1 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10,12 +10,22 @@ import Bonsplit import IOSurface #if os(macOS) -private func cmuxShouldUseTransparentBackgroundWindow() -> Bool { +func cmuxShouldUseTransparentBackgroundWindow() -> Bool { let defaults = UserDefaults.standard let sidebarBlendMode = defaults.string(forKey: "sidebarBlendMode") ?? "withinWindow" - let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? true + let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false return sidebarBlendMode == "behindWindow" && bgGlassEnabled && !WindowGlassEffect.isAvailable } + +func cmuxShouldUseClearWindowBackground(for opacity: Double) -> Bool { + cmuxShouldUseTransparentBackgroundWindow() || opacity < 0.999 +} + +private func cmuxTransparentWindowBaseColor() -> NSColor { + // A tiny non-zero alpha matches Ghostty's window compositing behavior on macOS and + // avoids visual artifacts that can happen with a fully clear window background. + NSColor.white.withAlphaComponent(0.001) +} #endif #if DEBUG @@ -831,6 +841,7 @@ class GhosttyApp { var opacity = defaultBackgroundOpacity let opacityKey = "background-opacity" _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) + opacity = min(1.0, max(0.0, opacity)) applyDefaultBackground( color: resolvedColor, opacity: opacity, @@ -1391,11 +1402,11 @@ class GhosttyApp { private func applyBackgroundToKeyWindow() { guard let window = activeMainWindow() else { return } - if cmuxShouldUseTransparentBackgroundWindow() { - window.backgroundColor = .clear + if cmuxShouldUseClearWindowBackground(for: defaultBackgroundOpacity) { + window.backgroundColor = cmuxTransparentWindowBaseColor() window.isOpaque = false if backgroundLogEnabled { - logBackground("applied transparent window for behindWindow blur") + logBackground("applied transparent window background opacity=\(String(format: "%.3f", defaultBackgroundOpacity))") } } else { let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity) @@ -2334,8 +2345,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let layer { CATransaction.begin() CATransaction.setDisableActions(true) - layer.backgroundColor = color.cgColor - layer.isOpaque = color.alphaComponent >= 1.0 + // GhosttySurfaceScrollView owns the panel background fill. Keeping this layer clear + // avoids stacking multiple identical translucent backgrounds (which looks opaque). + layer.backgroundColor = NSColor.clear.cgColor + layer.isOpaque = false CATransaction.commit() } terminalSurface?.hostedView.setBackgroundColor(color) @@ -2361,15 +2374,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } applySurfaceBackground() let color = effectiveBackgroundColor() - if cmuxShouldUseTransparentBackgroundWindow() { - window.backgroundColor = .clear + if cmuxShouldUseClearWindowBackground(for: color.alphaComponent) { + window.backgroundColor = cmuxTransparentWindowBaseColor() window.isOpaque = false } else { window.backgroundColor = color window.isOpaque = color.alphaComponent >= 1.0 } if GhosttyApp.shared.backgroundLogEnabled { - let signature = "\(cmuxShouldUseTransparentBackgroundWindow() ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))" + let signature = "\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent) ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))" if signature != lastLoggedWindowBackgroundSignature { lastLoggedWindowBackgroundSignature = signature let hasOverride = backgroundColor != nil @@ -2377,7 +2390,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() let source = hasOverride ? "surfaceOverride" : "defaultBackground" GhosttyApp.shared.logBackground( - "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) transparent=\(cmuxShouldUseTransparentBackgroundWindow()) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" + "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) transparent=\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent)) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" ) } } @@ -3810,6 +3823,13 @@ final class GhosttySurfaceScrollView: NSView { private var pendingDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. + + private static func panelBackgroundFillColor(for terminalBackgroundColor: NSColor) -> NSColor { + // The Ghostty renderer already draws translucent terminal backgrounds. If we paint an + // additional translucent layer here, alpha stacks and appears effectively opaque. + terminalBackgroundColor.alphaComponent < 0.999 ? .clear : terminalBackgroundColor + } + #if DEBUG private var lastDropZoneOverlayLogSignature: String? private static var flashCounts: [UUID: Int] = [:] @@ -3949,10 +3969,11 @@ final class GhosttySurfaceScrollView: NSView { layer?.masksToBounds = true backgroundView.wantsLayer = true - backgroundView.layer?.backgroundColor = - GhosttyApp.shared.defaultBackgroundColor - .withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity) - .cgColor + let initialTerminalBackground = GhosttyApp.shared.defaultBackgroundColor + .withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity) + let initialPanelFill = Self.panelBackgroundFillColor(for: initialTerminalBackground) + backgroundView.layer?.backgroundColor = initialPanelFill.cgColor + backgroundView.layer?.isOpaque = initialPanelFill.alphaComponent >= 1.0 addSubview(backgroundView) addSubview(scrollView) inactiveOverlayView.wantsLayer = true @@ -4167,9 +4188,11 @@ final class GhosttySurfaceScrollView: NSView { func setBackgroundColor(_ color: NSColor) { guard let layer = backgroundView.layer else { return } + let fillColor = Self.panelBackgroundFillColor(for: color) CATransaction.begin() CATransaction.setDisableActions(true) - layer.backgroundColor = color.cgColor + layer.backgroundColor = fillColor.cgColor + layer.isOpaque = fillColor.alphaComponent >= 1.0 CATransaction.commit() } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f91576d0..24a7150a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4,6 +4,53 @@ import WebKit import AppKit import Bonsplit +enum GhosttyBackgroundTheme { + static func clampedOpacity(_ opacity: Double) -> CGFloat { + CGFloat(max(0.0, min(1.0, opacity))) + } + + static func color(backgroundColor: NSColor, opacity: Double) -> NSColor { + backgroundColor.withAlphaComponent(clampedOpacity(opacity)) + } + + static func color( + from notification: Notification?, + fallbackColor: NSColor, + fallbackOpacity: Double + ) -> NSColor { + let userInfo = notification?.userInfo + let backgroundColor = + (userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor) + ?? fallbackColor + + let opacity: Double + if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? Double { + opacity = value + } else if let value = userInfo?[GhosttyNotificationKey.backgroundOpacity] as? NSNumber { + opacity = value.doubleValue + } else { + opacity = fallbackOpacity + } + + return color(backgroundColor: backgroundColor, opacity: opacity) + } + + static func color(from notification: Notification?) -> NSColor { + color( + from: notification, + fallbackColor: GhosttyApp.shared.defaultBackgroundColor, + fallbackOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) + } + + static func currentColor() -> NSColor { + color( + backgroundColor: GhosttyApp.shared.defaultBackgroundColor, + opacity: GhosttyApp.shared.defaultBackgroundOpacity + ) + } +} + enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google case duckduckgo @@ -1429,7 +1476,7 @@ final class BrowserPanel: Panel, ObservableObject { // Match the empty-page background to the terminal theme so newly-created browsers // don't flash white before content loads. - webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor() + webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor() // Always present as Safari. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent @@ -1646,7 +1693,7 @@ final class BrowserPanel: Panel, ObservableObject { NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange) .sink { [weak self] notification in guard let self else { return } - self.webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor(from: notification) + self.webView.underPageBackgroundColor = GhosttyBackgroundTheme.color(from: notification) } .store(in: &cancellables) } @@ -2418,7 +2465,7 @@ extension BrowserPanel { } func refreshAppearanceDrivenColors() { - webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor() + webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor() } func suppressOmnibarAutofocus(for seconds: TimeInterval) { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index ea282f33..1b46904c 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -231,6 +231,7 @@ struct BrowserPanelView: View { @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var isBrowserThemeMenuPresented = false + @State private var ghosttyBackgroundGeneration: Int = 0 // Keep this below half of the compact omnibar height so it reads as a squircle, // not a capsule. private let omnibarPillCornerRadius: CGFloat = 10 @@ -275,17 +276,24 @@ struct BrowserPanelView: View { BrowserThemeSettings.mode(for: browserThemeModeRaw) } + private var browserChromeBackground: Color { + _ = ghosttyBackgroundGeneration + return Color(nsColor: GhosttyBackgroundTheme.currentColor()) + } + private var browserChromeBackgroundColor: NSColor { - resolvedBrowserChromeBackgroundColor( + _ = ghosttyBackgroundGeneration + return resolvedBrowserChromeBackgroundColor( for: colorScheme, - themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() ) } private var browserChromeColorScheme: ColorScheme { - resolvedBrowserChromeColorScheme( + _ = ghosttyBackgroundGeneration + return resolvedBrowserChromeColorScheme( for: colorScheme, - themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + themeBackgroundColor: GhosttyBackgroundTheme.currentColor() ) } @@ -454,6 +462,9 @@ struct BrowserPanelView: View { addressBarFocused = false } } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + ghosttyBackgroundGeneration &+= 1 + } } private var addressBar: some View { @@ -471,7 +482,7 @@ struct BrowserPanelView: View { } .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) - .background(Color(nsColor: browserChromeBackgroundColor)) + .background(browserChromeBackground) // Keep the omnibar stack above WKWebView so the suggestions popup is visible. .zIndex(1) .environment(\.colorScheme, browserChromeColorScheme) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 46924d9e..4417cb13 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -998,7 +998,19 @@ final class Workspace: Identifiable, ObservableObject { } private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { - bonsplitAppearance(from: config.backgroundColor) + bonsplitAppearance( + from: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity + ) + } + + static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { + let themedColor = GhosttyBackgroundTheme.color( + backgroundColor: backgroundColor, + opacity: backgroundOpacity + ) + let includeAlpha = themedColor.alphaComponent < 0.999 + return themedColor.hexString(includeAlpha: includeAlpha) } nonisolated static func resolvedChromeColors( @@ -1007,37 +1019,49 @@ final class Workspace: Identifiable, ObservableObject { .init(backgroundHex: backgroundColor.hexString()) } - private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { - let chromeColors = resolvedChromeColors(from: backgroundColor) - return BonsplitConfiguration.Appearance( + private static func bonsplitAppearance( + from backgroundColor: NSColor, + backgroundOpacity: Double + ) -> BonsplitConfiguration.Appearance { + BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, - chromeColors: chromeColors + chromeColors: .init( + backgroundHex: Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) + ) ) } func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { - applyGhosttyChrome(backgroundColor: config.backgroundColor, reason: reason) + applyGhosttyChrome( + backgroundColor: config.backgroundColor, + backgroundOpacity: config.backgroundOpacity, + reason: reason + ) } - func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { + func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { + let nextHex = Self.bonsplitChromeHex( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundOpacity + ) let currentChromeColors = bonsplitController.configuration.appearance.chromeColors - let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor) - let isNoOp = currentChromeColors.backgroundHex == nextChromeColors.backgroundHex && - currentChromeColors.borderHex == nextChromeColors.borderHex + let isNoOp = currentChromeColors.backgroundHex == nextHex if GhosttyApp.shared.backgroundLogEnabled { let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" - let nextBackgroundHex = nextChromeColors.backgroundHex ?? "nil" GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" + "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" ) } if isNoOp { return } - bonsplitController.configuration.appearance.chromeColors = nextChromeColors + bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" @@ -1045,6 +1069,14 @@ final class Workspace: Identifiable, ObservableObject { } } + func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { + applyGhosttyChrome( + backgroundColor: backgroundColor, + backgroundOpacity: backgroundColor.alphaComponent, + reason: reason + ) + } + init( title: String = "Terminal", workingDirectory: String? = nil, @@ -1067,7 +1099,10 @@ final class Workspace: Identifiable, ObservableObject { // and keep split entry instantaneous. // Avoid re-reading/parsing Ghostty config on every new workspace; this hot path // runs for socket/CLI workspace creation and can cause visible typing lag. - let appearance = Self.bonsplitAppearance(from: GhosttyApp.shared.defaultBackgroundColor) + let appearance = Self.bonsplitAppearance( + from: GhosttyApp.shared.defaultBackgroundColor, + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity + ) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 0d3cc451..e8c087ac 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -177,7 +177,8 @@ struct WorkspaceContentView: View { reason: String = "unspecified", backgroundOverride: NSColor? = nil, loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() }, - defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor } + defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }, + defaultBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity } ) -> GhosttyConfig { var next = loadConfig() let loadedBackgroundHex = next.backgroundColor.hexString() @@ -194,9 +195,12 @@ struct WorkspaceContentView: View { } next.backgroundColor = resolvedBackground + // Use the runtime opacity from the Ghostty engine, which may differ from the + // file-level value parsed by GhosttyConfig.load(). + next.backgroundOpacity = defaultBackgroundOpacity() if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( - "theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")" + "theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) opacity=\(String(format: "%.3f", next.backgroundOpacity)) theme=\(next.theme ?? "nil")" ) } return next @@ -218,7 +222,8 @@ struct WorkspaceContentView: View { let sourceLabel = backgroundSource ?? "nil" let payloadLabel = notificationPayloadHex ?? "nil" let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString() - let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear" + let opacityChanged = abs(config.backgroundOpacity - next.backgroundOpacity) > 0.0001 + let shouldRequestTitlebarRefresh = backgroundChanged || opacityChanged || reason == "onAppear" logTheme( "theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")" ) @@ -400,7 +405,7 @@ struct EmptyPanelView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: .windowBackgroundColor)) + .background(Color(nsColor: GhosttyBackgroundTheme.currentColor())) #if DEBUG .onAppear { DebugUIEventCounters.emptyPanelAppearCount += 1 diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 80bde744..443cf148 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1316,7 +1316,7 @@ private enum DebugWindowConfigSnapshot { """ let backgroundPayload = """ - bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: true)) + bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: false)) bgGlassMaterial=\(stringValue(defaults, key: "bgGlassMaterial", fallback: "hudWindow")) bgGlassTintHex=\(stringValue(defaults, key: "bgGlassTintHex", fallback: "#000000")) bgGlassTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "bgGlassTintOpacity", fallback: 0.03))) @@ -2373,7 +2373,7 @@ private struct BackgroundDebugView: View { @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 @AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow" - @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false var body: some View { ScrollView { @@ -2419,7 +2419,7 @@ private struct BackgroundDebugView: View { bgGlassTintHex = "#000000" bgGlassTintOpacity = 0.03 bgGlassMaterial = "hudWindow" - bgGlassEnabled = true + bgGlassEnabled = false updateWindowGlassTint() } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3a90c8a1..d524f4a5 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1302,6 +1302,92 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { panel.setBrowserThemeMode(.system) XCTAssertNil(panel.webView.appearance) } + + func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() { + let panel = BrowserPanel(workspaceId: UUID()) + let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) + + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: updatedColor, + GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), + ] + ) + + guard let actual = panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB), + let expected = updatedColor.withAlphaComponent(0.57).usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible under-page background colors") + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.005) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.005) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.005) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.005) + } +} + +final class GhosttyBackgroundThemeTests: XCTestCase { + func testColorClampsOpacity() { + let base = NSColor(srgbRed: 0.10, green: 0.20, blue: 0.30, alpha: 1.0) + + let lowerClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: -2.0) + XCTAssertEqual(lowerClamped.alphaComponent, 0.0, accuracy: 0.0001) + + let upperClamped = GhosttyBackgroundTheme.color(backgroundColor: base, opacity: 5.0) + XCTAssertEqual(upperClamped.alphaComponent, 1.0, accuracy: 0.0001) + } + + func testColorFromNotificationUsesBackgroundAndOpacity() { + let fallbackColor = NSColor.black + let fallbackOpacity = 1.0 + let notification = Notification( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0), + GhosttyNotificationKey.backgroundOpacity: NSNumber(value: 0.57), + ] + ) + + let actual = GhosttyBackgroundTheme.color( + from: notification, + fallbackColor: fallbackColor, + fallbackOpacity: fallbackOpacity + ) + guard let srgb = actual.usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(srgb.redComponent, 0.18, accuracy: 0.005) + XCTAssertEqual(srgb.greenComponent, 0.29, accuracy: 0.005) + XCTAssertEqual(srgb.blueComponent, 0.44, accuracy: 0.005) + XCTAssertEqual(srgb.alphaComponent, 0.57, accuracy: 0.005) + } + + func testColorFromNotificationFallsBackWhenPayloadMissing() { + let fallbackColor = NSColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0) + let fallbackOpacity = 0.42 + let notification = Notification(name: .ghosttyDefaultBackgroundDidChange) + + let actual = GhosttyBackgroundTheme.color( + from: notification, + fallbackColor: fallbackColor, + fallbackOpacity: fallbackOpacity + ) + guard let srgb = actual.usingColorSpace(.sRGB) else { + XCTFail("Expected sRGB-convertible color") + return + } + + XCTAssertEqual(srgb.redComponent, 0.12, accuracy: 0.005) + XCTAssertEqual(srgb.greenComponent, 0.34, accuracy: 0.005) + XCTAssertEqual(srgb.blueComponent, 0.56, accuracy: 0.005) + XCTAssertEqual(srgb.alphaComponent, 0.42, accuracy: 0.005) + } } @MainActor diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 3f85abba..65fe2841 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -134,6 +134,12 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(rgb255(darkConfig.backgroundColor), RGB(red: 0, green: 43, blue: 54)) } + func testParseBackgroundOpacityReadsConfigValue() { + var config = GhosttyConfig() + config.parse("background-opacity = 0.42") + XCTAssertEqual(config.backgroundOpacity, 0.42, accuracy: 0.0001) + } + func testLoadThemeResolvesBuiltinAliasFromGhosttyResourcesDir() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-ghostty-themes-\(UUID().uuidString)") @@ -529,6 +535,84 @@ final class WorkspaceAppearanceConfigResolutionTests: XCTestCase { } } +@MainActor +final class WorkspaceChromeColorTests: XCTestCase { + func testBonsplitChromeHexIncludesAlphaWhenTranslucent() { + let color = NSColor( + srgbRed: 17.0 / 255.0, + green: 34.0 / 255.0, + blue: 51.0 / 255.0, + alpha: 1.0 + ) + + let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 0.5) + XCTAssertEqual(hex, "#1122337F") + } + + func testBonsplitChromeHexOmitsAlphaWhenOpaque() { + let color = NSColor( + srgbRed: 17.0 / 255.0, + green: 34.0 / 255.0, + blue: 51.0 / 255.0, + alpha: 1.0 + ) + + let hex = Workspace.bonsplitChromeHex(backgroundColor: color, backgroundOpacity: 1.0) + XCTAssertEqual(hex, "#112233") + } +} + +final class WindowTransparencyDecisionTests: XCTestCase { + private let sidebarBlendModeKey = "sidebarBlendMode" + private let bgGlassEnabledKey = "bgGlassEnabled" + + func testTranslucentOpacityForcesClearWindowBackgroundOutsideSidebarBlendModePath() { + withTemporaryWindowBackgroundDefaults { + let defaults = UserDefaults.standard + defaults.set("withinWindow", forKey: sidebarBlendModeKey) + defaults.set(false, forKey: bgGlassEnabledKey) + + XCTAssertFalse(cmuxShouldUseTransparentBackgroundWindow()) + XCTAssertTrue(cmuxShouldUseClearWindowBackground(for: 0.80)) + XCTAssertFalse(cmuxShouldUseClearWindowBackground(for: 1.0)) + } + } + + func testBehindWindowGlassPathStillControlsTransparentWindowFallback() { + withTemporaryWindowBackgroundDefaults { + let defaults = UserDefaults.standard + defaults.set("behindWindow", forKey: sidebarBlendModeKey) + defaults.set(true, forKey: bgGlassEnabledKey) + + let expectedTransparentFallback = !WindowGlassEffect.isAvailable + XCTAssertEqual(cmuxShouldUseTransparentBackgroundWindow(), expectedTransparentFallback) + XCTAssertEqual( + cmuxShouldUseClearWindowBackground(for: 1.0), + expectedTransparentFallback + ) + } + } + + private func withTemporaryWindowBackgroundDefaults(_ body: () -> Void) { + let defaults = UserDefaults.standard + let originalBlendMode = defaults.object(forKey: sidebarBlendModeKey) + let originalGlassEnabled = defaults.object(forKey: bgGlassEnabledKey) + defer { + restoreDefaultsValue(originalBlendMode, key: sidebarBlendModeKey, defaults: defaults) + restoreDefaultsValue(originalGlassEnabled, key: bgGlassEnabledKey, defaults: defaults) + } + body() + } + + private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } +} + final class NotificationBurstCoalescerTests: XCTestCase { func testSignalsInSameBurstFlushOnce() { let coalescer = NotificationBurstCoalescer(delay: 0.01) diff --git a/vendor/bonsplit b/vendor/bonsplit index c4b8f5cc..335facd9 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit c4b8f5cc3def0a44c1c3634d4f358a66fd956606 +Subproject commit 335facd9fd1d81a3c71fea69345af30f7e3601f9