Honor Ghostty background-opacity across all cmux chrome (#667)

* Honor Ghostty background-opacity across all cmux chrome

Parse background-opacity from Ghostty config and propagate it through
the entire chrome pipeline: bonsplit tab bar (via RRGGBBAA hex),
browser panel/omnibar, titlebar, empty panel, and window background.

Decouple glass effect from sidebar blend mode — bgGlassEnabled now
defaults to false so opacity works independently. Add
GhosttyBackgroundTheme helper for consistent color+opacity resolution
across all UI surfaces.

Fixes https://github.com/manaflow-ai/cmux/issues/263

* Titlebar and chrome opacity matches terminal background-opacity

Use CALayer-level opacity for the titlebar background instead of SwiftUI
Color alpha, matching the terminal's Metal compositing path. Account for
the double alpha stacking in the terminal area (Bonsplit container bg +
Ghostty renderer) so the titlebar visually matches.

Also fix opacity-only config changes not triggering titlebar refresh on
Cmd+Shift+, reload.
This commit is contained in:
Lawrence Chen 2026-03-01 03:48:46 -08:00 committed by GitHub
parent e295f384fc
commit bc1b6fd9eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 414 additions and 76 deletions

View file

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

View file

@ -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

View file

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

View file

@ -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) {

View file

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

View file

@ -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,

View file

@ -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

View file

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

View file

@ -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

View file

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

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit c4b8f5cc3def0a44c1c3634d4f358a66fd956606
Subproject commit 335facd9fd1d81a3c71fea69345af30f7e3601f9