Skip to content
Closed
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
100 changes: 83 additions & 17 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1067,12 +1067,18 @@ class GhosttyApp {
return
}

loadInlineGhosttyConfig(
"macos-background-from-layer = true",
into: fallbackConfig,
prefix: "cmux-renderer-bg",
logLabel: "renderer background (fallback)"
)
// Same layer-vs-Metal background ownership decision as the main
// config path; the minimal fallback config has an opaque default
// background, so this keeps Ghostty's stock in-Metal fill.
let fallbackUsesLayerBackground = hostLayerBackgroundDecision(probing: fallbackConfig)
if fallbackUsesLayerBackground {
loadInlineGhosttyConfig(
"macos-background-from-layer = true",
into: fallbackConfig,
prefix: "cmux-renderer-bg",
logLabel: "renderer background (fallback)"
)
}
loadInlineGhosttyConfig(
"macos-titlebar-proxy-icon = hidden",
into: fallbackConfig,
Expand All @@ -1090,7 +1096,7 @@ class GhosttyApp {
loadCmuxOwnedGhosttyKeybindOverrides(fallbackConfig)
loadNoActiveDisplayVsyncFallbackIfNeeded(fallbackConfig)
let fallbackRenderingModeChanged = setUsesHostLayerBackground(
true,
fallbackUsesLayerBackground,
source: "initialize.fallbackConfig"
)
ghostty_config_finalize(fallbackConfig)
Expand Down Expand Up @@ -1189,6 +1195,57 @@ class GhosttyApp {
}
}

/// Reads the effective background keys from a finalized clone of `config`
/// and decides whether the host CALayer must own default-background pixels.
/// A clone is probed because `theme` expansion at finalize time may set
/// background keys the raw load has not seen yet, and the real config must
/// receive the `macos-background-from-layer` injection before finalize.
private func hostLayerBackgroundDecision(probing config: ghostty_config_t) -> Bool {
guard let probe = ghostty_config_clone(config) else {
// Conservative fallback: keep the pre-existing layer-backdrop
// behavior rather than risking a transparent-window flash.
return true
}
defer { ghostty_config_free(probe) }
ghostty_config_finalize(probe)

var opacity: Double = 1.0
let opacityKey = "background-opacity"
_ = ghostty_config_get(probe, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8)))

var imagePath = ghostty_config_path_s()
let imageKey = "background-image"
let hasBackgroundImage = ghostty_config_get(
probe,
&imagePath,
imageKey,
UInt(imageKey.lengthOfBytes(using: .utf8))
)

return Self.shouldUseHostLayerBackground(
backgroundOpacity: opacity,
backgroundBlur: defaultBackgroundBlurValue(from: probe),
hasBackgroundImage: hasBackgroundImage
)
}

/// Pure decision for `macos-background-from-layer` /
/// ``usesHostLayerBackground``: the host CALayer owns the backdrop only
/// when the terminal background composites against other layers
/// (translucent, blurred, or image-backed). A plain opaque background uses
/// Ghostty's stock in-Metal fill, which is bit-identical to stock Ghostty;
/// the CoreAnimation-composited host layer drifts by +/-1/255 per channel.
static func shouldUseHostLayerBackground(
backgroundOpacity: Double,
backgroundBlur: GhosttyBackgroundBlur,
hasBackgroundImage: Bool
) -> Bool {
if backgroundOpacity < 1.0 { return true }
if backgroundBlur != .disabled { return true }
if hasBackgroundImage { return true }
return false
}

private func loadCmuxDefaultAppearanceConfig(
_ config: ghostty_config_t,
preferredColorScheme: GhosttyConfig.ColorSchemePreference
Expand Down Expand Up @@ -1309,19 +1366,28 @@ class GhosttyApp {
loadRealUserGhosttyConfig(config, preferredColorScheme: preferredColorScheme, themeColorScheme: themeColorScheme)
#endif
loadCJKFontFallbackIfNeeded(config)
// Let cmux own the window-level backdrop only when the terminal
// background actually composites against it: translucent background
// (opacity < 1), compositor blur, or a background image. That is the
// case `macos-background-from-layer` was added for (#2378): one host
// CALayer backdrop instead of separate translucent fills for terminal
// and chrome surfaces. A plain opaque background keeps Ghostty's stock
// in-Metal background fill so default-background pixels are
// bit-identical to stock Ghostty; routing them through CoreAnimation
// compositing drifts by +/-1/255 per channel.
let usesLayerBackground = hostLayerBackgroundDecision(probing: config)
let renderingModeChanged = setUsesHostLayerBackground(
true,
usesLayerBackground,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Surface reload desyncs host layer

High Severity

loadDefaultConfigFilesWithLegacyFallback now writes the conditional app-wide usesHostLayerBackground flag even when a surface-only full reload calls it. That path installs a temporary config on one surface and intentionally leaves GhosttyApp.config unchanged, so chrome can drop the host backdrop while other surfaces still zero Metal default-background alpha via macos-background-from-layer, leaving those terminals transparent.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0422832. Configure here.

source: "loadDefaultConfigFilesWithLegacyFallback"
)
// Let cmux own the window-level backdrop once, while Ghostty keeps
// rendering text, cell backgrounds, and background images. This avoids
// separate translucent fills for terminal and chrome surfaces.
loadInlineGhosttyConfig(
"macos-background-from-layer = true",
into: config,
prefix: "cmux-renderer-bg",
logLabel: "renderer background"
)
if usesLayerBackground {
loadInlineGhosttyConfig(
"macos-background-from-layer = true",
into: config,
prefix: "cmux-renderer-bg",
logLabel: "renderer background"
)
}
// Hide Ghostty's native AppKit proxy icon at the source instead of
// overriding NSWindow.representedURL on every cmux main window.
loadInlineGhosttyConfig(
Expand Down
85 changes: 85 additions & 0 deletions cmuxTests/GhosttyConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6313,3 +6313,88 @@ final class BrowserImportScopeTests: XCTestCase {
XCTAssertNil(scope)
}
}

/// `macos-background-from-layer` is injected only when the terminal
/// background actually composites against the host CALayer. A plain opaque
/// background must keep Ghostty's stock in-Metal fill so default-background
/// pixels are bit-identical to stock Ghostty (CoreAnimation compositing of
/// the host layer drifts by +/-1/255 per channel).
final class HostLayerBackgroundDecisionTests: XCTestCase {
func testOpaqueDefaultBackgroundUsesStockInMetalFill() {
XCTAssertFalse(
GhosttyApp.shouldUseHostLayerBackground(
backgroundOpacity: 1.0,
backgroundBlur: .disabled,
hasBackgroundImage: false
)
)
}

func testTranslucentBackgroundUsesHostLayer() {
XCTAssertTrue(
GhosttyApp.shouldUseHostLayerBackground(
backgroundOpacity: 0.95,
backgroundBlur: .disabled,
hasBackgroundImage: false
)
)
}

func testFullyTransparentBackgroundUsesHostLayer() {
XCTAssertTrue(
GhosttyApp.shouldUseHostLayerBackground(
backgroundOpacity: 0.0,
backgroundBlur: .disabled,
hasBackgroundImage: false
)
)
}

func testBlurRadiusUsesHostLayer() {
XCTAssertTrue(
GhosttyApp.shouldUseHostLayerBackground(
backgroundOpacity: 1.0,
backgroundBlur: .radius(20),
hasBackgroundImage: false
)
)
}

func testMacOSGlassBlurUsesHostLayer() {
XCTAssertTrue(
GhosttyApp.shouldUseHostLayerBackground(
backgroundOpacity: 1.0,
backgroundBlur: .macosGlassRegular,
hasBackgroundImage: false
)
)
XCTAssertTrue(
GhosttyApp.shouldUseHostLayerBackground(
backgroundOpacity: 1.0,
backgroundBlur: .macosGlassClear,
hasBackgroundImage: false
)
)
}

func testBackgroundImageUsesHostLayer() {
XCTAssertTrue(
GhosttyApp.shouldUseHostLayerBackground(
backgroundOpacity: 1.0,
backgroundBlur: .disabled,
hasBackgroundImage: true
)
)
}

func testOverrangeOpacityStaysOnStockFill() {
// Ghostty clamps background-opacity into 0...1; values >= 1 are opaque.
XCTAssertFalse(
GhosttyApp.shouldUseHostLayerBackground(
backgroundOpacity: 1.5,
backgroundBlur: .disabled,
hasBackgroundImage: false
)
)
}
}
Loading