diff --git a/include/ghostty.h b/include/ghostty.h index 65b1cdc5a45..541aa16a3d4 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -449,6 +449,7 @@ typedef struct { ghostty_platform_u platform; void* userdata; double scale_factor; + bool focused; float font_size; const char* working_directory; const char* command; diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 47503dc0e80..da9ba9044bb 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -641,6 +641,9 @@ extension Ghostty { /// The configuration for a surface. For any configuration not set, defaults will be chosen from /// libghostty, usually from the Ghostty configuration. struct SurfaceConfiguration { + /// Initial focused state for the created surface. + var focused: Bool = true + /// Explicit font size to use in points var fontSize: Float32? @@ -665,6 +668,7 @@ extension Ghostty { init() {} init(from config: ghostty_surface_config_s) { + self.focused = config.focused self.fontSize = config.font_size if let workingDirectory = config.working_directory { self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8) @@ -711,6 +715,8 @@ extension Ghostty { #error("unsupported target") #endif + config.focused = focused + // Zero is our default value that means to inherit the font size. config.font_size = fontSize ?? 0 diff --git a/src/Surface.zig b/src/Surface.zig index 719e4605096..9800ec7fef0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -491,6 +491,11 @@ pub fn init( // Initialize our renderer with our initialized surface. try Renderer.surfaceInit(rt_surface); + const initial_focused = if (comptime @hasDecl(apprt.runtime.Surface, "initialFocused")) + rt_surface.initialFocused() + else + true; + // Determine our DPI configurations so we can properly configure // font points to pixels and handle other high-DPI scaling factors. const content_scale = try rt_surface.getContentScale(); @@ -572,6 +577,7 @@ pub fn init( app_mailbox, ); errdefer render_thread.deinit(); + render_thread.flags.focused = initial_focused; // Create the IO thread var io_thread = try termio.Thread.init(alloc); @@ -600,6 +606,7 @@ pub fn init( .io_thr = undefined, .size = size, .config = derived_config, + .focused = initial_focused, // Our conditional state is initialized to the app state. This // lets us get the most likely correct color theme and so on. @@ -699,6 +706,11 @@ pub fn init( // so we can just defer this and not the subcomponents. errdefer self.io.deinit(); + // Seed the terminal focus state before the IO thread starts so DECSET + // 1004 observes the actual initial focus without requiring a synthetic + // focus callback. + self.io.terminal.flags.focused = self.focused; + // Report initial cell size on surface creation _ = try rt_app.performAction( .{ .surface = self }, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 76e65abf206..d519fd1b042 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -416,6 +416,7 @@ pub const Surface = struct { platform: Platform, userdata: ?*anyopaque = null, core_surface: CoreSurface, + focused: bool = true, content_scale: apprt.ContentScale, size: apprt.SurfaceSize, cursor_pos: apprt.CursorPos, @@ -441,6 +442,11 @@ pub const Surface = struct { /// The scale factor of the screen. scale_factor: f64 = 1, + /// Initial focused state for the surface. This seeds the core focus + /// bookkeeping without triggering focus-reporting side effects during + /// surface creation. + focused: bool = true, + /// The font size to inherit. If 0, default font size will be used. font_size: f32 = 0, @@ -486,6 +492,7 @@ pub const Surface = struct { .platform = try .init(opts.platform_tag, opts.platform), .userdata = opts.userdata, .core_surface = undefined, + .focused = opts.focused, .content_scale = .{ .x = @floatCast(opts.scale_factor), .y = @floatCast(opts.scale_factor), @@ -653,6 +660,10 @@ pub const Surface = struct { return self.io_mode; } + pub fn initialFocused(self: *const Surface) bool { + return self.focused; + } + pub fn ioWriteCallback(self: *const Surface) ?IoWriteCallback { return self.io_write_cb; } @@ -932,6 +943,7 @@ pub const Surface = struct { } pub fn focusCallback(self: *Surface, focused: bool) void { + self.focused = focused; self.core_surface.focusCallback(focused) catch |err| { log.err("error in focus callback err={}", .{err}); return; @@ -970,6 +982,7 @@ pub const Surface = struct { }; return .{ + .focused = self.focused, .font_size = font_size, .working_directory = working_directory, .context = context, diff --git a/src/config/Config.zig b/src/config/Config.zig index 29a45786fcb..8c0541b49ff 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1057,6 +1057,14 @@ palette: Palette = .{}, /// doing so. @"background-blur": BackgroundBlur = .false, +/// When true on macOS, the terminal background color is expected to be +/// provided by the host CALayer's backgroundColor rather than the GPU +/// full-screen background pass. The renderer sets bg_color alpha to 0 +/// so that the layer background shows through without alpha double-stacking. +/// This allows embedding apps to provide instant background coverage +/// during view resizes. +@"macos-background-from-layer": bool = false, + /// The opacity level (opposite of transparency) of an unfocused split. /// Unfocused splits by default are slightly faded out to make it easier to see /// which split is focused. To disable this feature, set this value to 1. diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e0d8a4dd67a..f8b53e8aa3c 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -575,6 +575,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, background_blur: configpkg.Config.BackgroundBlur, + macos_background_from_layer: bool, scroll_to_bottom_on_output: bool, pub fn init( @@ -649,6 +650,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", .background_blur = config.@"background-blur", + .macos_background_from_layer = config.@"macos-background-from-layer", .scroll_to_bottom_on_output = config.@"scroll-to-bottom".output, .arena = arena, }; @@ -735,9 +737,8 @@ 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. + // Glass effects and layer-background mode zero this + // out in updateFrame; use the config value for now. @intFromFloat(@round(options.config.background_opacity * 255.0)), }, .bools = .{ @@ -1412,16 +1413,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { @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 => {}, - }; + // On macOS, glass styles and layer-background mode both + // zero bg_color alpha so that per-cell backgrounds in the + // shaders composite to transparent instead of the terminal + // background (the host layer provides the background). + // The fullscreen background draw call is also skipped for + // layer-background mode (see draw pass below). + if (comptime builtin.os.tag == .macos) { + switch (self.config.background_blur) { + .@"macos-glass-regular", + .@"macos-glass-clear", + => self.uniforms.bg_color[3] = 0, + + else => {}, + } + if (self.config.macos_background_from_layer) + self.uniforms.bg_color[3] = 0; + } // Prepare our overlay image for upload (or unload). This // has to use our general allocator since it modifies @@ -1658,26 +1666,36 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Otherwise, if we don't have a background image, we // draw the background color by itself in its own step. // + // When the host app provides the background via a + // CALayer (macos_background_from_layer), skip the + // fullscreen fill entirely — the layer handles it. + // // NOTE: We don't use the clear_color for this because that // would require us to do color space conversion on the // CPU-side. In the future when we have utilities for // that we should remove this step and use clear_color. - if (self.bg_image) |img| switch (img) { - .ready => |texture| pass.step(.{ - .pipeline = self.shaders.pipelines.bg_image, - .uniforms = frame.uniforms.buffer, - .buffers = &.{frame.bg_image_buffer.buffer}, - .textures = &.{texture}, - .draw = .{ .type = .triangle, .vertex_count = 3 }, - }), - else => {}, - } else { - pass.step(.{ - .pipeline = self.shaders.pipelines.bg_color, - .uniforms = frame.uniforms.buffer, - .buffers = &.{ null, frame.cells_bg.buffer }, - .draw = .{ .type = .triangle, .vertex_count = 3 }, - }); + const skip_bg_fill = if (comptime builtin.os.tag == .macos) + self.config.macos_background_from_layer + else + false; + if (!skip_bg_fill) { + if (self.bg_image) |img| switch (img) { + .ready => |texture| pass.step(.{ + .pipeline = self.shaders.pipelines.bg_image, + .uniforms = frame.uniforms.buffer, + .buffers = &.{frame.bg_image_buffer.buffer}, + .textures = &.{texture}, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }), + else => {}, + } else { + pass.step(.{ + .pipeline = self.shaders.pipelines.bg_color, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + } } // Then we draw any kitty images that need diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b11554b1294..fa9ba4094ad 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -752,9 +752,12 @@ pub const StreamHandler = struct { .size_report = .mode_2048, }), - .focus_event => if (enabled) self.messageWriter(.{ - .focused = self.terminal.flags.focused, - }), + // xterm-compatible focus reporting only emits on subsequent focus + // transitions. Do not synthesize an immediate state report when the + // mode is enabled, otherwise applications that toggle DECSET 1004 + // during startup receive spurious CSI I/O without any real focus + // change having happened. + .focus_event => {}, .mouse_event_x10 => { if (enabled) { @@ -1554,3 +1557,51 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .progress_report = report }); } }; + +test "enabling focus reporting does not emit an immediate focus sequence" { + const testing = std.testing; + + var term = try terminal.Terminal.init(testing.allocator, .{ + .cols = 10, + .rows = 5, + }); + defer term.deinit(testing.allocator); + + var mailbox = try termio.Mailbox.initSPSC(testing.allocator); + defer mailbox.deinit(testing.allocator); + + var mutex: std.Thread.Mutex = .{}; + var renderer_state: renderer.State = .{ + .mutex = &mutex, + .terminal = &term, + }; + var size: renderer.Size = .{ + .screen = .{ .width = 800, .height = 600 }, + .cell = .{ .width = 8, .height = 16 }, + .padding = .{}, + }; + var handler = StreamHandler{ + .alloc = testing.allocator, + .size = &size, + .terminal = &term, + .termio_mailbox = &mailbox, + .surface_mailbox = undefined, + .renderer_state = &renderer_state, + .renderer_mailbox = undefined, + .renderer_wakeup = undefined, + .default_cursor_style = .block, + .default_cursor_blink = null, + .enquiry_response = "", + .osc_color_report_format = .none, + .clipboard_write = .deny, + }; + defer handler.deinit(); + + mutex.lock(); + defer mutex.unlock(); + try handler.setMode(.focus_event, true); + + try testing.expect(term.modes.get(.focus_event)); + try testing.expect(!handler.termio_messaged); + try testing.expect(mailbox.spsc.queue.pop() == null); +}