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
commit
6d2beed1b0
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue