macOS: refine liquid glass styles (#13928)

Rework for https://github.com/ghostty-org/ghostty/pull/10943;

Keep the background color as it is and apply glass effect on top of it.
Remove the color modification and keep our implementation relatively
simple and more straightforward.

This fixes https://github.com/ghostty-org/ghostty/issues/13914 and also
ofc the linked issue in previous pr.

### Regular


https://github.com/user-attachments/assets/b44e0af1-0a03-4773-8bfa-03ba48804235


https://github.com/user-attachments/assets/83ab0ef5-6791-412d-b260-e7c55e7f890c

### Clear


https://github.com/user-attachments/assets/8970b97c-7866-45dd-a122-dc9c8bdc7ec0


https://github.com/user-attachments/assets/4105dc8f-f531-4a77-b193-b914c46d3075
This commit is contained in:
Mitchell Hashimoto
2026-08-20 06:57:16 -07:00
committed by GitHub
6 changed files with 46 additions and 120 deletions

View File

@@ -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 }

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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)
}