From 0422832a79ff467bfa0bd95a50b5e3f991b6d061 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 7 Aug 2026 01:05:43 -0700 Subject: [PATCH] Inject macos-background-from-layer only for compositing backgrounds cmux unconditionally set macos-background-from-layer=true, routing default-background pixels through CoreAnimation host-layer compositing, which drifts +/-1/255 per channel vs Ghostty's stock in-Metal fill. The flag exists to unify translucent fills (#2378), so inject it only when the effective background actually composites against the host layer: background-opacity < 1, background-blur enabled, or a background image. The decision probes a finalized clone of the config (theme expansion at finalize may set background keys) and re-evaluates on every config reload, so live opacity/theme changes flip the mode. Plain opaque backgrounds now render bit-identical to stock desktop Ghostty. --- Sources/GhosttyTerminalView.swift | 100 ++++++++++++++++++++++++----- cmuxTests/GhosttyConfigTests.swift | 85 ++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 17 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4febc6877eb8..8eb35d04f76d 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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, @@ -1090,7 +1096,7 @@ class GhosttyApp { loadCmuxOwnedGhosttyKeybindOverrides(fallbackConfig) loadNoActiveDisplayVsyncFallbackIfNeeded(fallbackConfig) let fallbackRenderingModeChanged = setUsesHostLayerBackground( - true, + fallbackUsesLayerBackground, source: "initialize.fallbackConfig" ) ghostty_config_finalize(fallbackConfig) @@ -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 @@ -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, 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( diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 6ea47c64119e..941d3a85cab3 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -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 + ) + ) + } +}