From 1c90af3569d3591f735678987639885783e97e20 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:29:14 +0100 Subject: [PATCH 1/2] macOS: move `NSGlassEffectView` into `TerminalViewContainer` --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../QuickTerminalController.swift | 13 +- .../Terminal/TerminalController.swift | 4 +- .../Terminal/TerminalViewContainer.swift | 127 ++++++++++++++++++ .../Window Styles/TerminalWindow.swift | 62 +-------- macos/Sources/Ghostty/Ghostty.Config.swift | 12 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 + 7 files changed, 155 insertions(+), 67 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalViewContainer.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 91c2300cc..feda1bed0 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ Features/Terminal/TerminalRestorable.swift, Features/Terminal/TerminalTabColor.swift, Features/Terminal/TerminalView.swift, + Features/Terminal/TerminalViewContainer.swift, "Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift", "Features/Terminal/Window Styles/Terminal.xib", "Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib", diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 8a642034f..07c0c4c19 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -137,11 +137,11 @@ class QuickTerminalController: BaseTerminalController { } // Setup our content - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self - )) + ) // Clear out our frame at this point, the fixup from above is complete. if let qtWindow = window as? QuickTerminalWindow { @@ -609,7 +609,7 @@ class QuickTerminalController: BaseTerminalController { // If we have window transparency then set it transparent. Otherwise set it opaque. // Also check if the user has overridden transparency to be fully opaque. - if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 { + if !isBackgroundOpaque && (self.derivedConfig.backgroundOpacity < 1 || derivedConfig.backgroundBlur.isGlassStyle) { window.isOpaque = false // This is weird, but we don't use ".clear" because this creates a look that @@ -617,7 +617,9 @@ class QuickTerminalController: BaseTerminalController { // Terminal.app more easily. window.backgroundColor = .white.withAlphaComponent(0.001) - ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) + if !derivedConfig.backgroundBlur.isGlassStyle { + ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque()) + } } else { window.isOpaque = true window.backgroundColor = .windowBackgroundColor @@ -722,6 +724,7 @@ class QuickTerminalController: BaseTerminalController { let quickTerminalSpaceBehavior: QuickTerminalSpaceBehavior let quickTerminalSize: QuickTerminalSize let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur init() { self.quickTerminalScreen = .main @@ -730,6 +733,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = .move self.quickTerminalSize = QuickTerminalSize() self.backgroundOpacity = 1.0 + self.backgroundBlur = .disabled } init(_ config: Ghostty.Config) { @@ -739,6 +743,7 @@ class QuickTerminalController: BaseTerminalController { self.quickTerminalSpaceBehavior = config.quickTerminalSpaceBehavior self.quickTerminalSize = config.quickTerminalSize self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bccdd9c69..c5481851b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -936,11 +936,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } // Initialize our content view to the SwiftUI root - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = TerminalViewContainer( ghostty: self.ghostty, viewModel: self, delegate: self, - )) + ) // If we have a default size, we want to apply it. if let defaultSize { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift new file mode 100644 index 000000000..f4e2fc080 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -0,0 +1,127 @@ +import AppKit +import SwiftUI + +/// Use this container to achieve a glass effect at the window level. +/// Modifying `NSThemeFrame` can sometimes be unpredictable. +class TerminalViewContainer: NSView { + private let terminalView: NSView + + /// Glass effect view for liquid glass background when transparency is enabled + private 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 + )) + super.init(frame: .zero) + setup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + addSubview(terminalView) + terminalView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + terminalView.topAnchor.constraint(equalTo: topAnchor), + terminalView.leadingAnchor.constraint(equalTo: leadingAnchor), + terminalView.bottomAnchor.constraint(equalTo: bottomAnchor), + terminalView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyConfigDidChange(_:)), + name: .ghosttyConfigDidChange, + object: nil + ) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateGlassEffectIfNeeded() + } + + @objc private func ghosttyConfigDidChange(_ notification: Notification) { + guard let config = notification.userInfo?[ + Notification.Name.GhosttyConfigChangeKey + ] as? Ghostty.Config else { return } + let newValue = DerivedConfig(config: config) + guard newValue != derivedConfig else { return } + derivedConfig = newValue + DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) + } +} + +// MARK: Glass + +private extension TerminalViewContainer { +#if compiler(>=6.2) + @available(macOS 26.0, *) + func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { + if let existed = glassEffectView as? NSGlassEffectView { + return existed + } + guard let themeFrameView = window?.contentView?.superview else { + return nil + } + let effectView = NSGlassEffectView() + addSubview(effectView, positioned: .below, relativeTo: terminalView) + effectView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + effectView.topAnchor.constraint(equalTo: topAnchor, constant: -themeFrameView.safeAreaInsets.top), + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + glassEffectView = effectView + return effectView + } +#endif // compiler(>=6.2) + + func updateGlassEffectIfNeeded() { +#if compiler(>=6.2) + guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { + glassEffectView?.removeFromSuperview() + glassEffectView = nil + return + } + guard let effectView = addGlassEffectViewIfNeeded() else { + return + } + switch derivedConfig.backgroundBlur { + case .macosGlassRegular: + effectView.style = NSGlassEffectView.Style.regular + case .macosGlassClear: + effectView.style = NSGlassEffectView.Style.clear + default: + break + } + let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) + effectView.tintColor = backgroundColor + .withAlphaComponent(derivedConfig.backgroundOpacity) + if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat { + effectView.cornerRadius = cornerRadius + } +#endif // compiler(>=6.2) + } + + struct DerivedConfig: Equatable { + var backgroundOpacity: Double = 0 + var backgroundBlur: Ghostty.Config.BackgroundBlur + var backgroundColor: Color = .clear + + init(config: Ghostty.Config) { + self.backgroundBlur = config.backgroundBlur + self.backgroundOpacity = config.backgroundOpacity + self.backgroundColor = config.backgroundColor + } + } +} diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 4196df97f..9debd2cb3 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -474,7 +474,7 @@ class TerminalWindow: NSWindow { let forceOpaque = terminalController?.isBackgroundOpaque ?? false if !styleMask.contains(.fullScreen) && !forceOpaque && - surfaceConfig.backgroundOpacity < 1 + (surfaceConfig.backgroundOpacity < 1 || surfaceConfig.backgroundBlur.isGlassStyle) { isOpaque = false @@ -483,15 +483,8 @@ class TerminalWindow: NSWindow { // Terminal.app more easily. backgroundColor = .white.withAlphaComponent(0.001) - // Add liquid glass behind terminal content - if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle { - setupGlassLayer() - } else if let appDelegate = NSApp.delegate as? AppDelegate { - // If we had a prior glass layer we should remove it - if #available(macOS 26.0, *) { - removeGlassLayer() - } - + // We don't need to set blur when using glass + if !surfaceConfig.backgroundBlur.isGlassStyle, let appDelegate = NSApp.delegate as? AppDelegate { ghostty_set_window_background_blur( appDelegate.ghostty.app, Unmanaged.passUnretained(self).toOpaque()) @@ -499,11 +492,6 @@ class TerminalWindow: NSWindow { } else { isOpaque = true - // Remove liquid glass when not transparent - if #available(macOS 26.0, *) { - removeGlassLayer() - } - let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor) self.backgroundColor = backgroundColor.withAlphaComponent(1) } @@ -581,50 +569,6 @@ class TerminalWindow: NSWindow { NotificationCenter.default.removeObserver(observer) } } - -#if compiler(>=6.2) - // MARK: Glass - - @available(macOS 26.0, *) - private func setupGlassLayer() { - // Remove existing glass effect view - removeGlassLayer() - - // Get the window content view (parent of the NSHostingView) - guard let contentView else { return } - guard let windowContentView = contentView.superview else { return } - - // Create NSGlassEffectView for native glass effect - let effectView = NSGlassEffectView() - - // Map Ghostty config to NSGlassEffectView style - switch derivedConfig.backgroundBlur { - case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular - case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear - default: - // Should not reach here since we check for glass style before calling - // setupGlassLayer() - assertionFailure() - } - - effectView.cornerRadius = derivedConfig.windowCornerRadius - effectView.tintColor = preferredBackgroundColor - effectView.frame = windowContentView.bounds - effectView.autoresizingMask = [.width, .height] - - // Position BELOW the terminal content to act as background - windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView) - glassEffectView = effectView - } - - @available(macOS 26.0, *) - private func removeGlassLayer() { - glassEffectView?.removeFromSuperview() - glassEffectView = nil - } -#endif // compiler(>=6.2) // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 5aa79a149..b3a8700e9 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -658,9 +658,17 @@ extension Ghostty.Config { case 0: self = .disabled case -1: - self = .macosGlassRegular + if #available(macOS 26.0, *) { + self = .macosGlassRegular + } else { + self = .disabled + } case -2: - self = .macosGlassClear + if #available(macOS 26.0, *) { + self = .macosGlassClear + } else { + self = .disabled + } default: self = .radius(Int(value)) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 37cc9282e..77e1c43d4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1654,6 +1654,7 @@ extension Ghostty { struct DerivedConfig { let backgroundColor: Color let backgroundOpacity: Double + let backgroundBlur: Ghostty.Config.BackgroundBlur let macosWindowShadow: Bool let windowTitleFontFamily: String? let windowAppearance: NSAppearance? @@ -1662,6 +1663,7 @@ extension Ghostty { init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.backgroundOpacity = 1 + self.backgroundBlur = .disabled self.macosWindowShadow = true self.windowTitleFontFamily = nil self.windowAppearance = nil @@ -1671,6 +1673,7 @@ extension Ghostty { init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.backgroundOpacity = config.backgroundOpacity + self.backgroundBlur = config.backgroundBlur self.macosWindowShadow = config.macosWindowShadow self.windowTitleFontFamily = config.windowTitleFontFamily self.windowAppearance = .init(ghosttyConfig: config) From 6ab884d69f8854351dd140ed9a9fabf9c526488c Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Fri, 26 Dec 2025 13:35:25 +0100 Subject: [PATCH 2/2] macOS: fix intrinsicContentSize of `TerminalViewContainer` --- .../Sources/Features/Terminal/TerminalViewContainer.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index f4e2fc080..1765edec3 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -26,6 +26,13 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } + /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` + /// work in ``TerminalController/windowDidLoad()``, + /// we override this to provide the correct size. + override var intrinsicContentSize: NSSize { + terminalView.intrinsicContentSize + } + private func setup() { addSubview(terminalView) terminalView.translatesAutoresizingMaskIntoConstraints = false