diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index c65dca1d2..ef4aff5b9 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -6,9 +6,8 @@ import SwiftUI class TerminalViewContainer: 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: 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: 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: 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) } diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift index 63cf02ed4..ed2177325 100644 --- a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -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),