Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions macos/Sources/Ghostty/Surface View/SurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -572,6 +577,7 @@ pub fn init(
app_mailbox,
);
errdefer render_thread.deinit();
render_thread.flags.focused = initial_focused;

Comment on lines +580 to 581
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

render_thread.flags.focused is seeded from initial_focused, but the renderer implementation itself is still initialized with focused = true (see renderer/generic.zig). If initial_focused is ever false, the renderer thread will later process the first .focus = true message and call renderer.setFocus(true), which currently asserts because the renderer already thinks it is focused. This also means an initially-unfocused surface would render with focused cursor/state until a focus transition occurs.

To fix this, ensure the renderer thread applies the initial focus state to the renderer before the event loop starts (and before starting cursor blink), e.g. on thread startup call renderer.setFocus(false) when flags.focused is false, or otherwise synchronize renderer focus with initial_focused without relying on a subsequent focus transition.

Copilot uses AI. Check for mistakes.
// Create the IO thread
var io_thread = try termio.Thread.init(alloc);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 },
Expand Down
13 changes: 13 additions & 0 deletions src/apprt/embedded.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -970,6 +982,7 @@ pub const Surface = struct {
};

return .{
.focused = self.focused,
.font_size = font_size,
.working_directory = working_directory,
.context = context,
Expand Down
8 changes: 8 additions & 0 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
76 changes: 47 additions & 29 deletions src/renderer/generic.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 = .{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
57 changes: 54 additions & 3 deletions src/termio/stream_handler.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Loading