diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index e84ff27d618c..8d8b3b5233d3 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ 125BAEF2FE3B454547B964D1 /* ShortcutHintPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = A080D1F6CCCBFBED8D95DD1D /* ShortcutHintPill.swift */; }; 807E058A23061EFB70A1B7F8 /* WindowGlassEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B54144FA244A0B482D2903D /* WindowGlassEffect.swift */; }; A5001700A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001701A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift */; }; + A5001800A1B2C3D4E5F60718 /* WindowBackdropController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */; }; F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; A8CBA43C2DA5AB9E3A1E65A4 /* CommandPaletteSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */; }; 0D56BE882EAD4B67AC44F96D /* TerminalDirectoryOpenSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */; }; @@ -231,6 +232,7 @@ F20F85FC5900550685FA33AD /* StackAuth in Frameworks */ = {isa = PBXBuildFile; productRef = A8BD195031FC4B82B4354297 /* StackAuth */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; + F4200000A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */; }; F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */; }; F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; @@ -378,6 +380,7 @@ A080D1F6CCCBFBED8D95DD1D /* ShortcutHintPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHintPill.swift; sourceTree = ""; }; 0B54144FA244A0B482D2903D /* WindowGlassEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowGlassEffect.swift; sourceTree = ""; }; A5001701A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowAppearanceSnapshot.swift; sourceTree = ""; }; + A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Windowing/WindowBackdropController.swift; sourceTree = ""; }; D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPalette/CommandPaletteSearch.swift; sourceTree = ""; }; 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalDirectoryOpenSupport.swift; sourceTree = ""; }; @@ -546,6 +549,7 @@ F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = ""; }; + F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAppearanceSnapshotTests.swift; sourceTree = ""; }; F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScannerTests.swift; sourceTree = ""; }; F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; @@ -731,6 +735,7 @@ A080D1F6CCCBFBED8D95DD1D /* ShortcutHintPill.swift */, 0B54144FA244A0B482D2903D /* WindowGlassEffect.swift */, A5001701A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift */, + A5001801A1B2C3D4E5F60718 /* WindowBackdropController.swift */, D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */, 38A09EB2E92203B2E95923A7 /* CommandPaletteSearch.swift */, 6B8E2E03F4A64C61B729CF19 /* TerminalDirectoryOpenSupport.swift */, @@ -888,6 +893,7 @@ F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, + F4200001A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift */, F4100001A1B2C3D4E5F60718 /* PortScannerTests.swift */, F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, @@ -1187,6 +1193,7 @@ 125BAEF2FE3B454547B964D1 /* ShortcutHintPill.swift in Sources */, 807E058A23061EFB70A1B7F8 /* WindowGlassEffect.swift in Sources */, A5001700A1B2C3D4E5F60718 /* WindowAppearanceSnapshot.swift in Sources */, + A5001800A1B2C3D4E5F60718 /* WindowBackdropController.swift in Sources */, F57072635F25EBCA741E125D /* SidebarState.swift in Sources */, A8CBA43C2DA5AB9E3A1E65A4 /* CommandPaletteSearch.swift in Sources */, 0D56BE882EAD4B67AC44F96D /* TerminalDirectoryOpenSupport.swift in Sources */, @@ -1378,6 +1385,7 @@ F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, + F4200000A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift in Sources */, F4100000A1B2C3D4E5F60718 /* PortScannerTests.swift in Sources */, F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4d5403718641..1e3622fd0548 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -615,6 +615,18 @@ private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") let tmuxWorkspacePaneOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.tmuxWorkspacePane.overlay.container") +private func windowContentOverlayInstallationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { + if let glassTarget = WindowGlassEffect.portalInstallationTarget(for: window) { + return glassTarget + } + + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + return (themeFrame, contentView) +} + enum CommandPaletteOverlayPromotionPolicy { static func shouldPromote(previouslyVisible: Bool, isVisible: Bool) -> Bool { isVisible && !previouslyVisible @@ -725,7 +737,8 @@ private final class WindowCommandPaletteOverlayController: NSObject { private let containerView = CommandPaletteOverlayContainerView(frame: .zero) private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) private var installConstraints: [NSLayoutConstraint] = [] - private weak var installedThemeFrame: NSView? + private weak var installedContainerView: NSView? + private weak var installedReferenceView: NSView? private var focusLockTimer: DispatchSourceTimer? private var scheduledFocusWorkItem: DispatchWorkItem? private var isPaletteVisible = false @@ -760,31 +773,38 @@ private final class WindowCommandPaletteOverlayController: NSObject { @discardableResult private func ensureInstalled() -> Bool { guard let window, - let contentView = window.contentView, - let themeFrame = contentView.superview else { return false } + let target = windowContentOverlayInstallationTarget(for: window) else { return false } - if containerView.superview !== themeFrame { + if containerView.superview !== target.container || installedReferenceView !== target.reference { NSLayoutConstraint.deactivate(installConstraints) installConstraints.removeAll() containerView.removeFromSuperview() - themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + target.container.addSubview(containerView, positioned: .above, relativeTo: nil) installConstraints = [ - containerView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerView.topAnchor.constraint(equalTo: target.reference.topAnchor), + containerView.bottomAnchor.constraint(equalTo: target.reference.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: target.reference.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: target.reference.trailingAnchor), ] NSLayoutConstraint.activate(installConstraints) - installedThemeFrame = themeFrame + installedContainerView = target.container + installedReferenceView = target.reference +#if DEBUG + cmuxDebugLog( + "palette.overlay.install container=\(String(describing: type(of: target.container))) " + + "reference=\(String(describing: type(of: target.reference))) " + + "glass=\(WindowGlassEffect.portalInstallationTarget(for: window) != nil ? 1 : 0)" + ) +#endif } return true } private func promoteOverlayAboveSiblingsIfNeeded() { - guard let themeFrame = installedThemeFrame, - containerView.superview === themeFrame else { return } - themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + guard let container = installedContainerView, + containerView.superview === container else { return } + container.addSubview(containerView, positioned: .above, relativeTo: nil) } private func isPaletteResponder(_ responder: NSResponder?) -> Bool { @@ -1179,8 +1199,7 @@ private final class WindowCommandPaletteOverlayController: NSObject { func underlyingResponder(atWindowPoint windowPoint: NSPoint) -> NSResponder? { guard let window, - let contentView = window.contentView, - let themeFrame = contentView.superview else { + let target = windowContentOverlayInstallationTarget(for: window) else { return nil } @@ -1190,8 +1209,8 @@ private final class WindowCommandPaletteOverlayController: NSObject { containerView.capturesMouseEvents = previousCapturesMouseEvents } - let pointInTheme = themeFrame.convert(windowPoint, from: nil) - return themeFrame.hitTest(pointInTheme) + let pointInContainer = target.container.convert(windowPoint, from: nil) + return target.container.hitTest(pointInContainer) } } @@ -1212,6 +1231,7 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject { private let model = TmuxWorkspacePaneOverlayModel() private let hostingView: NSHostingView private var installConstraints: [NSLayoutConstraint] = [] + private weak var installedReferenceView: NSView? private var lastRenderState: TmuxWorkspacePaneOverlayRenderState? init(window: NSWindow) { @@ -1247,21 +1267,21 @@ private final class WindowTmuxWorkspacePaneOverlayController: NSObject { @discardableResult private func ensureInstalled() -> Bool { guard let window, - let contentView = window.contentView, - let themeFrame = contentView.superview else { return false } + let target = windowContentOverlayInstallationTarget(for: window) else { return false } - if containerView.superview !== themeFrame { + if containerView.superview !== target.container || installedReferenceView !== target.reference { NSLayoutConstraint.deactivate(installConstraints) installConstraints.removeAll() containerView.removeFromSuperview() - themeFrame.addSubview(containerView, positioned: .above, relativeTo: contentView) + target.container.addSubview(containerView, positioned: .above, relativeTo: target.reference) installConstraints = [ - containerView.topAnchor.constraint(equalTo: contentView.topAnchor), - containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerView.topAnchor.constraint(equalTo: target.reference.topAnchor), + containerView.bottomAnchor.constraint(equalTo: target.reference.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: target.reference.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: target.reference.trailingAnchor), ] NSLayoutConstraint.activate(installConstraints) + installedReferenceView = target.reference } return true @@ -1477,26 +1497,34 @@ private func configureFileDropOverlay(_ overlay: FileDropOverlayView, tabManager } } -private func attachFileDropOverlay(_ overlay: FileDropOverlayView, to contentView: NSView, in themeFrame: NSView) { +private func attachFileDropOverlay( + _ overlay: FileDropOverlayView, + to referenceView: NSView, + in containerView: NSView +) { overlay.translatesAutoresizingMaskIntoConstraints = false - themeFrame.addSubview(overlay, positioned: .above, relativeTo: contentView) + containerView.addSubview(overlay, positioned: .above, relativeTo: referenceView) NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: contentView.topAnchor), - overlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - overlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + overlay.topAnchor.constraint(equalTo: referenceView.topAnchor), + overlay.bottomAnchor.constraint(equalTo: referenceView.bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: referenceView.leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: referenceView.trailingAnchor) ]) } -private func fileDropOverlay(_ overlay: FileDropOverlayView, isAttachedTo contentView: NSView, in themeFrame: NSView) -> Bool { - guard overlay.superview === themeFrame else { return false } +private func fileDropOverlay( + _ overlay: FileDropOverlayView, + isAttachedTo referenceView: NSView, + in containerView: NSView +) -> Bool { + guard overlay.superview === containerView else { return false } let requiredAttributes: [NSLayoutConstraint.Attribute] = [.top, .bottom, .leading, .trailing] return requiredAttributes.allSatisfy { attribute in - themeFrame.constraints.contains { constraint in + containerView.constraints.contains { constraint in let firstView = constraint.firstItem as? NSView let secondView = constraint.secondItem as? NSView return firstView === overlay && - secondView === contentView && + secondView === referenceView && constraint.firstAttribute == attribute && constraint.secondAttribute == attribute } @@ -1505,30 +1533,29 @@ private func fileDropOverlay(_ overlay: FileDropOverlayView, isAttachedTo conten @discardableResult func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) -> Bool { - guard let contentView = window.contentView, - let themeFrame = contentView.superview else { return false } + guard let target = windowContentOverlayInstallationTarget(for: window) else { return false } let existingOverlay = (objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView) - ?? findFileDropOverlayView(in: themeFrame) + ?? findFileDropOverlayView(in: target.container) if let existingOverlay { configureFileDropOverlay(existingOverlay, tabManager: tabManager) objc_setAssociatedObject(window, &fileDropOverlayKey, existingOverlay, .OBJC_ASSOCIATION_RETAIN) - guard !fileDropOverlay(existingOverlay, isAttachedTo: contentView, in: themeFrame) else { + guard !fileDropOverlay(existingOverlay, isAttachedTo: target.reference, in: target.container) else { return true } existingOverlay.removeFromSuperview() - attachFileDropOverlay(existingOverlay, to: contentView, in: themeFrame) + attachFileDropOverlay(existingOverlay, to: target.reference, in: target.container) return true } - let overlay = FileDropOverlayView(frame: contentView.frame) + let overlay = FileDropOverlayView(frame: target.reference.frame) configureFileDropOverlay(overlay, tabManager: tabManager) // Publish the overlay before mutating the view tree so any re-entrant lookup resolves // the in-flight view instead of installing a second overlay during layout. objc_setAssociatedObject(window, &fileDropOverlayKey, overlay, .OBJC_ASSOCIATION_RETAIN) - attachFileDropOverlay(overlay, to: contentView, in: themeFrame) + attachFileDropOverlay(overlay, to: target.reference, in: target.container) return true } @@ -3765,47 +3792,21 @@ struct ContentView: View { // User settings decide whether window glass is active. The native Tahoe // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen // inside WindowGlassEffect.apply. - let currentThemeBackground = appearance.compositedTerminalBackgroundColor - let shouldApplyWindowGlass = appearance.windowGlassSettings.shouldApply() - let shouldForceTransparentHosting = appearance.shouldUseTransparentHosting() + let backdropPlan = appearance.backdropPlan() removeNativeTitlebarBackdrop(in: window) #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") } #endif - if shouldForceTransparentHosting { - window.isOpaque = false - // 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 = WindowGlassEffect.originalContentView(for: window) ?? window.contentView { - 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 shouldApplyWindowGlass { - // Apply liquid glass effect to the window with tint from settings - let didChangeGlassRoot = WindowGlassEffect.apply( - to: window, - tintColor: appearance.windowGlassSettings.tintColor, - style: appearance.windowGlassSettings.style - ) - if didChangeGlassRoot { - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) - BrowserWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) - } - } else { - if WindowGlassEffect.remove(from: window) { - TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) - BrowserWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) - } + let backdropResult = WindowBackdropController.apply(plan: backdropPlan, to: window) + if backdropResult.didChangeGlassRoot { + let tmuxOverlayState = tmuxWorkspacePaneWindowOverlayState(for: window) + tmuxWorkspacePaneWindowOverlayController(for: window, createIfNeeded: tmuxOverlayState != nil)?.update(state: tmuxOverlayState) + commandPaletteWindowOverlayController(for: window) + .update(isVisible: isCommandPalettePresented) { AnyView(commandPaletteOverlay) } + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) + BrowserWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window) } AppDelegate.shared?.attachUpdateAccessory(to: window) AppDelegate.shared?.applyWindowDecorations(to: window) @@ -3814,7 +3815,7 @@ struct ContentView: View { syncNativeTitlebarBackdrop( in: window, enabled: true, - usesGlassStyle: shouldApplyWindowGlass + usesGlassStyle: backdropResult.usesWindowGlass ) AppDelegate.shared?.registerMainWindow( window, @@ -4069,21 +4070,11 @@ 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 } let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.updateTint(to: window, color: tintColor) + WindowBackdropController.updateGlassTint(to: window, color: tintColor) } private func removeNativeTitlebarBackdrop(in window: NSWindow) { @@ -15567,10 +15558,11 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { } func updateNSView(_ nsView: NSView, context: Context) { + let clampedOpacity = max(0.0, min(1.0, opacity)) // Configure based on view type if nsView.className == "NSGlassEffectView" { // NSGlassEffectView configuration via private API - nsView.alphaValue = max(0.0, min(1.0, opacity)) + nsView.alphaValue = clampedOpacity nsView.layer?.cornerRadius = cornerRadius nsView.layer?.masksToBounds = cornerRadius > 0 @@ -15586,7 +15578,7 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { visualEffect.material = material visualEffect.blendingMode = blendingMode visualEffect.state = state - visualEffect.alphaValue = max(0.0, min(1.0, opacity)) + visualEffect.alphaValue = clampedOpacity visualEffect.layer?.cornerRadius = cornerRadius visualEffect.layer?.masksToBounds = cornerRadius > 0 visualEffect.needsDisplay = true diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ca64cb13696c..d43bb101832c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -183,7 +183,7 @@ func cmuxResetCompositorBackgroundBlur(on window: NSWindow) { ) } -private func cmuxTransparentWindowBaseColor() -> NSColor { +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) @@ -3903,28 +3903,13 @@ class GhosttyApp { private func applyBackgroundToKeyWindow() { guard let window = activeMainWindow() else { return } - if cmuxShouldUseClearWindowBackground( - for: defaultBackgroundOpacity, - usesGhosttyGlassStyle: defaultBackgroundBlur.isMacOSGlassStyle - ) { - window.backgroundColor = cmuxTransparentWindowBaseColor() - window.isOpaque = false - if defaultBackgroundBlur.isMacOSGlassStyle { - cmuxResetCompositorBackgroundBlur(on: window) - } else { - applyWindowBlurIfNeeded(window) - } - if backgroundLogEnabled { - logBackground("applied transparent window background opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) blur=\(defaultBackgroundBlur)") - } - } else { - let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity) - window.backgroundColor = color - window.isOpaque = color.alphaComponent >= 1.0 - cmuxResetCompositorBackgroundBlur(on: window) - if backgroundLogEnabled { - logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") - } + let snapshot = WindowAppearanceSnapshot.currentFromUserDefaults(app: self) + let plan = snapshot.backdropPlan() + _ = WindowBackdropController.apply(plan: plan, to: window) + if backgroundLogEnabled { + logBackground( + "applied window backdrop phase=\(plan.hostingPhase.rawValue) opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) blur=\(defaultBackgroundBlur)" + ) } } @@ -6077,26 +6062,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } applySurfaceBackground() let color = effectiveBackgroundColor() - let usesGhosttyGlassStyle = GhosttyApp.shared.defaultBackgroundBlur.isMacOSGlassStyle - let shouldUseClearWindowBackground = cmuxShouldUseClearWindowBackground( - for: color.alphaComponent, - usesGhosttyGlassStyle: usesGhosttyGlassStyle - ) - if shouldUseClearWindowBackground { - window.backgroundColor = cmuxTransparentWindowBaseColor() - window.isOpaque = false - if usesGhosttyGlassStyle { - cmuxResetCompositorBackgroundBlur(on: window) - } else { - GhosttyApp.shared.applyWindowBlurIfNeeded(window) - } - } else { - window.backgroundColor = color - window.isOpaque = color.alphaComponent >= 1.0 - cmuxResetCompositorBackgroundBlur(on: window) - } + let snapshot = WindowAppearanceSnapshot + .currentFromUserDefaults(app: GhosttyApp.shared) + .replacingTerminalBackgroundColor(backgroundColor ?? GhosttyApp.shared.defaultBackgroundColor) + let plan = snapshot.backdropPlan() + _ = WindowBackdropController.apply(plan: plan, to: window) if GhosttyApp.shared.backgroundLogEnabled { - let signature = "\(shouldUseClearWindowBackground ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent)):\(GhosttyApp.shared.defaultBackgroundBlur)" + let signature = "\(plan.hostingPhase.rawValue):\(color.hexString()):\(String(format: "%.3f", color.alphaComponent)):\(GhosttyApp.shared.defaultBackgroundBlur)" if signature != lastLoggedWindowBackgroundSignature { lastLoggedWindowBackgroundSignature = signature let hasOverride = backgroundColor != nil @@ -6104,7 +6076,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=\(shouldUseClearWindowBackground) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent)) blur=\(GhosttyApp.shared.defaultBackgroundBlur)" + "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) phase=\(plan.hostingPhase.rawValue) transparent=\(plan.usesTransparentWindow) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent)) blur=\(GhosttyApp.shared.defaultBackgroundBlur)" ) } } diff --git a/Sources/Windowing/WindowAppearanceSnapshot.swift b/Sources/Windowing/WindowAppearanceSnapshot.swift index 5419fcadc388..2b809560fc92 100644 --- a/Sources/Windowing/WindowAppearanceSnapshot.swift +++ b/Sources/Windowing/WindowAppearanceSnapshot.swift @@ -289,23 +289,6 @@ struct WindowAppearanceSnapshot { terminalBackgroundColor.withAlphaComponent(terminalBackgroundOpacity) } - var appKitWindowMutationID: String { - [ - terminalBackgroundColor.hexString(includeAlpha: true), - String(format: "%.4f", Double(terminalBackgroundOpacity)), - String(describing: terminalBackgroundBlur), - String(describing: terminalRenderingMode), - String(unifySurfaceBackdrops), - sidebarSettings.appKitMutationID, - windowGlassSettings.appKitMutationID, - ].joined(separator: "|") - } - - func shouldUseTransparentHosting(glassEffectAvailable: Bool = WindowGlassEffect.isAvailable) -> Bool { - windowGlassSettings.shouldApply(glassEffectAvailable: glassEffectAvailable) - || compositedTerminalBackgroundColor.alphaComponent < 0.999 - } - func policy(for role: WindowBackdropRole) -> WindowBackdropPolicy { switch role { case .windowRoot: @@ -320,7 +303,7 @@ struct WindowAppearanceSnapshot { } } - private func terminalBackdropPolicy() -> WindowBackdropPolicy { + func terminalBackdropPolicy() -> WindowBackdropPolicy { if terminalBackgroundBlur.isMacOSGlassStyle { return .clear } diff --git a/Sources/Windowing/WindowBackdropController.swift b/Sources/Windowing/WindowBackdropController.swift new file mode 100644 index 000000000000..d9c8630f16a3 --- /dev/null +++ b/Sources/Windowing/WindowBackdropController.swift @@ -0,0 +1,227 @@ +import AppKit +import SwiftUI + +enum WindowBackdropHostingPhase: String, Equatable { + case opaqueWindowFill + case transparentRootBackdrop + case windowGlass +} + +struct WindowBackdropGlassPlan { + let tintColor: NSColor + let style: WindowGlassEffect.Style +} + +struct WindowBackdropPlan { + let hostingPhase: WindowBackdropHostingPhase + let windowBackgroundColor: NSColor + let windowIsOpaque: Bool + let rootPolicy: WindowBackdropPolicy + let glass: WindowBackdropGlassPlan? + let shouldApplyGhosttyCompositorBlur: Bool + + var usesTransparentWindow: Bool { + hostingPhase != .opaqueWindowFill + } + + var usesWindowGlass: Bool { + hostingPhase == .windowGlass + } + + var appKitMutationID: String { + [ + hostingPhase.rawValue, + windowBackgroundColor.hexString(includeAlpha: true), + String(windowIsOpaque), + rootPolicy.identityComponent, + glass?.tintColor.hexString(includeAlpha: true) ?? "nil", + glass.map { String(describing: $0.style) } ?? "nil", + String(shouldApplyGhosttyCompositorBlur), + ].joined(separator: "|") + } +} + +struct WindowBackdropApplicationResult { + let didChangeGlassRoot: Bool + let usesWindowGlass: Bool +} + +enum WindowBackdropController { + static func apply( + snapshot: WindowAppearanceSnapshot, + to window: NSWindow, + glassEffectAvailable: Bool = WindowGlassEffect.isAvailable + ) -> WindowBackdropApplicationResult { + apply(plan: snapshot.backdropPlan(glassEffectAvailable: glassEffectAvailable), to: window) + } + + static func apply( + plan: WindowBackdropPlan, + to window: NSWindow + ) -> WindowBackdropApplicationResult { + var didChangeGlassRoot = false + + switch plan.hostingPhase { + case .opaqueWindowFill: + didChangeGlassRoot = WindowGlassEffect.remove(from: window) + window.backgroundColor = plan.windowBackgroundColor + window.isOpaque = plan.windowIsOpaque + cmuxResetCompositorBackgroundBlur(on: window) + case .transparentRootBackdrop: + didChangeGlassRoot = WindowGlassEffect.remove(from: window) + window.backgroundColor = plan.windowBackgroundColor + window.isOpaque = false + if plan.shouldApplyGhosttyCompositorBlur { + GhosttyApp.shared.applyWindowBlurIfNeeded(window) + } else { + cmuxResetCompositorBackgroundBlur(on: window) + } + case .windowGlass: + window.backgroundColor = plan.windowBackgroundColor + window.isOpaque = false + cmuxResetCompositorBackgroundBlur(on: window) + if let glass = plan.glass { + didChangeGlassRoot = WindowGlassEffect.apply( + to: window, + tintColor: glass.tintColor, + style: glass.style + ) + } else { + didChangeGlassRoot = WindowGlassEffect.remove(from: window) + } + } + + return WindowBackdropApplicationResult( + didChangeGlassRoot: didChangeGlassRoot, + usesWindowGlass: plan.usesWindowGlass + ) + } + + static func updateGlassTint(to window: NSWindow, color: NSColor?) { + WindowGlassEffect.updateTint(to: window, color: color) + } +} + +extension WindowAppearanceSnapshot { + static func currentFromUserDefaults( + defaults: UserDefaults = .standard, + app: GhosttyApp = .shared, + colorScheme: ColorScheme? = nil + ) -> Self { + current( + unifySurfaceBackdrops: defaults.object(forKey: "sidebarMatchTerminalBackground") as? Bool ?? false, + colorScheme: colorScheme ?? currentAppColorScheme(), + sidebarMaterial: defaults.string(forKey: "sidebarMaterial") ?? SidebarMaterialOption.sidebar.rawValue, + sidebarBlendMode: defaults.string(forKey: "sidebarBlendMode") ?? SidebarBlendModeOption.withinWindow.rawValue, + sidebarState: defaults.string(forKey: "sidebarState") ?? SidebarStateOption.followWindow.rawValue, + sidebarTintHex: defaults.string(forKey: "sidebarTintHex") ?? SidebarTintDefaults.hex, + sidebarTintHexLight: defaults.string(forKey: "sidebarTintHexLight"), + sidebarTintHexDark: defaults.string(forKey: "sidebarTintHexDark"), + sidebarTintOpacity: defaults.object(forKey: "sidebarTintOpacity") as? Double ?? SidebarTintDefaults.opacity, + sidebarCornerRadius: defaults.object(forKey: "sidebarCornerRadius") as? Double ?? 0.0, + sidebarBlurOpacity: defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? 1.0, + bgGlassEnabled: defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false, + bgGlassTintHex: defaults.string(forKey: "bgGlassTintHex") ?? "#000000", + bgGlassTintOpacity: defaults.object(forKey: "bgGlassTintOpacity") as? Double ?? 0.03, + app: app + ) + } + + func replacingTerminalBackgroundColor(_ color: NSColor) -> Self { + Self( + terminalBackgroundColor: color, + terminalBackgroundOpacity: terminalBackgroundOpacity, + terminalBackgroundBlur: terminalBackgroundBlur, + terminalRenderingMode: terminalRenderingMode, + unifySurfaceBackdrops: unifySurfaceBackdrops, + sidebarSettings: sidebarSettings, + windowGlassSettings: WindowGlassSettingsSnapshot( + sidebarBlendModeRawValue: windowGlassSettings.sidebarBlendModeRawValue, + isEnabled: windowGlassSettings.isEnabled, + tintHex: windowGlassSettings.tintHex, + tintOpacity: windowGlassSettings.tintOpacity, + terminalBackgroundBlur: terminalBackgroundBlur, + terminalGlassTintColor: color.withAlphaComponent(terminalBackgroundOpacity) + ) + ) + } + + var appKitWindowMutationID: String { + backdropPlan().appKitMutationID + } + + func shouldUseTransparentHosting(glassEffectAvailable: Bool = WindowGlassEffect.isAvailable) -> Bool { + backdropPlan(glassEffectAvailable: glassEffectAvailable).usesTransparentWindow + } + + func backdropPlan(glassEffectAvailable: Bool = WindowGlassEffect.isAvailable) -> WindowBackdropPlan { + let rootPolicy = terminalBackdropPolicy() + if windowGlassSettings.shouldApply(glassEffectAvailable: glassEffectAvailable) { + return WindowBackdropPlan( + hostingPhase: .windowGlass, + windowBackgroundColor: cmuxTransparentWindowBaseColor(), + windowIsOpaque: false, + rootPolicy: rootPolicy, + glass: WindowBackdropGlassPlan( + tintColor: windowGlassSettings.tintColor, + style: windowGlassSettings.style + ), + shouldApplyGhosttyCompositorBlur: false + ) + } + + if compositedTerminalBackgroundColor.alphaComponent < 0.999 { + return WindowBackdropPlan( + hostingPhase: .transparentRootBackdrop, + windowBackgroundColor: cmuxTransparentWindowBaseColor(), + windowIsOpaque: false, + rootPolicy: rootPolicy, + glass: nil, + shouldApplyGhosttyCompositorBlur: !terminalBackgroundBlur.isMacOSGlassStyle + ) + } + + return WindowBackdropPlan( + hostingPhase: .opaqueWindowFill, + windowBackgroundColor: compositedTerminalBackgroundColor, + windowIsOpaque: compositedTerminalBackgroundColor.alphaComponent >= 0.999, + rootPolicy: rootPolicy, + glass: nil, + shouldApplyGhosttyCompositorBlur: false + ) + } + + private static func currentAppColorScheme( + appearance: NSAppearance = NSApplication.shared.effectiveAppearance + ) -> ColorScheme { + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? .dark : .light + } +} + +private extension WindowBackdropPolicy { + var identityComponent: String { + switch self { + case let .ghosttyTerminalBackdrop(color, opacity, renderingMode): + return [ + "ghosttyTerminalBackdrop", + color.hexString(includeAlpha: true), + String(format: "%.4f", Double(opacity)), + String(describing: renderingMode), + ].joined(separator: ":") + case let .sidebarMaterial(materialPolicy): + return [ + "sidebarMaterial", + String(describing: materialPolicy.material), + String(describing: materialPolicy.blendingMode), + String(describing: materialPolicy.state), + String(format: "%.4f", materialPolicy.opacity), + materialPolicy.tintColor.hexString(includeAlpha: true), + String(format: "%.4f", Double(materialPolicy.cornerRadius)), + String(materialPolicy.preferLiquidGlass), + String(materialPolicy.usesWindowLevelGlass), + ].joined(separator: ":") + case .clear: + return "clear" + } + } +} diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 91af68c1fd55..dbd52c852f52 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4112,7 +4112,7 @@ private struct BackgroundDebugView: View { }() guard let window else { return } let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.updateTint(to: window, color: tintColor) + WindowBackdropController.updateGlassTint(to: window, color: tintColor) } private var tintColorBinding: Binding { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index c8c0bb23654b..1b203d0cc805 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -782,119 +782,6 @@ final class WorkspaceChromeThemeTests: XCTestCase { } } -final class WindowAppearanceSnapshotTests: XCTestCase { - func testUnifiedSurfaceBackdropsUseSingleWindowRootBackdrop() { - let snapshot = makeSnapshot(unifySurfaceBackdrops: true) - - assertTerminalBackdrop(snapshot.policy(for: .windowRoot)) - assertClearBackdrop(snapshot.policy(for: .terminalCanvas)) - assertClearBackdrop(snapshot.policy(for: .bonsplitChrome)) - assertClearBackdrop(snapshot.policy(for: .titlebar)) - assertClearBackdrop(snapshot.policy(for: .browserSurface)) - assertClearBackdrop(snapshot.policy(for: .leftSidebar)) - assertClearBackdrop(snapshot.policy(for: .rightSidebar)) - } - - func testSeparateSurfaceBackdropsKeepRootBackdropAndSidebarMaterialsSeparate() { - let snapshot = makeSnapshot(unifySurfaceBackdrops: false) - - assertTerminalBackdrop(snapshot.policy(for: .windowRoot)) - assertClearBackdrop(snapshot.policy(for: .terminalCanvas)) - assertClearBackdrop(snapshot.policy(for: .bonsplitChrome)) - assertClearBackdrop(snapshot.policy(for: .titlebar)) - assertClearBackdrop(snapshot.policy(for: .browserSurface)) - - guard case let .sidebarMaterial(leftPolicy) = snapshot.policy(for: .leftSidebar) else { - XCTFail("left sidebar should keep its own material policy") - return - } - XCTAssertEqual(leftPolicy.material, .sidebar) - XCTAssertEqual(leftPolicy.blendingMode, .withinWindow) - - guard case let .sidebarMaterial(rightPolicy) = snapshot.policy(for: .rightSidebar) else { - XCTFail("right sidebar should keep its own material policy") - return - } - XCTAssertEqual(rightPolicy.material, .sidebar) - XCTAssertEqual(rightPolicy.blendingMode, .withinWindow) - } - - func testMacOSGlassClearForcesTransparentHostingAndClearGlassStyle() { - let snapshot = makeSnapshot( - unifySurfaceBackdrops: true, - backgroundOpacity: 1.0, - backgroundBlur: .macosGlassClear - ) - - XCTAssertTrue(snapshot.shouldUseTransparentHosting()) - XCTAssertTrue(snapshot.windowGlassSettings.shouldApply()) - XCTAssertEqual(snapshot.windowGlassSettings.style, .clear) - XCTAssertEqual(snapshot.windowGlassSettings.tintColor.hexString(includeAlpha: true), "#272822FF") - assertClearBackdrop(snapshot.policy(for: .windowRoot)) - } - - private func makeSnapshot( - unifySurfaceBackdrops: Bool, - backgroundOpacity: CGFloat = 0.6, - backgroundBlur: GhosttyBackgroundBlur = .disabled - ) -> WindowAppearanceSnapshot { - WindowAppearanceSnapshot( - terminalBackgroundColor: NSColor(hex: "#272822") ?? .black, - terminalBackgroundOpacity: backgroundOpacity, - terminalBackgroundBlur: backgroundBlur, - terminalRenderingMode: .windowHostBackdrop, - unifySurfaceBackdrops: unifySurfaceBackdrops, - sidebarSettings: SidebarBackdropSettingsSnapshot( - materialRawValue: SidebarMaterialOption.sidebar.rawValue, - blendModeRawValue: SidebarBlendModeOption.withinWindow.rawValue, - stateRawValue: SidebarStateOption.followWindow.rawValue, - tintHex: "#000000", - tintHexLight: nil, - tintHexDark: nil, - tintOpacity: 0.18, - cornerRadius: 0, - blurOpacity: 1, - colorScheme: .dark - ), - windowGlassSettings: WindowGlassSettingsSnapshot( - sidebarBlendModeRawValue: SidebarBlendModeOption.withinWindow.rawValue, - isEnabled: false, - tintHex: "#000000", - tintOpacity: 0.03, - terminalBackgroundBlur: backgroundBlur, - terminalGlassTintColor: (NSColor(hex: "#272822") ?? .black) - .withAlphaComponent(backgroundOpacity) - ) - ) - } - - private func assertTerminalBackdrop( - _ policy: WindowBackdropPolicy, - file: StaticString = #filePath, - line: UInt = #line - ) { - guard case let .ghosttyTerminalBackdrop(color, opacity, renderingMode) = policy else { - XCTFail("expected terminal backdrop", file: file, line: line) - return - } - XCTAssertEqual(color.hexString(), "#272822", file: file, line: line) - XCTAssertEqual(opacity, 0.6, accuracy: 0.0001, file: file, line: line) - XCTAssertEqual(renderingMode, .windowHostBackdrop, file: file, line: line) - } - - private func assertClearBackdrop( - _ policy: WindowBackdropPolicy, - file: StaticString = #filePath, - line: UInt = #line - ) { - guard case .clear = policy else { - XCTFail("expected clear backdrop", file: file, line: line) - return - } - } - -} - final class WindowChromeSeparatorColorTests: XCTestCase { func testDarkChromeSeparatorMatchesBonsplitDerivation() { guard let backgroundColor = NSColor(hex: "#272822") else { diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift new file mode 100644 index 000000000000..0396108065ec --- /dev/null +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -0,0 +1,191 @@ +import XCTest +import AppKit +import SwiftUI + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class WindowAppearanceSnapshotTests: XCTestCase { + func testUnifiedSurfaceBackdropsUseSingleWindowRootBackdrop() { + let snapshot = makeSnapshot(unifySurfaceBackdrops: true) + + assertTerminalBackdrop(snapshot.policy(for: .windowRoot)) + assertClearBackdrop(snapshot.policy(for: .terminalCanvas)) + assertClearBackdrop(snapshot.policy(for: .bonsplitChrome)) + assertClearBackdrop(snapshot.policy(for: .titlebar)) + assertClearBackdrop(snapshot.policy(for: .browserSurface)) + assertClearBackdrop(snapshot.policy(for: .leftSidebar)) + assertClearBackdrop(snapshot.policy(for: .rightSidebar)) + } + + func testSeparateSurfaceBackdropsKeepRootBackdropAndSidebarMaterialsSeparate() { + let snapshot = makeSnapshot(unifySurfaceBackdrops: false) + + assertTerminalBackdrop(snapshot.policy(for: .windowRoot)) + assertClearBackdrop(snapshot.policy(for: .terminalCanvas)) + assertClearBackdrop(snapshot.policy(for: .bonsplitChrome)) + assertClearBackdrop(snapshot.policy(for: .titlebar)) + assertClearBackdrop(snapshot.policy(for: .browserSurface)) + + guard case let .sidebarMaterial(leftPolicy) = snapshot.policy(for: .leftSidebar) else { + XCTFail("left sidebar should keep its own material policy") + return + } + XCTAssertEqual(leftPolicy.material, .sidebar) + XCTAssertEqual(leftPolicy.blendingMode, .withinWindow) + + guard case let .sidebarMaterial(rightPolicy) = snapshot.policy(for: .rightSidebar) else { + XCTFail("right sidebar should keep its own material policy") + return + } + XCTAssertEqual(rightPolicy.material, .sidebar) + XCTAssertEqual(rightPolicy.blendingMode, .withinWindow) + } + + func testMacOSGlassClearForcesTransparentHostingAndClearGlassStyle() { + let snapshot = makeSnapshot( + unifySurfaceBackdrops: true, + backgroundOpacity: 1.0, + backgroundBlur: .macosGlassClear + ) + + XCTAssertTrue(snapshot.shouldUseTransparentHosting(glassEffectAvailable: true)) + XCTAssertTrue(snapshot.windowGlassSettings.shouldApply(glassEffectAvailable: true)) + XCTAssertEqual(snapshot.windowGlassSettings.style, .clear) + XCTAssertEqual(snapshot.windowGlassSettings.tintColor.hexString(includeAlpha: true), "#272822FF") + assertClearBackdrop(snapshot.policy(for: .windowRoot)) + XCTAssertEqual(snapshot.backdropPlan(glassEffectAvailable: true).hostingPhase, .windowGlass) + } + + func testTranslucentTerminalWithSidebarTintKeepsRootBackdropOwner() { + let snapshot = makeSnapshot( + unifySurfaceBackdrops: false, + backgroundOpacity: 0.9, + sidebarTintHexDark: "#FF0000", + sidebarTintOpacity: 0.4 + ) + let plan = snapshot.backdropPlan(glassEffectAvailable: false) + + XCTAssertEqual(plan.hostingPhase, .transparentRootBackdrop) + XCTAssertTrue(plan.usesTransparentWindow) + XCTAssertFalse(plan.usesWindowGlass) + assertTerminalBackdrop(plan.rootPolicy, expectedOpacity: 0.9) + + guard case let .sidebarMaterial(sidebarPolicy) = snapshot.policy(for: .leftSidebar) else { + XCTFail("left sidebar should keep its own tint material") + return + } + XCTAssertEqual(sidebarPolicy.tintColor.hexString(includeAlpha: true), "#FF000066") + } + + func testSidebarTintChangesDoNotDriveWindowBackdropPlanIdentity() { + let red = makeSnapshot( + unifySurfaceBackdrops: false, + backgroundOpacity: 0.9, + sidebarTintHexDark: "#FF0000", + sidebarTintOpacity: 0.4 + ) + let blue = makeSnapshot( + unifySurfaceBackdrops: false, + backgroundOpacity: 0.9, + sidebarTintHexDark: "#0000FF", + sidebarTintOpacity: 0.8 + ) + + XCTAssertEqual( + red.backdropPlan(glassEffectAvailable: false).appKitMutationID, + blue.backdropPlan(glassEffectAvailable: false).appKitMutationID + ) + } + + func testOpaqueTerminalUsesOpaqueWindowFill() { + let snapshot = makeSnapshot(unifySurfaceBackdrops: false, backgroundOpacity: 1.0) + let plan = snapshot.backdropPlan(glassEffectAvailable: false) + + XCTAssertEqual(plan.hostingPhase, .opaqueWindowFill) + XCTAssertFalse(plan.usesTransparentWindow) + XCTAssertEqual(plan.windowBackgroundColor.hexString(includeAlpha: true), "#272822FF") + } + + func testDebugBackgroundGlassUsesWindowGlassPhase() { + let snapshot = makeSnapshot( + unifySurfaceBackdrops: false, + backgroundOpacity: 1.0, + sidebarBlendMode: SidebarBlendModeOption.behindWindow.rawValue, + bgGlassEnabled: true + ) + let plan = snapshot.backdropPlan(glassEffectAvailable: true) + + XCTAssertEqual(plan.hostingPhase, .windowGlass) + XCTAssertTrue(plan.usesTransparentWindow) + XCTAssertTrue(plan.usesWindowGlass) + } + + private func makeSnapshot( + unifySurfaceBackdrops: Bool, + backgroundOpacity: CGFloat = 0.6, + backgroundBlur: GhosttyBackgroundBlur = .disabled, + sidebarBlendMode: String = SidebarBlendModeOption.withinWindow.rawValue, + sidebarTintHexDark: String? = nil, + sidebarTintOpacity: Double = 0.18, + bgGlassEnabled: Bool = false + ) -> WindowAppearanceSnapshot { + WindowAppearanceSnapshot( + terminalBackgroundColor: NSColor(hex: "#272822") ?? .black, + terminalBackgroundOpacity: backgroundOpacity, + terminalBackgroundBlur: backgroundBlur, + terminalRenderingMode: .windowHostBackdrop, + unifySurfaceBackdrops: unifySurfaceBackdrops, + sidebarSettings: SidebarBackdropSettingsSnapshot( + materialRawValue: SidebarMaterialOption.sidebar.rawValue, + blendModeRawValue: sidebarBlendMode, + stateRawValue: SidebarStateOption.followWindow.rawValue, + tintHex: "#000000", + tintHexLight: nil, + tintHexDark: sidebarTintHexDark, + tintOpacity: sidebarTintOpacity, + cornerRadius: 0, + blurOpacity: 1, + colorScheme: .dark + ), + windowGlassSettings: WindowGlassSettingsSnapshot( + sidebarBlendModeRawValue: sidebarBlendMode, + isEnabled: bgGlassEnabled, + tintHex: "#000000", + tintOpacity: 0.03, + terminalBackgroundBlur: backgroundBlur, + terminalGlassTintColor: (NSColor(hex: "#272822") ?? .black) + .withAlphaComponent(backgroundOpacity) + ) + ) + } + + private func assertTerminalBackdrop( + _ policy: WindowBackdropPolicy, + expectedOpacity: CGFloat = 0.6, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case let .ghosttyTerminalBackdrop(color, opacity, renderingMode) = policy else { + XCTFail("expected terminal backdrop", file: file, line: line) + return + } + XCTAssertEqual(color.hexString(), "#272822", file: file, line: line) + XCTAssertEqual(opacity, expectedOpacity, accuracy: 0.0001, file: file, line: line) + XCTAssertEqual(renderingMode, .windowHostBackdrop, file: file, line: line) + } + + private func assertClearBackdrop( + _ policy: WindowBackdropPolicy, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .clear = policy else { + XCTFail("expected clear backdrop", file: file, line: line) + return + } + } +}