mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
Refactor glass effect into TerminalGlassView and add inactive window tint overlay
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user