mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
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:
@@ -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