mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 08:01:45 +00:00
macOS: rework for #10943
Keep the background color as it is and apply glass effect on top of it
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user