diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 9d22b647b..cc02a2886 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -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 #include +#include #include #include #include diff --git a/include/ghostty/vt/focus.h b/include/ghostty/vt/focus.h new file mode 100644 index 000000000..ea3618bc8 --- /dev/null +++ b/include/ghostty/vt/focus.h @@ -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 + * #include + * + * 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 +#include + +#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 */ diff --git a/include/ghostty/vt/key/encoder.h b/include/ghostty/vt/key/encoder.h index 3053d73ef..9bfeba98d 100644 --- a/include/ghostty/vt/key/encoder.h +++ b/include/ghostty/vt/key/encoder.h @@ -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); diff --git a/include/ghostty/vt/mouse/encoder.h b/include/ghostty/vt/mouse/encoder.h index c418ed8ec..63fb3e075 100644 --- a/include/ghostty/vt/mouse/encoder.h +++ b/include/ghostty/vt/mouse/encoder.h @@ -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 diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 44ffe7a3e..9d8e94bba 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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" }); diff --git a/src/terminal/c/focus.zig b/src/terminal/c/focus.zig new file mode 100644 index 000000000..05930c8f3 --- /dev/null +++ b/src/terminal/c/focus.zig @@ -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); +} diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index e83c9b221..58405876f 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -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, diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 5adc00251..133acbd1f 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; diff --git a/src/terminal/c/mouse_encode.zig b/src/terminal/c/mouse_encode.zig index 1a19b0511..963e296bd 100644 --- a/src/terminal/c/mouse_encode.zig +++ b/src/terminal/c/mouse_encode.zig @@ -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, diff --git a/src/terminal/focus.zig b/src/terminal/focus.zig new file mode 100644 index 000000000..563c0871a --- /dev/null +++ b/src/terminal/focus.zig @@ -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()); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index a87d5ec87..bbdea1542 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -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"); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 4523e9c53..73e1c46d5 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -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.