vt: expose focus encoding in C and Zig APIs

Add focus event encoding (CSI I / CSI O) to the libghostty-vt public
API, following the same patterns as key and mouse encoding.

The focus Event enum uses lib.Enum for C ABI compatibility. The C API
provides ghostty_focus_encode() which writes into a caller-provided
buffer and returns GHOSTTY_OUT_OF_SPACE with the required size when
the buffer is too small.

Also update key and mouse encoders to return GHOSTTY_OUT_OF_SPACE
instead of GHOSTTY_OUT_OF_MEMORY for buffer-too-small errors,
reserving OUT_OF_MEMORY for actual allocation failures. Update all
corresponding header documentation.
This commit is contained in:
Mitchell Hashimoto
2026-03-16 14:23:15 -07:00
parent c1326c57f9
commit bed9d92f04
12 changed files with 226 additions and 16 deletions

View File

@@ -30,14 +30,17 @@
* The API is organized into the following groups:
* - @ref terminal "Terminal" - Complete terminal emulator state and rendering
* - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML
* - @ref key "Key Encoding" - Encode key events into terminal sequences
* - @ref mouse "Mouse Encoding" - Encode mouse events into terminal sequences
* - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
* - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
* - @ref paste "Paste Utilities" - Validate paste data safety
* - @ref allocator "Memory Management" - Memory management and custom allocators
* - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions
*
* Encoding related APIs:
* - @ref focus "Focus Encoding" - Encode focus in/out events into terminal sequences
* - @ref key "Key Encoding" - Encode key events into terminal sequences
* - @ref mouse "Mouse Encoding" - Encode mouse events into terminal sequences
*
* @section examples_sec Examples
*
* Complete working examples:
@@ -90,6 +93,7 @@ extern "C" {
#include <ghostty/vt/types.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/focus.h>
#include <ghostty/vt/formatter.h>
#include <ghostty/vt/terminal.h>
#include <ghostty/vt/osc.h>

View File

@@ -0,0 +1,94 @@
/**
* @file focus.h
*
* Focus encoding - encode focus in/out events into terminal escape sequences.
*/
#ifndef GHOSTTY_VT_FOCUS_H
#define GHOSTTY_VT_FOCUS_H
/** @defgroup focus Focus Encoding
*
* Utilities for encoding focus gained/lost events into terminal escape
* sequences (CSI I / CSI O) for focus reporting mode (mode 1004).
*
* ## Basic Usage
*
* Use ghostty_focus_encode() to encode a focus event into a caller-provided
* buffer. If the buffer is too small, the function returns
* GHOSTTY_OUT_OF_SPACE and sets the required size in the output parameter.
*
* ## Example
*
* @code{.c}
* #include <stdio.h>
* #include <ghostty/vt.h>
*
* int main() {
* char buf[8];
* size_t written = 0;
*
* GhosttyResult result = ghostty_focus_encode(
* GHOSTTY_FOCUS_GAINED, buf, sizeof(buf), &written);
*
* if (result == GHOSTTY_SUCCESS) {
* printf("Encoded %zu bytes: ", written);
* fwrite(buf, 1, written, stdout);
* printf("\n");
* }
*
* return 0;
* }
* @endcode
*
* @{
*/
#include <stddef.h>
#include <ghostty/vt/types.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* Focus event types for focus reporting mode (mode 1004).
*/
typedef enum {
/** Terminal window gained focus */
GHOSTTY_FOCUS_GAINED = 0,
/** Terminal window lost focus */
GHOSTTY_FOCUS_LOST = 1,
} GhosttyFocusEvent;
/**
* Encode a focus event into a terminal escape sequence.
*
* Encodes a focus gained (CSI I) or focus lost (CSI O) report into the
* provided buffer.
*
* If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE
* and writes the required buffer size to @p out_written. The caller can
* then retry with a sufficiently sized buffer.
*
* @param event The focus event to encode
* @param buf Output buffer to write the encoded sequence into (may be NULL)
* @param buf_len Size of the output buffer in bytes
* @param[out] out_written On success, the number of bytes written. On
* GHOSTTY_OUT_OF_SPACE, the required buffer size.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer
* is too small
*/
GhosttyResult ghostty_focus_encode(
GhosttyFocusEvent event,
char* buf,
size_t buf_len,
size_t* out_written);
#ifdef __cplusplus
}
#endif
/** @} */
#endif /* GHOSTTY_VT_FOCUS_H */

View File

@@ -185,7 +185,7 @@ void ghostty_key_encoder_setopt_from_terminal(GhosttyKeyEncoder encoder, Ghostty
* typically don't generate escape sequences. Check the out_len parameter to
* determine if any data was written.
*
* If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY
* If the output buffer is too small, this function returns GHOSTTY_OUT_OF_SPACE
* and out_len will contain the required buffer size. The caller can then
* allocate a larger buffer and call the function again.
*
@@ -194,15 +194,15 @@ void ghostty_key_encoder_setopt_from_terminal(GhosttyKeyEncoder encoder, Ghostty
* @param out_buf Buffer to write the encoded sequence to
* @param out_buf_size Size of the output buffer in bytes
* @param out_len Pointer to store the number of bytes written (may be NULL)
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer too small, or other error code
*
* ## Example: Calculate required buffer size
*
* @code{.c}
* // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY)
* // Query the required size with a NULL buffer (always returns OUT_OF_SPACE)
* size_t required = 0;
* GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required);
* assert(result == GHOSTTY_OUT_OF_MEMORY);
* assert(result == GHOSTTY_OUT_OF_SPACE);
*
* // Allocate buffer of required size
* char *buf = malloc(required);
@@ -228,7 +228,7 @@ void ghostty_key_encoder_setopt_from_terminal(GhosttyKeyEncoder encoder, Ghostty
* if (result == GHOSTTY_SUCCESS) {
* // Write the encoded sequence to the terminal
* write(pty_fd, buf, written);
* } else if (result == GHOSTTY_OUT_OF_MEMORY) {
* } else if (result == GHOSTTY_OUT_OF_SPACE) {
* // Buffer too small, written contains required size
* char *dynamic_buf = malloc(written);
* result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written);

View File

@@ -189,7 +189,7 @@ void ghostty_mouse_encoder_reset(GhosttyMouseEncoder encoder);
* Not all mouse events produce output. In such cases this returns
* GHOSTTY_SUCCESS with out_len set to 0.
*
* If the output buffer is too small, this returns GHOSTTY_OUT_OF_MEMORY
* If the output buffer is too small, this returns GHOSTTY_OUT_OF_SPACE
* and out_len contains the required size.
*
* @param encoder The encoder handle, must not be NULL
@@ -197,7 +197,7 @@ void ghostty_mouse_encoder_reset(GhosttyMouseEncoder encoder);
* @param out_buf Buffer to write encoded bytes to, or NULL to query required size
* @param out_buf_size Size of out_buf in bytes
* @param out_len Pointer to store bytes written (or required bytes on failure)
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer is too small,
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer is too small,
* or another error code
*
* @ingroup mouse

View File

@@ -81,11 +81,17 @@ pub const input = struct {
// We have to be careful to only import targeted files within
// the input package because the full package brings in too many
// other dependencies.
const focus = terminal.focus;
const paste = @import("input/paste.zig");
const key = @import("input/key.zig");
const key_encode = @import("input/key_encode.zig");
const mouse_encode = @import("input/mouse_encode.zig");
// Focus-related APIs
pub const max_focus_encode_size = focus.max_encode_size;
pub const FocusEvent = focus.Event;
pub const encodeFocus = focus.encode;
// Paste-related APIs
pub const PasteError = paste.Error;
pub const PasteOptions = paste.Options;
@@ -158,6 +164,7 @@ comptime {
@export(&c.osc_end, .{ .name = "ghostty_osc_end" });
@export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" });
@export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" });
@export(&c.focus_encode, .{ .name = "ghostty_focus_encode" });
@export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" });
@export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" });
@export(&c.sgr_new, .{ .name = "ghostty_sgr_new" });

54
src/terminal/c/focus.zig Normal file
View File

@@ -0,0 +1,54 @@
const std = @import("std");
const terminal_focus = @import("../focus.zig");
const Result = @import("result.zig").Result;
pub fn encode(
event: terminal_focus.Event,
out_: ?[*]u8,
out_len: usize,
out_written: *usize,
) callconv(.c) Result {
var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{});
terminal_focus.encode(&writer, event) catch |err| switch (err) {
error.WriteFailed => {
var discarding: std.Io.Writer.Discarding = .init(&.{});
terminal_focus.encode(&discarding.writer, event) catch unreachable;
out_written.* = @intCast(discarding.count);
return .out_of_space;
},
};
out_written.* = writer.end;
return .success;
}
test "encode focus gained" {
var buf: [terminal_focus.max_encode_size]u8 = undefined;
var written: usize = 0;
const result = encode(.gained, &buf, buf.len, &written);
try std.testing.expectEqual(.success, result);
try std.testing.expectEqualStrings("\x1B[I", buf[0..written]);
}
test "encode focus lost" {
var buf: [terminal_focus.max_encode_size]u8 = undefined;
var written: usize = 0;
const result = encode(.lost, &buf, buf.len, &written);
try std.testing.expectEqual(.success, result);
try std.testing.expectEqualStrings("\x1B[O", buf[0..written]);
}
test "encode with insufficient buffer" {
var buf: [1]u8 = undefined;
var written: usize = 0;
const result = encode(.gained, &buf, buf.len, &written);
try std.testing.expectEqual(.out_of_space, result);
try std.testing.expectEqual(terminal_focus.max_encode_size, written);
}
test "encode with null buffer" {
var written: usize = 0;
const result = encode(.gained, null, 0, &written);
try std.testing.expectEqual(.out_of_space, result);
try std.testing.expectEqual(terminal_focus.max_encode_size, written);
}

View File

@@ -152,7 +152,7 @@ pub fn encode(
// Discarding always uses a u64. If we're on 32-bit systems
// we cast down. We should make this safer in the future.
out_written.* = @intCast(discarding.count);
return .out_of_memory;
return .out_of_space;
},
};
@@ -329,7 +329,7 @@ test "encode: kitty ctrl release with ctrl mod set" {
// Encode null should give us the length required
var required: usize = 0;
try testing.expectEqual(Result.out_of_memory, encode(
try testing.expectEqual(Result.out_of_space, encode(
encoder,
event,
null,

View File

@@ -1,4 +1,5 @@
pub const color = @import("color.zig");
pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
pub const osc = @import("osc.zig");
pub const key_event = @import("key_event.zig");
@@ -20,6 +21,8 @@ pub const osc_command_data = osc.commandData;
pub const color_rgb_get = color.rgb_get;
pub const focus_encode = focus.encode;
pub const formatter_terminal_new = formatter.terminal_new;
pub const formatter_format_buf = formatter.format_buf;
pub const formatter_format_alloc = formatter.format_alloc;
@@ -90,6 +93,7 @@ pub const terminal_scroll_viewport = terminal.scroll_viewport;
test {
_ = color;
_ = focus;
_ = formatter;
_ = osc;
_ = key_event;

View File

@@ -240,7 +240,7 @@ pub fn encode(
// Discarding always uses a u64. If we're on 32-bit systems
// we cast down. We should make this safer in the future.
out_written.* = @intCast(discarding.count);
return .out_of_memory;
return .out_of_space;
},
};
@@ -386,7 +386,7 @@ test "encode: sgr press left" {
mouse_event.set_position(event, .{ .x = 0, .y = 0 });
var required: usize = 0;
try testing.expectEqual(Result.out_of_memory, encode(
try testing.expectEqual(Result.out_of_space, encode(
encoder,
event,
null,
@@ -507,7 +507,7 @@ test "encode: querying required size doesn't update dedupe state" {
mouse_event.set_position(event, .{ .x = 5, .y = 6 });
var required: usize = 0;
try testing.expectEqual(Result.out_of_memory, encode(
try testing.expectEqual(Result.out_of_space, encode(
encoder,
event,
null,

41
src/terminal/focus.zig Normal file
View File

@@ -0,0 +1,41 @@
const std = @import("std");
const build_options = @import("terminal_options");
const lib = @import("../lib/main.zig");
const lib_target: lib.Target = if (build_options.c_abi) .c else .zig;
/// Maximum number of bytes that `encode` will write. Any users of this
/// should be resilient to this changing, so this is always a specific
/// value (e.g. we don't add unnecessary padding).
pub const max_encode_size = 3;
/// A focus event that can be reported to the application running in the
/// terminal when focus reporting mode (mode 1004) is enabled.
pub const Event = lib.Enum(lib_target, &.{
"gained",
"lost",
});
/// Encode a focus in/out report (CSI I / CSI O).
pub fn encode(
writer: *std.Io.Writer,
event: Event,
) std.Io.Writer.Error!void {
try writer.writeAll(switch (event) {
.gained => "\x1B[I",
.lost => "\x1B[O",
});
}
test "encode focus gained" {
var buf: [max_encode_size]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encode(&writer, .gained);
try std.testing.expectEqualStrings("\x1B[I", writer.buffered());
}
test "encode focus lost" {
var buf: [max_encode_size]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encode(&writer, .lost);
try std.testing.expectEqualStrings("\x1B[O", writer.buffered());
}

View File

@@ -11,6 +11,7 @@ pub const osc = @import("osc.zig");
pub const point = @import("point.zig");
pub const color = @import("color.zig");
pub const device_status = @import("device_status.zig");
pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
pub const highlight = @import("highlight.zig");
pub const kitty = @import("kitty.zig");

View File

@@ -664,8 +664,13 @@ pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void {
// If we have focus events enabled, we send the focus event.
if (focus_event) {
const seq = if (focused) "\x1b[I" else "\x1b[O";
try self.queueWrite(td, seq, false);
var buf: [terminalpkg.focus.max_encode_size]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
terminalpkg.focus.encode(&writer, if (focused) .gained else .lost) catch |err| {
log.err("error encoding focus event err={}", .{err});
return;
};
try self.queueWrite(td, writer.buffered(), false);
}
// We always notify our backend of focus changes.