macOS: Add inactive window tint overlay for liquid glass (#10943)

**Summary:**
- Add tint overlay to dim terminal windows when inactive, fixes
https://github.com/ghostty-org/ghostty/discussions/10040
- Refactor the liquid glass effect into a dedicated `TerminalGlassView`
class

Note: The tint overlay color and opacity values may not be ideal —
feedback is welcome.

**AI Disclosure:** I used Claude Code to read the macos repo and
understand the liquid glass implementation. Implemented basic tint
overlay mainly by hand. Refactor the code and review changes with Claude
Code.
This commit is contained in:
Mitchell Hashimoto
2026-02-23 09:01:25 -08:00
committed by GitHub
2 changed files with 173 additions and 27 deletions

View File

@@ -6,9 +6,8 @@ import SwiftUI
class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
private let terminalView: NSView
/// Glass effect view for liquid glass background when transparency is enabled
/// Combined glass effect and inactive tint overlay view
private var glassEffectView: NSView?
private var glassTopConstraint: NSLayoutConstraint?
private var derivedConfig: DerivedConfig
init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) {
@@ -27,6 +26,10 @@ 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.
@@ -50,6 +53,20 @@ class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
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() {
@@ -72,36 +89,139 @@ class TerminalViewContainer<ViewModel: TerminalViewModel>: NSView {
derivedConfig = newValue
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)
}
@objc private func windowDidResignKey(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
window == self.window else { return }
updateGlassTintOverlay(isKeyWindow: false)
}
}
// MARK: Glass
/// An `NSView` that contains a liquid glass background effect and
/// an inactive-window tint overlay.
#if compiler(>=6.2)
@available(macOS 26.0, *)
private class TerminalGlassView: NSView {
private let glassEffectView: NSGlassEffectView
private var glassTopConstraint: NSLayoutConstraint?
private let tintOverlay: NSView
private var tintTopConstraint: NSLayoutConstraint?
init(topOffset: CGFloat) {
self.glassEffectView = NSGlassEffectView()
self.tintOverlay = NSView()
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
// Glass effect view fills this view.
glassEffectView.translatesAutoresizingMaskIntoConstraints = false
addSubview(glassEffectView)
glassTopConstraint = 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),
])
}
// 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),
])
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Configures the glass effect style, tint color, corner radius, and
/// updates the inactive tint overlay based on window key status.
func configure(
style: NSGlassEffectView.Style,
backgroundColor: NSColor,
backgroundOpacity: Double,
cornerRadius: CGFloat?,
isKeyWindow: Bool
) {
glassEffectView.style = style
glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity)
if let cornerRadius {
glassEffectView.cornerRadius = cornerRadius
}
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
}
/// Updates the tint overlay visibility based on window key status.
func updateKeyStatus(_ isKeyWindow: Bool, backgroundColor: NSColor) {
let tint = tintProperties(for: backgroundColor)
tintOverlay.layer?.backgroundColor = tint.color.cgColor
tintOverlay.alphaValue = isKeyWindow ? 0 : tint.opacity
}
/// Computes a saturation-boosted tint color and opacity for the inactive overlay.
private func tintProperties(for color: NSColor) -> (color: NSColor, opacity: CGFloat) {
let isLight = color.isLightColor
let vibrant = color.adjustingSaturation(by: 1.2)
let overlayOpacity: CGFloat = isLight ? 0.35 : 0.85
return (vibrant, overlayOpacity)
}
}
#endif // compiler(>=6.2)
private extension TerminalViewContainer {
#if compiler(>=6.2)
@available(macOS 26.0, *)
func addGlassEffectViewIfNeeded() -> NSGlassEffectView? {
if let existed = glassEffectView as? NSGlassEffectView {
func addGlassEffectViewIfNeeded() -> TerminalGlassView? {
if let existed = glassEffectView as? TerminalGlassView {
updateGlassEffectTopInsetIfNeeded()
return existed
}
guard let themeFrameView = window?.contentView?.superview else {
return nil
}
let effectView = NSGlassEffectView()
let effectView = TerminalGlassView(topOffset: -themeFrameView.safeAreaInsets.top)
addSubview(effectView, positioned: .below, relativeTo: terminalView)
effectView.translatesAutoresizingMaskIntoConstraints = false
glassTopConstraint = effectView.topAnchor.constraint(
equalTo: topAnchor,
constant: -themeFrameView.safeAreaInsets.top
)
if let glassTopConstraint {
NSLayoutConstraint.activate([
glassTopConstraint,
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
NSLayoutConstraint.activate([
effectView.topAnchor.constraint(equalTo: topAnchor),
effectView.leadingAnchor.constraint(equalTo: leadingAnchor),
effectView.bottomAnchor.constraint(equalTo: bottomAnchor),
effectView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
glassEffectView = effectView
return effectView
}
@@ -112,26 +232,35 @@ private extension TerminalViewContainer {
guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else {
glassEffectView?.removeFromSuperview()
glassEffectView = nil
glassTopConstraint = nil
return
}
guard let effectView = addGlassEffectViewIfNeeded() else {
return
}
let style: NSGlassEffectView.Style
switch derivedConfig.backgroundBlur {
case .macosGlassRegular:
effectView.style = NSGlassEffectView.Style.regular
style = NSGlassEffectView.Style.regular
case .macosGlassClear:
effectView.style = NSGlassEffectView.Style.clear
style = NSGlassEffectView.Style.clear
default:
break
style = NSGlassEffectView.Style.regular
}
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
var cornerRadius: CGFloat?
if let window, window.responds(to: Selector(("_cornerRadius"))) {
cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat
}
effectView.configure(
style: style,
backgroundColor: backgroundColor,
backgroundOpacity: derivedConfig.backgroundOpacity,
cornerRadius: cornerRadius,
isKeyWindow: window?.isKeyWindow ?? true
)
#endif // compiler(>=6.2)
}
@@ -142,7 +271,16 @@ private extension TerminalViewContainer {
}
guard glassEffectView != nil else { return }
guard let themeFrameView = window?.contentView?.superview else { return }
glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top
(glassEffectView as? TerminalGlassView)?.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)
#endif // compiler(>=6.2)
}

View File

@@ -24,6 +24,14 @@ extension NSColor {
appleColorList?.allKeys.map { $0.lowercased() } ?? []
}
/// Returns a new color with its saturation multiplied by the given factor, clamped to [0, 1].
func adjustingSaturation(by factor: CGFloat) -> NSColor {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
let hsbColor = self.usingColorSpace(.sRGB) ?? self
hsbColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
return NSColor(hue: h, saturation: min(max(s * factor, 0), 1), brightness: b, alpha: a)
}
/// Calculates the perceptual distance to another color in RGB space.
func distance(to other: NSColor) -> Double {
guard let a = self.usingColorSpace(.sRGB),