macOS: refine window tint for liquid glass (#11018)

Depends on #11030

- Update constraints of `TerminalGlassView`
- Use `TerminalViewContainer.DerivedConfig` to map styling properties
- Add TerminalViewContainerTests
- Instead of using delay, now the view updates are explicitly called by
window controllers
This commit is contained in:
Lukas
2026-02-27 19:49:12 +01:00
committed by GitHub
parent b0657036a0
commit df53f75ad1
5 changed files with 219 additions and 130 deletions

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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<ViewModel: TerminalViewModel>: 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<Root: View>(@ViewBuilder rootView: () -> Root) {
self.terminalView = NSHostingView(rootView: rootView())
super.init(frame: .zero)
setup()
}
@@ -26,10 +33,6 @@ class TerminalViewContainer<ViewModel: TerminalViewModel>: 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<ViewModel: TerminalViewModel>: 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<ViewModel: TerminalViewModel>: 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<ViewModel: TerminalViewModel>: 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
}
}
}

View File

@@ -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
}

View File

@@ -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)
}