diff --git a/include/ghostty.h b/include/ghostty.h index 43d478ab1..2cb915ddd 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -276,6 +276,7 @@ void *ghostty_app_userdata(ghostty_app_t); ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*); void ghostty_surface_free(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t); +bool ghostty_surface_transparent(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); void ghostty_surface_set_focus(ghostty_surface_t, bool); @@ -290,6 +291,10 @@ void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); void ghostty_surface_split_focus(ghostty_surface_t, ghostty_split_focus_direction_e); +// APIs I'd like to get rid of eventually but are still needed for now. +// Don't use these unless you know what you're doing. +void ghostty_set_window_background_blur(ghostty_surface_t, void *); + #ifdef __cplusplus } #endif diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 48467e264..25c74b3dc 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -197,6 +197,20 @@ extension Ghostty { ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) } + override func viewDidMoveToWindow() { + guard let window = self.window else { return } + guard let surface = self.surface else { return } + guard ghostty_surface_transparent(surface) else { return } + + // Set the window transparency settings + window.isOpaque = false + window.hasShadow = false + window.backgroundColor = .clear + + // If we have a blur, set the blur + ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque()) + } + override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index f5a8f899a..1b5c3c4b8 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -470,6 +470,11 @@ pub const CAPI = struct { return surface.app; } + /// Returns ture if the surface has transparency set. + export fn ghostty_surface_transparent(surface: *Surface) bool { + return surface.app.config.@"background-opacity" < 1.0; + } + /// Tell the surface that it needs to schedule a render export fn ghostty_surface_refresh(surface: *Surface) void { surface.refresh(); @@ -565,4 +570,34 @@ pub const CAPI = struct { export fn ghostty_surface_split_focus(ptr: *Surface, direction: input.SplitFocusDirection) void { ptr.gotoSplit(direction); } + + /// Sets the window background blur on macOS to the desired value. + /// I do this in Zig as an extern function because I don't know how to + /// call these functions in Swift. + /// + /// This uses an undocumented, non-public API because this is what + /// every terminal appears to use, including Terminal.app. + export fn ghostty_set_window_background_blur( + ptr: *Surface, + window: *anyopaque, + ) void { + const config = ptr.app.config; + + // Do nothing if we don't have background transparency enabled + if (config.@"background-opacity" >= 1.0) return; + + // Do nothing if our blur value is zero + if (config.@"background-blur-radius" == 0) return; + + const nswindow = objc.Object.fromId(window); + _ = CGSSetWindowBackgroundBlurRadius( + CGSDefaultConnectionForThread(), + nswindow.msgSend(usize, objc.sel("windowNumber"), .{}), + @intCast(config.@"background-blur-radius"), + ); + } + + /// See ghostty_set_window_background_blur + extern "c" fn CGSSetWindowBackgroundBlurRadius(*anyopaque, usize, c_int) i32; + extern "c" fn CGSDefaultConnectionForThread() *anyopaque; }; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 47bf9c422..4863e5511 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -278,7 +278,7 @@ pub const Surface = struct { "ghostty", null, null, - Renderer.glfwWindowHints(), + Renderer.glfwWindowHints(&app.config), ) orelse return glfw.mustGetErrorCode(); errdefer win.destroy(); diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 69a9634b4..c3a609c4a 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -286,6 +286,9 @@ const Window = struct { /// The notebook (tab grouping) for this window. notebook: *c.GtkNotebook, + /// The background CSS for the window (if any). + css_window_background: ?[]u8 = null, + pub fn init(self: *Window, app: *App) !void { // Set up our own state self.* = .{ @@ -301,6 +304,27 @@ const Window = struct { self.window = gtk_window; c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_default_size(gtk_window, 200, 200); + + // Apply background opacity if we have it + if (app.config.@"background-opacity" < 1) { + var css = try std.fmt.allocPrint( + app.core_app.alloc, + ".window-transparent {{ background-color: rgba(0, 0, 0, {d}); }}", + .{app.config.@"background-opacity"}, + ); + self.css_window_background = css; + + const display = c.gtk_widget_get_display(@ptrCast(window)); + const provider = c.gtk_css_provider_new(); + c.gtk_css_provider_load_from_data(provider, css.ptr, @intCast(css.len)); + c.gtk_style_context_add_provider_for_display( + display, + @ptrCast(provider), + c.GTK_STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + c.gtk_widget_add_css_class(@ptrCast(window), "window-transparent"); + } + c.gtk_widget_show(window); _ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(>kCloseRequest), self, null, G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, G_CONNECT_DEFAULT); @@ -326,9 +350,7 @@ const Window = struct { } pub fn deinit(self: *Window) void { - // Notify our app we're gone. - // TODO - _ = self; + if (self.css_window_background) |ptr| self.app.core_app.alloc.free(ptr); } /// Add a new tab to this window. diff --git a/src/cli_args.zig b/src/cli_args.zig index 70a918369..8afb35e65 100644 --- a/src/cli_args.zig +++ b/src/cli_args.zig @@ -144,6 +144,11 @@ fn parseIntoField( 0, ), + f64 => try std.fmt.parseFloat( + f64, + value orelse return error.ValueRequired, + ), + else => unreachable, }; @@ -298,6 +303,20 @@ test "parseIntoField: unsigned numbers" { try testing.expectEqual(@as(u8, 1), data.u8); } +test "parseIntoField: floats" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var data: struct { + f64: f64, + } = undefined; + + try parseIntoField(@TypeOf(data), alloc, &data, "f64", "1"); + try testing.expectEqual(@as(f64, 1.0), data.f64); +} + test "parseIntoField: optional field" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); diff --git a/src/config.zig b/src/config.zig index c1c9f1f72..cd92cbbf4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -63,6 +63,24 @@ pub const Config = struct { /// The color of the cursor. If this is not set, a default will be chosen. @"cursor-color": ?Color = null, + /// The opacity level (opposite of transparency) of the background. + /// A value of 1 is fully opaque and a value of 0 is fully transparent. + /// A value less than 0 or greater than 1 will be clamped to the nearest + /// valid value. + /// + /// Changing this value at runtime (and reloading config) will only + /// affect new windows, tabs, and splits. + @"background-opacity": f64 = 1.0, + + /// A positive value enables blurring of the background when + /// background-opacity is less than 1. The value is the blur radius to + /// apply. A value of 20 is reasonable for a good looking blur. + /// Higher values will cause strange rendering issues as well as + /// performance issues. + /// + /// This is only supported on macOS. + @"background-blur-radius": u8 = 0, + /// The command to run, usually a shell. If this is not an absolute path, /// it'll be looked up in the PATH. If this is not set, a default will /// be looked up from your system. The rules for the default lookup are: @@ -754,6 +772,7 @@ pub const Config = struct { switch (@typeInfo(T)) { inline .Bool, .Int, + .Float, => return src, .Optional => |info| return try cloneValue( @@ -879,6 +898,7 @@ fn equal(comptime T: type, old: T, new: T) bool { inline .Bool, .Int, + .Float, .Enum, => return old == new, diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 7d75219bf..89a200be0 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -132,6 +132,7 @@ pub const DerivedConfig = struct { font_thicken: bool, cursor_color: ?terminal.color.RGB, background: terminal.color.RGB, + background_opacity: f64, foreground: terminal.color.RGB, selection_background: ?terminal.color.RGB, selection_foreground: ?terminal.color.RGB, @@ -143,6 +144,7 @@ pub const DerivedConfig = struct { _ = alloc_gpa; return .{ + .background_opacity = @max(0, @min(1, config.@"background-opacity")), .font_thicken = config.@"font-thicken", .cursor_color = if (config.@"cursor-color") |col| @@ -171,11 +173,10 @@ pub const DerivedConfig = struct { }; /// Returns the hints that we want for this -pub fn glfwWindowHints() glfw.Window.Hints { +pub fn glfwWindowHints(config: *const configpkg.Config) glfw.Window.Hints { return .{ .client_api = .no_api, - // .cocoa_graphics_switching = builtin.os.tag == .macos, - // .cocoa_retina_framebuffer = true, + .transparent_framebuffer = config.@"background-opacity" < 1, }; } @@ -196,7 +197,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { const CAMetalLayer = objc.Class.getClass("CAMetalLayer").?; const swapchain = CAMetalLayer.msgSend(objc.Object, objc.sel("layer"), .{}); swapchain.setProperty("device", device.value); - swapchain.setProperty("opaque", true); + swapchain.setProperty("opaque", options.config.background_opacity >= 1); // disable v-sync swapchain.setProperty("displaySyncEnabled", false); @@ -628,7 +629,7 @@ pub fn render( .red = @as(f32, @floatFromInt(critical.bg.r)) / 255, .green = @as(f32, @floatFromInt(critical.bg.g)) / 255, .blue = @as(f32, @floatFromInt(critical.bg.b)) / 255, - .alpha = 1.0, + .alpha = self.config.background_opacity, }); } @@ -943,30 +944,25 @@ pub fn updateCell( fg: terminal.color.RGB, }; + // True if this cell is selected + // TODO(perf): we can check in advance if selection is in + // our viewport at all and not run this on every point. + const selected: bool = if (selection) |sel| selected: { + const screen_point = (terminal.point.Viewport{ + .x = x, + .y = y, + }).toScreen(screen); + + break :selected sel.contains(screen_point); + } else false; + // The colors for the cell. const colors: BgFg = colors: { - // If we have a selection, then we need to check if this - // cell is selected. - // TODO(perf): we can check in advance if selection is in - // our viewport at all and not run this on every point. - var selection_res: ?BgFg = sel_colors: { - if (selection) |sel| { - const screen_point = (terminal.point.Viewport{ - .x = x, - .y = y, - }).toScreen(screen); - - // If we are selected, we our colors are just inverted fg/bg - if (sel.contains(screen_point)) { - break :sel_colors BgFg{ - .bg = self.config.selection_background orelse self.config.foreground, - .fg = self.config.selection_foreground orelse self.config.background, - }; - } - } - - break :sel_colors null; - }; + // If we are selected, we our colors are just inverted fg/bg + var selection_res: ?BgFg = if (selected) .{ + .bg = self.config.selection_background orelse self.config.foreground, + .fg = self.config.selection_foreground orelse self.config.background, + } else null; const res: BgFg = selection_res orelse if (!cell.attrs.inverse) .{ // In normal mode, background and fg match the cell. We @@ -998,11 +994,37 @@ pub fn updateCell( // If the cell has a background, we always draw it. if (colors.bg) |rgb| { + // Determine our background alpha. If we have transparency configured + // then this is dynamic depending on some situations. This is all + // in an attempt to make transparency look the best for various + // situations. See inline comments. + const bg_alpha: u8 = bg_alpha: { + if (self.config.background_opacity >= 1) break :bg_alpha alpha; + + // If we're selected, we do not apply background opacity + if (selected) break :bg_alpha alpha; + + // If we're reversed, do not apply background opacity + if (cell.attrs.inverse) break :bg_alpha alpha; + + // If we have a background and its not the default background + // then we apply background opacity + if (cell.attrs.has_bg and !std.meta.eql(rgb, self.config.background)) { + break :bg_alpha alpha; + } + + // We apply background opacity. + var bg_alpha: f64 = @floatFromInt(alpha); + bg_alpha *= self.config.background_opacity; + bg_alpha = @ceil(bg_alpha); + break :bg_alpha @intFromFloat(bg_alpha); + }; + self.cells_bg.appendAssumeCapacity(.{ .mode = .bg, .grid_pos = .{ @as(f32, @floatFromInt(x)), @as(f32, @floatFromInt(y)) }, .cell_width = cell.widthLegacy(), - .color = .{ rgb.r, rgb.g, rgb.b, alpha }, + .color = .{ rgb.r, rgb.g, rgb.b, bg_alpha }, }); } diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 84c7ad76e..3d06901de 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -238,6 +238,7 @@ pub const DerivedConfig = struct { font_thicken: bool, cursor_color: ?terminal.color.RGB, background: terminal.color.RGB, + background_opacity: f64, foreground: terminal.color.RGB, selection_background: ?terminal.color.RGB, selection_foreground: ?terminal.color.RGB, @@ -249,6 +250,7 @@ pub const DerivedConfig = struct { _ = alloc_gpa; return .{ + .background_opacity = @max(0, @min(1, config.@"background-opacity")), .font_thicken = config.@"font-thicken", .cursor_color = if (config.@"cursor-color") |col| @@ -461,7 +463,7 @@ fn resetCellsLRU(self: *OpenGL) void { } /// Returns the hints that we want for this -pub fn glfwWindowHints() glfw.Window.Hints { +pub fn glfwWindowHints(config: *const configpkg.Config) glfw.Window.Hints { return .{ .context_version_major = 3, .context_version_minor = 3, @@ -469,6 +471,7 @@ pub fn glfwWindowHints() glfw.Window.Hints { .opengl_forward_compat = true, .cocoa_graphics_switching = builtin.os.tag == .macos, .cocoa_retina_framebuffer = true, + .transparent_framebuffer = config.@"background-opacity" < 1, }; } @@ -1059,30 +1062,25 @@ pub fn updateCell( fg: terminal.color.RGB, }; + // True if this cell is selected + // TODO(perf): we can check in advance if selection is in + // our viewport at all and not run this on every point. + const selected: bool = if (selection) |sel| selected: { + const screen_point = (terminal.point.Viewport{ + .x = x, + .y = y, + }).toScreen(screen); + + break :selected sel.contains(screen_point); + } else false; + // The colors for the cell. const colors: BgFg = colors: { - // If we have a selection, then we need to check if this - // cell is selected. - // TODO(perf): we can check in advance if selection is in - // our viewport at all and not run this on every point. - var selection_res: ?BgFg = sel_colors: { - if (selection) |sel| { - const screen_point = (terminal.point.Viewport{ - .x = x, - .y = y, - }).toScreen(screen); - - // If we are selected, we our colors are just inverted fg/bg - if (sel.contains(screen_point)) { - break :sel_colors BgFg{ - .bg = self.config.selection_background orelse self.config.foreground, - .fg = self.config.selection_foreground orelse self.config.background, - }; - } - } - - break :sel_colors null; - }; + // If we are selected, we our colors are just inverted fg/bg + var selection_res: ?BgFg = if (selected) .{ + .bg = self.config.selection_background orelse self.config.foreground, + .fg = self.config.selection_foreground orelse self.config.background, + } else null; const res: BgFg = selection_res orelse if (!cell.attrs.inverse) .{ // In normal mode, background and fg match the cell. We @@ -1125,10 +1123,34 @@ pub fn updateCell( // If the cell has a background, we always draw it. if (colors.bg) |rgb| { - var mode: GPUCellMode = .bg; + // Determine our background alpha. If we have transparency configured + // then this is dynamic depending on some situations. This is all + // in an attempt to make transparency look the best for various + // situations. See inline comments. + const bg_alpha: u8 = bg_alpha: { + if (self.config.background_opacity >= 1) break :bg_alpha alpha; + + // If we're selected, we do not apply background opacity + if (selected) break :bg_alpha alpha; + + // If we're reversed, do not apply background opacity + if (cell.attrs.inverse) break :bg_alpha alpha; + + // If we have a background and its not the default background + // then we apply background opacity + if (cell.attrs.has_bg and !std.meta.eql(rgb, self.config.background)) { + break :bg_alpha alpha; + } + + // We apply background opacity. + var bg_alpha: f64 = @floatFromInt(alpha); + bg_alpha *= self.config.background_opacity; + bg_alpha = @ceil(bg_alpha); + break :bg_alpha @intFromFloat(bg_alpha); + }; self.cells_bg.appendAssumeCapacity(.{ - .mode = mode, + .mode = .bg, .grid_col = @intCast(x), .grid_row = @intCast(y), .grid_width = cell.widthLegacy(), @@ -1145,7 +1167,7 @@ pub fn updateCell( .bg_r = rgb.r, .bg_g = rgb.g, .bg_b = rgb.b, - .bg_a = alpha, + .bg_a = bg_alpha, }); } @@ -1411,7 +1433,7 @@ pub fn draw(self: *OpenGL) !void { @as(f32, @floatFromInt(self.draw_background.r)) / 255, @as(f32, @floatFromInt(self.draw_background.g)) / 255, @as(f32, @floatFromInt(self.draw_background.b)) / 255, - 1.0, + @floatCast(self.config.background_opacity), ); gl.clear(gl.c.GL_COLOR_BUFFER_BIT);