diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 351756df4..e7e8a8200 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -159,8 +159,6 @@ 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,8 +172,6 @@ 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 } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 587510f09..340a0b489 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1239,12 +1239,6 @@ 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) { diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index dd0190c4c..b54d62f0f 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -6,7 +6,7 @@ import SwiftUI class TerminalViewContainer: NSView { private let terminalView: NSView - /// Combined glass effect and inactive tint overlay view + /// Background color applied with glass effect private(set) var glassEffectView: NSView? private var derivedConfig: DerivedConfig? @@ -96,14 +96,42 @@ extension BaseTerminalController { /// an inactive-window tint overlay. #if compiler(>=6.2) @available(macOS 26.0, *) -private class TerminalGlassView: NSView { - private let glassEffectView: NSGlassEffectView +private class TerminalGlassView: NSView, ObservableObject { + /// We use this to apply glass effect to background colors + /// + struct GlassBackground: View { + @ObservedObject var model: GlassViewModel + + var body: some View { + model.color + .glassEffect( + model.glass, + in: RoundedRectangle(cornerRadius: model.cornerRadius) + ) + } + } + + class GlassViewModel: ObservableObject { + @Published var isActive: Bool = false + @Published var backgroundColor: Color = .clear + @Published var backgroundOpacity: Double = 0 + @Published var cornerRadius: CGFloat = 0 + @Published var glass: Glass = .identity + + /// backgroundColor applied with backgroundOpacity + var color: Color { + backgroundColor.opacity(backgroundOpacity) + } + } + + private let glassEffectView: NSView private var topConstraint: NSLayoutConstraint! - private let tintOverlay: NSView + private let glassViewModel: GlassViewModel init(topOffset: CGFloat) { - self.glassEffectView = NSGlassEffectView() - self.tintOverlay = NSView() + let viewModel = GlassViewModel() + self.glassEffectView = NSHostingView(rootView: GlassBackground(model: viewModel)) + self.glassViewModel = viewModel super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false @@ -121,19 +149,6 @@ private class TerminalGlassView: NSView { 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) - - 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) @@ -141,19 +156,17 @@ private class TerminalGlassView: NSView { 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. + /// Configures the glass, tint color, corner radius. func configure( - style: NSGlassEffectView.Style, + glass: Glass, backgroundColor: NSColor, backgroundOpacity: Double, cornerRadius: CGFloat?, - isKeyWindow: Bool ) { - glassEffectView.style = style - glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity) - glassEffectView.cornerRadius = cornerRadius ?? 0 - updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) + glassViewModel.backgroundColor = Color(backgroundColor) + glassViewModel.backgroundOpacity = backgroundOpacity + glassViewModel.cornerRadius = cornerRadius ?? 0 + glassViewModel.glass = glass } /// Updates the top inset offset for both the glass effect and tint overlay. @@ -161,21 +174,6 @@ private class TerminalGlassView: NSView { func updateTopInset(_ offset: CGFloat) { topConstraint.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) @@ -215,11 +213,10 @@ extension TerminalViewContainer { } effectView.configure( - style: derivedConfig.style.official, + glass: derivedConfig.glass.official, backgroundColor: derivedConfig.backgroundColor, backgroundOpacity: derivedConfig.backgroundOpacity, cornerRadius: derivedConfig.cornerRadius, - isKeyWindow: window?.isKeyWindow ?? true ) #endif // compiler(>=6.2) } @@ -237,21 +234,8 @@ extension TerminalViewContainer { #endif // compiler(>=6.2) } - func updateGlassTintOverlay(isKeyWindow: Bool) { -#if compiler(>=6.2) - 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 { - let style: BackportNSGlassStyle + let glass: BackportGlass let backgroundColor: NSColor let backgroundOpacity: Double let cornerRadius: CGFloat? @@ -259,9 +243,9 @@ extension TerminalViewContainer { init?(config: Ghostty.Config, preferredBackgroundColor: NSColor?, cornerRadius: CGFloat?) { switch config.backgroundBlur { case .macosGlassRegular: - style = .regular + glass = .regular case .macosGlassClear: - style = .clear + glass = .clear default: return nil } diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 4f7123424..00edb7806 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -104,11 +104,11 @@ enum BackportPointerStyle { } } -enum BackportNSGlassStyle { +enum BackportGlass { case regular, clear @available(macOS 26, *) - var official: NSGlassEffectView.Style { + var official: Glass { switch self { case .regular: return .regular case .clear: return .clear diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift index ed2177325..63cf02ed4 100644 --- a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -24,14 +24,6 @@ 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), diff --git a/macos/Tests/Terminal/TerminalViewContainerTests.swift b/macos/Tests/Terminal/TerminalViewContainerTests.swift index e3df8483e..90373e9db 100644 --- a/macos/Tests/Terminal/TerminalViewContainerTests.swift +++ b/macos/Tests/Terminal/TerminalViewContainerTests.swift @@ -60,44 +60,4 @@ struct TerminalViewContainerTests { #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) }