Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 72 additions & 30 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Comment on lines +2478 to +2481
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reapply browser-window background when Ghostty theme changes

This fallback path does not actually run on subsequent theme/opacity updates, so browser-focused workspaces can keep a stale NSWindow background after background-opacity changes. refreshGhosttyAppearanceConfig already skips terminalPanel.applyWindowBackgroundIfActive() when no terminal is focused, and this code relies on WindowAccessor to handle that case, but WindowAccessor defaults to dedupeByWindow = true and short-circuits callbacks unless the window instance changes (Sources/WindowAccessor.swift, guard on lastWindow !== window), so the newly added browser fallback is effectively one-shot.

Useful? React with 👍 / 👎.

}

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)
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -8976,18 +9016,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)
}
}
5 changes: 5 additions & 0 deletions Sources/GhosttyConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Expand Down Expand Up @@ -148,6 +149,10 @@ struct GhosttyConfig {
if let color = NSColor(hex: value) {
backgroundColor = color
}
case "background-opacity":
if let opacity = Double(value) {
backgroundOpacity = opacity
}
Comment on lines +152 to +155
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate and clamp parsed background-opacity range.

Line 153 currently accepts any Double. Clamp to [0.0, 1.0] (and reject non-finite values) to prevent invalid opacity state from propagating.

🔧 Proposed fix
                 case "background-opacity":
-                    if let opacity = Double(value) {
-                        backgroundOpacity = opacity
+                    if let opacity = Double(value), opacity.isFinite {
+                        backgroundOpacity = min(1.0, max(0.0, opacity))
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyConfig.swift` around lines 152 - 155, The "background-opacity"
parsing currently assigns any Double(value) to backgroundOpacity; change this to
validate the parsed Double for finiteness and clamp it into the [0.0, 1.0] range
before assigning. In the switch case handling "background-opacity" (where you
currently call Double(value) and set backgroundOpacity), parse to a Double,
ensure it is finite (not NaN or infinite), then clamp with min/max (or
equivalent) to 0.0/1.0; if the value is non-finite or cannot be parsed, leave
backgroundOpacity unchanged (or handle via existing error path) so invalid
values do not propagate.

case "foreground":
if let color = NSColor(hex: value) {
foregroundColor = color
Expand Down
55 changes: 39 additions & 16 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -2361,23 +2374,23 @@ 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
let overrideHex = backgroundColor?.hexString() ?? "nil"
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))"
)
}
}
Expand Down Expand Up @@ -3790,6 +3803,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] = [:]
Expand Down Expand Up @@ -3929,10 +3949,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
Expand Down Expand Up @@ -4147,9 +4168,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()
}

Expand Down
53 changes: 50 additions & 3 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1405,7 +1452,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
Expand Down Expand Up @@ -1622,7 +1669,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)
}
Expand Down Expand Up @@ -2391,7 +2438,7 @@ extension BrowserPanel {
}

func refreshAppearanceDrivenColors() {
webView.underPageBackgroundColor = Self.resolvedBrowserChromeBackgroundColor()
webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor()
}

func suppressOmnibarAutofocus(for seconds: TimeInterval) {
Expand Down
Loading
Loading