From b91cc867a815f1d26bd5b34d17b70b64abff88d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 06:50:58 -0700 Subject: [PATCH] vt: add ghostty_terminal_set for configuring effects callbacks Add a typed option setter ghostty_terminal_set() following the existing setopt pattern used by the key encoder and render state APIs. This is the first step toward exposing stream_terminal Handler.Effects through the C API. The initial implementation includes a write_pty callback and a shared userdata pointer. The write_pty callback is invoked synchronously during ghostty_terminal_vt_write() when the terminal needs to send a response back to the pty, such as DECRQM mode reports or device status responses. Trampolines are always installed at terminal creation time and no-op when no C callback is set, so callers can configure callbacks at any point without reinitializing the stream. The C callback state is grouped into an internal Effects struct on the TerminalWrapper to simplify adding more callbacks in the future. --- include/ghostty/vt/terminal.h | 77 ++++++++++++++++++++++++++++++++--- src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 74 ++++++++++++++++++++++++++++++++- 4 files changed, 146 insertions(+), 7 deletions(-) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b8e929684..064ee81dd 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -134,6 +134,52 @@ typedef struct { uint64_t len; } GhosttyTerminalScrollbar; +/** + * Callback function type for write_pty. + * + * Called when the terminal needs to write data back to the pty, for + * example in response to a device status report or mode query. The + * data is only valid for the duration of the call; callers must copy + * it if it needs to persist. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param data Pointer to the response bytes + * @param len Length of the response in bytes + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len); + +/** + * Terminal option identifiers. + * + * These values are used with ghostty_terminal_set() to configure + * terminal callbacks and associated state. + * + * @ingroup terminal + */ +typedef enum { + /** + * Opaque userdata pointer passed to all callbacks. + * + * Input type: void** + */ + GHOSTTY_TERMINAL_OPT_USERDATA = 0, + + /** + * Callback invoked when the terminal needs to write data back + * to the pty (e.g. in response to a DECRQM query or device + * status report). Set to NULL to ignore such sequences. + * + * Input type: GhosttyTerminalWritePtyFn* + */ + GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1, +} GhosttyTerminalOption; + /** * Terminal data types. * @@ -290,15 +336,36 @@ GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, uint16_t cols, uint16_t rows); +/** + * Set an option on the terminal. + * + * Configures terminal callbacks and associated state such as the + * write_pty callback and userdata pointer. A NULL value pointer + * clears the option to its default (NULL/disabled). + * + * Callbacks are invoked synchronously during ghostty_terminal_vt_write(). + * Callbacks must not call ghostty_terminal_vt_write() on the same + * terminal (no reentrancy). + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option), + * or NULL to clear the option + * + * @ingroup terminal + */ +void ghostty_terminal_set(GhosttyTerminal terminal, + GhosttyTerminalOption option, + const void* value); + /** * Write VT-encoded data to the terminal for processing. * * Feeds raw bytes through the terminal's VT stream parser, updating - * terminal state accordingly. Only read-only sequences are processed; - * sequences that require output (queries) are ignored. - * - * In the future, a callback-based API will be added to allow handling - * of output or side effect sequences. + * terminal state accordingly. By default, sequences that require output + * (queries, device status reports) are silently ignored. Use + * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_WRITE_PTY to install + * a callback that receives response data. * * This never fails. Any erroneous input or errors in processing the * input are logged internally but do not cause this function to fail diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 81375ec8a..0a749be87 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -206,6 +206,7 @@ comptime { @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); + @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 7597228d1..d854ac60e 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -132,6 +132,7 @@ pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; pub const terminal_resize = terminal.resize; +pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; pub const terminal_mode_get = terminal.mode_get; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index bebcf4ea1..70997cc08 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -16,14 +16,35 @@ const grid_ref_c = @import("grid_ref.zig"); const style_c = @import("style.zig"); const Result = @import("result.zig").Result; +const Handler = @import("../stream_terminal.zig").Handler; + const log = std.log.scoped(.terminal_c); +/// C function pointer type for the write_pty callback. +pub const CWritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; + /// Wrapper around ZigTerminal that tracks additional state for C API usage, /// such as the persistent VT stream needed to handle escape sequences split /// across multiple vt_write calls. const TerminalWrapper = struct { terminal: *ZigTerminal, stream: Stream, + effects: Effects = .{}, +}; + +/// C callback state for terminal effects. Trampolines are always +/// installed on the stream handler; they check these fields and +/// no-op when the corresponding callback is null. +const Effects = struct { + userdata: ?*anyopaque = null, + write_pty: ?CWritePtyFn = null, + + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.write_pty orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); + } }; /// C: GhosttyTerminal @@ -80,8 +101,10 @@ fn new_( }); errdefer t.deinit(alloc); - // Setup our stream - const handler: Stream.Handler = t.vtHandler(); + // Setup our stream with trampolines always installed so that + // setting C callbacks at any time takes effect immediately. + var handler: Stream.Handler = t.vtHandler(); + handler.effects.write_pty = &Effects.writePtyTrampoline; wrapper.* = .{ .terminal = t, @@ -100,6 +123,53 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +/// C: GhosttyTerminalOption +pub const Option = enum(c_int) { + userdata = 0, + write_pty = 1, + + /// Input type expected for setting the option. + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*anyopaque, + .write_pty => ?CWritePtyFn, + }; + } +}; + +pub fn set( + terminal_: Terminal, + option: Option, + value: ?*const anyopaque, +) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("terminal_set invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + terminal_, + comptime_option, + @ptrCast(@alignCast(value)), + ), + }; +} + +fn setTyped( + terminal_: Terminal, + comptime option: Option, + value: ?*const option.InType(), +) void { + const wrapper = terminal_ orelse return; + switch (option) { + .userdata => wrapper.effects.userdata = if (value) |v| v.* else null, + .write_pty => wrapper.effects.write_pty = if (value) |v| v.* else null, + } +} + /// C: GhosttyTerminalScrollViewport pub const ScrollViewport = ZigTerminal.ScrollViewport.C;