feat: add liquid glass background effect support (#8801)

## Description

This PR implements the basic functionality for "Liquid Glass" style
background support (#8155). I used OpenCode (Claude 4) in some capacity
to write this as I'm not super familiar with AppKit / SwiftUI. A good
chunk of this I still needed to write by hand since Claude doesn't
understand the Glass APIs, but I'm not 100% if the implementation here
makes the best decisions since the practices in Ghostty config and
separation of the AppKit code and SwiftUI seemed inconsistent to me.

Some of the combinations of options obviously create entirely unreadable
terminals, but I've found that regular glass and transparent with
opacity to be fairly readable. We *don't* enable this feature by default
since it would of course break existing users setups.

## Open Questions

- [x] How to determine the correct cornerRadius? For now this is
eyeballed, I can't see any macOS public API or clearly documented
constants.
- [x] Should boolean options be exposed for reasonable defaults?
- [x] Should the option need to be namespaced to macos-\*?

## Screenshots

0% Opacity, Regular
<img width="917" height="683" alt="image"
src="https://github.com/user-attachments/assets/ccb96ba7-5df2-4284-8526-e07bbb62e3e5"
/>
50% Opacity, Transparent
<img width="880" height="680" alt="image"
src="https://github.com/user-attachments/assets/5bdf12f9-3209-4aa9-8a4f-9a6eb4f95894"
/>
0% Opacity, Transparent
<img width="860" height="681" alt="image"
src="https://github.com/user-attachments/assets/1b33d400-4d8b-479a-94d7-47b844743e52"
/>
pull/8815/head
Mitchell Hashimoto 2025-12-15 11:10:03 -08:00 committed by GitHub
commit 6d2beed1b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 243 additions and 24 deletions

View File

@ -44,6 +44,9 @@ class TerminalWindow: NSWindow {
true
}
/// Glass effect view for liquid glass background when transparency is enabled
private var glassEffectView: NSView?
/// Gets the terminal controller from the window controller.
var terminalController: TerminalController? {
windowController as? TerminalController
@ -476,7 +479,15 @@ class TerminalWindow: NSWindow {
// Terminal.app more easily.
backgroundColor = .white.withAlphaComponent(0.001)
if let appDelegate = NSApp.delegate as? AppDelegate {
// Add liquid glass behind terminal content
if #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle {
setupGlassLayer()
} else if let appDelegate = NSApp.delegate as? AppDelegate {
// If we had a prior glass layer we should remove it
if #available(macOS 26.0, *) {
removeGlassLayer()
}
ghostty_set_window_background_blur(
appDelegate.ghostty.app,
Unmanaged.passUnretained(self).toOpaque())
@ -484,6 +495,11 @@ class TerminalWindow: NSWindow {
} else {
isOpaque = true
// Remove liquid glass when not transparent
if #available(macOS 26.0, *) {
removeGlassLayer()
}
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
self.backgroundColor = backgroundColor.withAlphaComponent(1)
}
@ -562,19 +578,69 @@ class TerminalWindow: NSWindow {
}
}
#if compiler(>=6.2)
// MARK: Glass
@available(macOS 26.0, *)
private func setupGlassLayer() {
// Remove existing glass effect view
removeGlassLayer()
// Get the window content view (parent of the NSHostingView)
guard let contentView else { return }
guard let windowContentView = contentView.superview else { return }
// Create NSGlassEffectView for native glass effect
let effectView = NSGlassEffectView()
// Map Ghostty config to NSGlassEffectView style
switch derivedConfig.backgroundBlur {
case .macosGlassRegular:
effectView.style = NSGlassEffectView.Style.regular
case .macosGlassClear:
effectView.style = NSGlassEffectView.Style.clear
default:
// Should not reach here since we check for glass style before calling
// setupGlassLayer()
assertionFailure()
}
effectView.cornerRadius = derivedConfig.windowCornerRadius
effectView.tintColor = preferredBackgroundColor
effectView.frame = windowContentView.bounds
effectView.autoresizingMask = [.width, .height]
// Position BELOW the terminal content to act as background
windowContentView.addSubview(effectView, positioned: .below, relativeTo: contentView)
glassEffectView = effectView
}
@available(macOS 26.0, *)
private func removeGlassLayer() {
glassEffectView?.removeFromSuperview()
glassEffectView = nil
}
#endif // compiler(>=6.2)
// MARK: Config
struct DerivedConfig {
let title: String?
let backgroundBlur: Ghostty.Config.BackgroundBlur
let backgroundColor: NSColor
let backgroundOpacity: Double
let macosWindowButtons: Ghostty.MacOSWindowButtons
let macosTitlebarStyle: String
let windowCornerRadius: CGFloat
init() {
self.title = nil
self.backgroundColor = NSColor.windowBackgroundColor
self.backgroundOpacity = 1
self.macosWindowButtons = .visible
self.backgroundBlur = .disabled
self.macosTitlebarStyle = "transparent"
self.windowCornerRadius = 16
}
init(_ config: Ghostty.Config) {
@ -582,6 +648,18 @@ class TerminalWindow: NSWindow {
self.backgroundColor = NSColor(config.backgroundColor)
self.backgroundOpacity = config.backgroundOpacity
self.macosWindowButtons = config.macosWindowButtons
self.backgroundBlur = config.backgroundBlur
self.macosTitlebarStyle = config.macosTitlebarStyle
// Set corner radius based on macos-titlebar-style
// Native, transparent, and hidden styles use 16pt radius
// Tabs style uses 20pt radius
switch config.macosTitlebarStyle {
case "tabs":
self.windowCornerRadius = 20
default:
self.windowCornerRadius = 16
}
}
}
}

View File

@ -88,7 +88,16 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// color of the titlebar in native fullscreen view.
if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") {
titlebarView.wantsLayer = true
titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor
// For glass background styles, use a transparent titlebar to let the glass effect show through
// Only apply this for transparent and tabs titlebar styles
let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle
let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" ||
derivedConfig.macosTitlebarStyle == "tabs"
titlebarView.layer?.backgroundColor = (isGlassStyle && isTransparentTitlebar)
? NSColor.clear.cgColor
: preferredBackgroundColor?.cgColor
}
// In all cases, we have to hide the background view since this has multiple subviews

View File

@ -402,12 +402,12 @@ extension Ghostty {
return v;
}
var backgroundBlurRadius: Int {
guard let config = self.config else { return 1 }
var v: Int = 0
var backgroundBlur: BackgroundBlur {
guard let config = self.config else { return .disabled }
var v: Int16 = 0
let key = "background-blur"
_ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8)))
return v;
return BackgroundBlur(fromCValue: v)
}
var unfocusedSplitOpacity: Double {
@ -626,6 +626,60 @@ extension Ghostty.Config {
case download
}
/// Background blur configuration that maps from the C API values.
/// Positive values represent blur radius, special negative values
/// represent macOS-specific glass effects.
enum BackgroundBlur: Equatable {
case disabled
case radius(Int)
case macosGlassRegular
case macosGlassClear
init(fromCValue value: Int16) {
switch value {
case 0:
self = .disabled
case -1:
self = .macosGlassRegular
case -2:
self = .macosGlassClear
default:
self = .radius(Int(value))
}
}
var isEnabled: Bool {
switch self {
case .disabled:
return false
default:
return true
}
}
/// Returns true if this is a macOS glass style (regular or clear).
var isGlassStyle: Bool {
switch self {
case .macosGlassRegular, .macosGlassClear:
return true
default:
return false
}
}
/// Returns the blur radius if applicable, nil for glass effects.
var radius: Int? {
switch self {
case .disabled:
return nil
case .radius(let r):
return r
case .macosGlassRegular, .macosGlassClear:
return nil
}
}
}
struct BellFeatures: OptionSet {
let rawValue: CUnsignedInt
@ -635,7 +689,7 @@ extension Ghostty.Config {
static let title = BellFeatures(rawValue: 1 << 3)
static let border = BellFeatures(rawValue: 1 << 4)
}
enum MacDockDropBehavior: String {
case new_tab = "new-tab"
case new_window = "new-window"

View File

@ -56,7 +56,7 @@ extension Ghostty {
case app
case zig_run
}
/// Returns the mechanism that launched the app. This is based on an env var so
/// its up to the env var being set in the correct circumstance.
static var launchSource: LaunchSource {
@ -65,7 +65,7 @@ extension Ghostty {
// source. If its unset we assume we're in a CLI environment.
return .cli
}
// If the env var is set but its unknown then we default back to the app.
return LaunchSource(rawValue: envValue) ?? .app
}
@ -76,17 +76,17 @@ extension Ghostty {
extension Ghostty {
class AllocatedString {
private let cString: ghostty_string_s
init(_ c: ghostty_string_s) {
self.cString = c
}
var string: String {
guard let ptr = cString.ptr else { return "" }
let data = Data(bytes: ptr, count: Int(cString.len))
return String(data: data, encoding: .utf8) ?? ""
}
deinit {
ghostty_string_free(cString)
}

View File

@ -927,6 +927,15 @@ palette: Palette = .{},
/// reasonable for a good looking blur. Higher blur intensities may
/// cause strange rendering and performance issues.
///
/// On macOS 26.0 and later, there are additional special values that
/// can be set to use the native macOS glass effects:
///
/// * `macos-glass-regular` - Standard glass effect with some opacity
/// * `macos-glass-clear` - Highly transparent glass effect
///
/// If the macOS values are set, then this implies `background-blur = true`
/// on non-macOS platforms.
///
/// Supported on macOS and on some Linux desktop environments, including:
///
/// * KDE Plasma (Wayland and X11)
@ -8315,6 +8324,8 @@ pub const AutoUpdate = enum {
pub const BackgroundBlur = union(enum) {
false,
true,
@"macos-glass-regular",
@"macos-glass-clear",
radius: u8,
pub fn parseCLI(self: *BackgroundBlur, input: ?[]const u8) !void {
@ -8324,14 +8335,35 @@ pub const BackgroundBlur = union(enum) {
return;
};
self.* = if (cli.args.parseBool(input_)) |b|
if (b) .true else .false
else |_|
.{ .radius = std.fmt.parseInt(
u8,
input_,
0,
) catch return error.InvalidValue };
// Try to parse normal bools
if (cli.args.parseBool(input_)) |b| {
self.* = if (b) .true else .false;
return;
} else |_| {}
// Try to parse enums
if (std.meta.stringToEnum(
std.meta.Tag(BackgroundBlur),
input_,
)) |v| switch (v) {
inline else => |tag| tag: {
// We can only parse void types
const info = std.meta.fieldInfo(BackgroundBlur, tag);
if (info.type != void) break :tag;
self.* = @unionInit(
BackgroundBlur,
@tagName(tag),
{},
);
return;
},
};
self.* = .{ .radius = std.fmt.parseInt(
u8,
input_,
0,
) catch return error.InvalidValue };
}
pub fn enabled(self: BackgroundBlur) bool {
@ -8339,14 +8371,24 @@ pub const BackgroundBlur = union(enum) {
.false => false,
.true => true,
.radius => |v| v > 0,
// We treat these as true because they both imply some blur!
// This has the effect of making the standard blur happen on
// Linux.
.@"macos-glass-regular", .@"macos-glass-clear" => true,
};
}
pub fn cval(self: BackgroundBlur) u8 {
pub fn cval(self: BackgroundBlur) i16 {
return switch (self) {
.false => 0,
.true => 20,
.radius => |v| v,
// I hate sentinel values like this but this is only for
// our macOS application currently. We can switch to a proper
// tagged union if we ever need to.
.@"macos-glass-regular" => -1,
.@"macos-glass-clear" => -2,
};
}
@ -8358,6 +8400,8 @@ pub const BackgroundBlur = union(enum) {
.false => try formatter.formatEntry(bool, false),
.true => try formatter.formatEntry(bool, true),
.radius => |v| try formatter.formatEntry(u8, v),
.@"macos-glass-regular" => try formatter.formatEntry([]const u8, "macos-glass-regular"),
.@"macos-glass-clear" => try formatter.formatEntry([]const u8, "macos-glass-clear"),
}
}
@ -8377,6 +8421,12 @@ pub const BackgroundBlur = union(enum) {
try v.parseCLI("42");
try testing.expectEqual(42, v.radius);
try v.parseCLI("macos-glass-regular");
try testing.expectEqual(.@"macos-glass-regular", v);
try v.parseCLI("macos-glass-clear");
try testing.expectEqual(.@"macos-glass-clear", v);
try testing.expectError(error.InvalidValue, v.parseCLI(""));
try testing.expectError(error.InvalidValue, v.parseCLI("aaaa"));
try testing.expectError(error.InvalidValue, v.parseCLI("420"));

View File

@ -193,20 +193,32 @@ test "c_get: background-blur" {
{
c.@"background-blur" = .false;
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(0, cval);
}
{
c.@"background-blur" = .true;
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(20, cval);
}
{
c.@"background-blur" = .{ .radius = 42 };
var cval: u8 = undefined;
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(42, cval);
}
{
c.@"background-blur" = .@"macos-glass-regular";
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(-1, cval);
}
{
c.@"background-blur" = .@"macos-glass-clear";
var cval: i16 = undefined;
try testing.expect(get(&c, .@"background-blur", @ptrCast(&cval)));
try testing.expectEqual(-2, cval);
}
}

View File

@ -561,6 +561,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
vsync: bool,
colorspace: configpkg.Config.WindowColorspace,
blending: configpkg.Config.AlphaBlending,
background_blur: configpkg.Config.BackgroundBlur,
pub fn init(
alloc_gpa: Allocator,
@ -633,6 +634,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.vsync = config.@"window-vsync",
.colorspace = config.@"window-colorspace",
.blending = config.@"alpha-blending",
.background_blur = config.@"background-blur",
.arena = arena,
};
}
@ -716,6 +718,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
options.config.background.r,
options.config.background.g,
options.config.background.b,
// Note that if we're on macOS with glass effects
// we'll disable background opacity but we handle
// that in updateFrame.
@intFromFloat(@round(options.config.background_opacity * 255.0)),
},
.bools = .{
@ -1295,6 +1300,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
self.terminal_state.colors.background.b,
@intFromFloat(@round(self.config.background_opacity * 255.0)),
};
// If we're on macOS and have glass styles, we remove
// the background opacity because the glass effect handles
// it.
if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) {
.@"macos-glass-regular",
.@"macos-glass-clear",
=> self.uniforms.bg_color[3] = 0,
else => {},
};
}
}