diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index de1ea903d..eb28942ee 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -137,11 +137,9 @@ class QuickTerminalController: BaseTerminalController { } // Setup our content - window.contentView = TerminalViewContainer( - ghostty: self.ghostty, - viewModel: self, - delegate: self - ) + window.contentView = TerminalViewContainer { + TerminalView(ghostty: ghostty, viewModel: self, delegate: self) + } // Clear out our frame at this point, the fixup from above is complete. if let qtWindow = window as? QuickTerminalWindow { @@ -161,6 +159,8 @@ class QuickTerminalController: BaseTerminalController { // applies if we can be seen. guard visible else { return } + terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: true) + // Re-hide the dock if we were hiding it before. hiddenDock?.hide() } @@ -174,6 +174,8 @@ class QuickTerminalController: BaseTerminalController { // ensures we don't run logic twice. guard visible else { return } + terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: false) + // We don't animate out if there is a modal sheet being shown currently. // This lets us show alerts without causing the window to disappear. guard window?.attachedSheet == nil else { return } @@ -706,6 +708,8 @@ class QuickTerminalController: BaseTerminalController { self.derivedConfig = DerivedConfig(config) syncAppearance() + + terminalViewContainer?.ghosttyConfigDidChange(config, preferredBackgroundColor: nil) } @objc private func onNewTab(notification: SwiftUI.Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c5c003459..c2c06e6e6 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -585,6 +585,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Call this last in case it uses any of the properties above. window.syncAppearance(surfaceConfig) + terminalViewContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: window.preferredBackgroundColor) } /// Adjusts the given frame for the configured window position. @@ -1024,11 +1025,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = TerminalViewContainer( - ghostty: self.ghostty, - viewModel: self, - delegate: self, - ) + window.contentView = TerminalViewContainer { + TerminalView(ghostty: ghostty, viewModel: self, delegate: self) + } // If we have a default size, we want to apply it. if let defaultSize { @@ -1148,6 +1147,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr super.windowDidBecomeKey(notification) self.relabelTabs() self.fixTabBar() + terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: true) + } + + override func windowDidResignKey(_ notification: Notification) { + super.windowDidResignKey(notification) + terminalViewContainer?.updateGlassTintOverlay(isKeyWindow: false) } override func windowDidMove(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index 81d7ede1f..92832efec 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -3,20 +3,27 @@ import SwiftUI /// Use this container to achieve a glass effect at the window level. /// Modifying `NSThemeFrame` can sometimes be unpredictable. -class TerminalViewContainer: NSView { +class TerminalViewContainer: NSView { private let terminalView: NSView /// Combined glass effect and inactive tint overlay view - private var glassEffectView: NSView? - private var derivedConfig: DerivedConfig + private(set) var glassEffectView: NSView? + private var derivedConfig: DerivedConfig? - init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { - self.derivedConfig = DerivedConfig(config: ghostty.config) - self.terminalView = NSHostingView(rootView: TerminalView( - ghostty: ghostty, - viewModel: viewModel, - delegate: delegate - )) + var windowThemeFrameView: NSView? { + window?.contentView?.superview + } + + var windowCornerRadius: CGFloat? { + guard let window, window.responds(to: Selector(("_cornerRadius"))) else { + return nil + } + + return window.value(forKey: "_cornerRadius") as? CGFloat + } + + init(@ViewBuilder rootView: () -> Root) { + self.terminalView = NSHostingView(rootView: rootView()) super.init(frame: .zero) setup() } @@ -26,10 +33,6 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } - deinit { - NotificationCenter.default.removeObserver(self) - } - /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` /// work in ``TerminalController/windowDidLoad()``, /// we override this to provide the correct size. @@ -46,27 +49,6 @@ class TerminalViewContainer: NSView { terminalView.bottomAnchor.constraint(equalTo: bottomAnchor), terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), ]) - - NotificationCenter.default.addObserver( - self, - selector: #selector(ghosttyConfigDidChange(_:)), - name: .ghosttyConfigDidChange, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidBecomeKey(_:)), - name: NSWindow.didBecomeKeyNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(windowDidResignKey(_:)), - name: NSWindow.didResignKeyNotification, - object: nil - ) } override func viewDidMoveToWindow() { @@ -84,29 +66,22 @@ class TerminalViewContainer: NSView { guard let config = notification.userInfo?[ Notification.Name.GhosttyConfigChangeKey ] as? Ghostty.Config else { return } - let newValue = DerivedConfig(config: config) + ghosttyConfigDidChange(config, preferredBackgroundColor: (window as? TerminalWindow)?.preferredBackgroundColor) + } + + func ghosttyConfigDidChange(_ config: Ghostty.Config, preferredBackgroundColor: NSColor?) { + let newValue = DerivedConfig(config: config, preferredBackgroundColor: preferredBackgroundColor, cornerRadius: windowCornerRadius) guard newValue != derivedConfig else { return } derivedConfig = newValue - - // Add some delay to wait TerminalWindow to update first to ensure - // that some of our properties are updated. This is a HACK to ensure - // light/dark themes work, and we will come up with a better way - // in the future. - DispatchQueue.main.asyncAfter( - deadline: .now() + 0.05, - execute: updateGlassEffectIfNeeded) + DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) } +} - @objc private func windowDidBecomeKey(_ notification: Notification) { - guard let window = notification.object as? NSWindow, - window == self.window else { return } - updateGlassTintOverlay(isKeyWindow: true) - } +// MARK: - BaseTerminalController + terminalViewContainer - @objc private func windowDidResignKey(_ notification: Notification) { - guard let window = notification.object as? NSWindow, - window == self.window else { return } - updateGlassTintOverlay(isKeyWindow: false) +extension BaseTerminalController { + var terminalViewContainer: TerminalViewContainer? { + window?.contentView as? TerminalViewContainer } } @@ -118,9 +93,8 @@ class TerminalViewContainer: NSView { @available(macOS 26.0, *) private class TerminalGlassView: NSView { private let glassEffectView: NSGlassEffectView - private var glassTopConstraint: NSLayoutConstraint? + private var topConstraint: NSLayoutConstraint! private let tintOverlay: NSView - private var tintTopConstraint: NSLayoutConstraint? init(topOffset: CGFloat) { self.glassEffectView = NSGlassEffectView() @@ -132,36 +106,29 @@ private class TerminalGlassView: NSView { // Glass effect view fills this view. glassEffectView.translatesAutoresizingMaskIntoConstraints = false addSubview(glassEffectView) - glassTopConstraint = glassEffectView.topAnchor.constraint( + topConstraint = glassEffectView.topAnchor.constraint( equalTo: topAnchor, constant: topOffset ) - if let glassTopConstraint { - NSLayoutConstraint.activate([ - glassTopConstraint, - glassEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), - glassEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), - glassEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - } + NSLayoutConstraint.activate([ + topConstraint, + glassEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + glassEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + glassEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) // Tint overlay sits above the glass effect. tintOverlay.translatesAutoresizingMaskIntoConstraints = false tintOverlay.wantsLayer = true tintOverlay.alphaValue = 0 addSubview(tintOverlay, positioned: .above, relativeTo: glassEffectView) - tintTopConstraint = tintOverlay.topAnchor.constraint( - equalTo: topAnchor, - constant: topOffset - ) - if let tintTopConstraint { - NSLayoutConstraint.activate([ - tintTopConstraint, - tintOverlay.leadingAnchor.constraint(equalTo: leadingAnchor), - tintOverlay.bottomAnchor.constraint(equalTo: bottomAnchor), - tintOverlay.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - } + + NSLayoutConstraint.activate([ + tintOverlay.topAnchor.constraint(equalTo: glassEffectView.topAnchor), + tintOverlay.leadingAnchor.constraint(equalTo: glassEffectView.leadingAnchor), + tintOverlay.bottomAnchor.constraint(equalTo: glassEffectView.bottomAnchor), + tintOverlay.trailingAnchor.constraint(equalTo: glassEffectView.trailingAnchor), + ]) } @available(*, unavailable) @@ -180,17 +147,14 @@ private class TerminalGlassView: NSView { ) { glassEffectView.style = style glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity) - if let cornerRadius { - glassEffectView.cornerRadius = cornerRadius - } + glassEffectView.cornerRadius = cornerRadius ?? 0 updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) } /// Updates the top inset offset for both the glass effect and tint overlay. /// Call this when the safe area insets change (e.g., during layout). func updateTopInset(_ offset: CGFloat) { - glassTopConstraint?.constant = offset - tintTopConstraint?.constant = offset + topConstraint.constant = offset } /// Updates the tint overlay visibility based on window key status. @@ -210,15 +174,15 @@ private class TerminalGlassView: NSView { } #endif // compiler(>=6.2) -private extension TerminalViewContainer { +extension TerminalViewContainer { #if compiler(>=6.2) @available(macOS 26.0, *) - func addGlassEffectViewIfNeeded() -> TerminalGlassView? { + private func addGlassEffectViewIfNeeded() -> TerminalGlassView? { if let existed = glassEffectView as? TerminalGlassView { updateGlassEffectTopInsetIfNeeded() return existed } - guard let themeFrameView = window?.contentView?.superview else { + guard let themeFrameView = windowThemeFrameView else { return nil } let effectView = TerminalGlassView(topOffset: -themeFrameView.safeAreaInsets.top) @@ -234,9 +198,9 @@ private extension TerminalViewContainer { } #endif // compiler(>=6.2) - func updateGlassEffectIfNeeded() { + private func updateGlassEffectIfNeeded() { #if compiler(>=6.2) - guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { + guard #available(macOS 26.0, *), let derivedConfig else { glassEffectView?.removeFromSuperview() glassEffectView = nil return @@ -245,61 +209,60 @@ private extension TerminalViewContainer { return } - let style: NSGlassEffectView.Style - switch derivedConfig.backgroundBlur { - case .macosGlassRegular: - style = NSGlassEffectView.Style.regular - case .macosGlassClear: - style = NSGlassEffectView.Style.clear - default: - style = NSGlassEffectView.Style.regular - } - let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) - - var cornerRadius: CGFloat? - if let window, window.responds(to: Selector(("_cornerRadius"))) { - cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat - } - effectView.configure( - style: style, - backgroundColor: backgroundColor, + style: derivedConfig.style.official, + backgroundColor: derivedConfig.backgroundColor, backgroundOpacity: derivedConfig.backgroundOpacity, - cornerRadius: cornerRadius, + cornerRadius: derivedConfig.cornerRadius, isKeyWindow: window?.isKeyWindow ?? true ) #endif // compiler(>=6.2) } - func updateGlassEffectTopInsetIfNeeded() { + private func updateGlassEffectTopInsetIfNeeded() { #if compiler(>=6.2) - guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { + guard + #available(macOS 26.0, *), + let effectView = glassEffectView as? TerminalGlassView, + let themeFrameView = windowThemeFrameView + else { return } - guard glassEffectView != nil else { return } - guard let themeFrameView = window?.contentView?.superview else { return } - (glassEffectView as? TerminalGlassView)?.updateTopInset(-themeFrameView.safeAreaInsets.top) + effectView.updateTopInset(-themeFrameView.safeAreaInsets.top) #endif // compiler(>=6.2) } func updateGlassTintOverlay(isKeyWindow: Bool) { #if compiler(>=6.2) - guard #available(macOS 26.0, *) else { return } - guard glassEffectView != nil else { return } - let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) - (glassEffectView as? TerminalGlassView)?.updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) + guard + #available(macOS 26.0, *), + let effectView = glassEffectView as? TerminalGlassView, + let derivedConfig + else { + return + } + effectView.updateKeyStatus(isKeyWindow, backgroundColor: derivedConfig.backgroundColor) #endif // compiler(>=6.2) } struct DerivedConfig: Equatable { - var backgroundOpacity: Double = 0 - var backgroundBlur: Ghostty.Config.BackgroundBlur - var backgroundColor: Color = .clear + let style: BackportNSGlassStyle + let backgroundColor: NSColor + let backgroundOpacity: Double + let cornerRadius: CGFloat? - init(config: Ghostty.Config) { - self.backgroundBlur = config.backgroundBlur + init?(config: Ghostty.Config, preferredBackgroundColor: NSColor?, cornerRadius: CGFloat?) { + switch config.backgroundBlur { + case .macosGlassRegular: + style = .regular + case .macosGlassClear: + style = .clear + default: + return nil + } + self.backgroundColor = preferredBackgroundColor ?? NSColor(config.backgroundColor) self.backgroundOpacity = config.backgroundOpacity - self.backgroundColor = config.backgroundColor + self.cornerRadius = cornerRadius } } } diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 28da6cce6..e2afb5b0c 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -117,3 +117,17 @@ enum BackportPointerStyle { } #endif } + +enum BackportNSGlassStyle { + case regular, clear + + #if canImport(AppKit) + @available(macOS 26, *) + var official: NSGlassEffectView.Style { + switch self { + case .regular: return .regular + case .clear: return .clear + } + } + #endif +} diff --git a/macos/Tests/Terminal/TerminalViewContainerTests.swift b/macos/Tests/Terminal/TerminalViewContainerTests.swift new file mode 100644 index 000000000..e3df8483e --- /dev/null +++ b/macos/Tests/Terminal/TerminalViewContainerTests.swift @@ -0,0 +1,103 @@ +// +// TerminalViewContainerTests.swift +// Ghostty +// +// Created by Lukas on 26.02.2026. +// + +import SwiftUI +import Testing +@testable import Ghostty + +class MockTerminalViewContainer: TerminalViewContainer { + var _windowCornerRadius: CGFloat? + override var windowThemeFrameView: NSView? { + NSView() + } + + override var windowCornerRadius: CGFloat? { + _windowCornerRadius + } +} + +class MockConfig: Ghostty.Config { + internal init(backgroundBlur: Ghostty.Config.BackgroundBlur, backgroundColor: Color, backgroundOpacity: Double) { + self._backgroundBlur = backgroundBlur + self._backgroundColor = backgroundColor + self._backgroundOpacity = backgroundOpacity + super.init(config: nil) + } + + var _backgroundBlur: Ghostty.Config.BackgroundBlur + var _backgroundColor: Color + var _backgroundOpacity: Double + + override var backgroundBlur: Ghostty.Config.BackgroundBlur { + _backgroundBlur + } + + override var backgroundColor: Color { + _backgroundColor + } + + override var backgroundOpacity: Double { + _backgroundOpacity + } +} + +struct TerminalViewContainerTests { + @Test func glassAvailability() async throws { + let view = await MockTerminalViewContainer { + EmptyView() + } + + let config = MockConfig(backgroundBlur: .macosGlassRegular, backgroundColor: .clear, backgroundOpacity: 1) + await view.ghosttyConfigDidChange(config, preferredBackgroundColor: nil) + try await Task.sleep(nanoseconds: UInt64(1e8)) // wait for the view to be setup if needed + if #available(macOS 26.0, *) { + #expect(view.glassEffectView != nil) + } else { + #expect(view.glassEffectView == nil) + } + } + +#if compiler(>=6.2) + @Test func configChangeUpdatesGlass() async throws { + guard #available(macOS 26.0, *) else { return } + let view = await MockTerminalViewContainer { + EmptyView() + } + let config1 = MockConfig(backgroundBlur: .macosGlassRegular, backgroundColor: .clear, backgroundOpacity: 1) + await view.ghosttyConfigDidChange(config1, preferredBackgroundColor: nil) + let glassEffectView = await view.descendants(withClassName: "NSGlassEffectView").first as? NSGlassEffectView + let effectView = try #require(glassEffectView) + try await Task.sleep(nanoseconds: UInt64(1e8)) // wait for the view to be setup if needed + #expect(effectView.tintColor?.hexString == NSColor.clear.hexString) + + // Test with same config but with different preferredBackgroundColor + await view.ghosttyConfigDidChange(config1, preferredBackgroundColor: .red) + #expect(effectView.tintColor?.hexString == NSColor.red.hexString) + + // MARK: - Corner Radius + + #expect(effectView.cornerRadius == 0) + await MainActor.run { view._windowCornerRadius = 10 } + + // This won't change, unless ghosttyConfigDidChange is called + #expect(effectView.cornerRadius == 0) + + await view.ghosttyConfigDidChange(config1, preferredBackgroundColor: .red) + #expect(effectView.cornerRadius == 10) + + // MARK: - Glass Style + + #expect(effectView.style == .regular) + + let config2 = MockConfig(backgroundBlur: .macosGlassClear, backgroundColor: .clear, backgroundOpacity: 1) + await view.ghosttyConfigDidChange(config2, preferredBackgroundColor: .red) + + #expect(effectView.style == .clear) + + } +#endif // compiler(>=6.2) +}