Refactor glass effect into TerminalGlassView and add inactive window tint overlay

This commit is contained in:
miracles
2026-02-21 23:46:09 -08:00
parent 861a9cf537
commit 81c9c81ae3
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),