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:
parent
e295f384fc
commit
bc1b6fd9eb
11 changed files with 414 additions and 76 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit c4b8f5cc3def0a44c1c3634d4f358a66fd956606
|
||||
Subproject commit 335facd9fd1d81a3c71fea69345af30f7e3601f9
|
||||
Loading…
Add table
Add a link
Reference in a new issue