From 302e68fd3d9891919a3b6f32f47ee7f954bef848 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 12:57:46 -0700 Subject: [PATCH 01/17] vt: expose ghostty_terminal_new/free --- include/ghostty/vt.h | 2 + include/ghostty/vt/terminal.h | 88 ++++++++++++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/Terminal.zig | 2 +- src/terminal/c/main.zig | 5 ++ src/terminal/c/terminal.zig | 112 ++++++++++++++++++++++++++++++++++ 6 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 include/ghostty/vt/terminal.h create mode 100644 src/terminal/c/terminal.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 4f8fef88e..e07ccefaf 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -28,6 +28,7 @@ * @section groups_sec API Reference * * The API is organized into the following groups: + * - @ref terminal "Terminal Lifecycle" - Create and destroy terminal instances * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences @@ -74,6 +75,7 @@ extern "C" { #include #include +#include #include #include #include diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 index 000000000..f2e68fa96 --- /dev/null +++ b/include/ghostty/vt/terminal.h @@ -0,0 +1,88 @@ +/** + * @file terminal.h + * + * Terminal lifecycle management. + */ + +#ifndef GHOSTTY_VT_TERMINAL_H +#define GHOSTTY_VT_TERMINAL_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup terminal Terminal Lifecycle + * + * Minimal API for creating and destroying terminal instances. + * + * This currently only exposes lifecycle operations. Additional terminal + * APIs will be added over time. + * + * @{ + */ + +/** + * Opaque handle to a terminal instance. + * + * @ingroup terminal + */ +typedef struct GhosttyTerminal* GhosttyTerminal; + +/** + * Terminal initialization options. + * + * @ingroup terminal + */ +typedef struct { + /** Terminal width in cells. Must be greater than zero. */ + uint16_t cols; + + /** Terminal height in cells. Must be greater than zero. */ + uint16_t rows; + + /** Maximum number of lines to keep in scrollback history. */ + size_t max_scrollback; + + // TODO: Consider ABI compatibility implications of this struct. + // We may want to artificially pad it significantly to support + // future options. +} GhosttyTerminalOptions; + +/** + * Create a new terminal instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param terminal Pointer to store the created terminal handle + * @param options Terminal initialization options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, + GhosttyTerminal* terminal, + GhosttyTerminalOptions options); + +/** + * Free a terminal instance. + * + * Releases all resources associated with the terminal. After this call, + * the terminal handle becomes invalid and must not be used. + * + * @param terminal The terminal handle to free (may be NULL) + * + * @ingroup terminal + */ +void ghostty_terminal_free(GhosttyTerminal terminal); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 426660621..9af5d9d70 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -143,6 +143,8 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); + @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); + @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 706f235c7..ec08ec72c 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -271,7 +271,7 @@ pub fn vtHandler(self: *Terminal) ReadonlyHandler { } /// The general allocator we should use for this terminal. -fn gpa(self: *Terminal) Allocator { +pub fn gpa(self: *Terminal) Allocator { return self.screens.active.alloc; } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index bc92597f5..e77769e24 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); pub const paste = @import("paste.zig"); pub const sgr = @import("sgr.zig"); +pub const terminal = @import("terminal.zig"); // The full C API, unexported. pub const osc_new = osc.new; @@ -52,6 +53,9 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; +pub const terminal_new = terminal.new; +pub const terminal_free = terminal.free; + test { _ = color; _ = osc; @@ -59,6 +63,7 @@ test { _ = key_encode; _ = paste; _ = sgr; + _ = terminal; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 index 000000000..14d66ca0a --- /dev/null +++ b/src/terminal/c/terminal.zig @@ -0,0 +1,112 @@ +const std = @import("std"); +const testing = std.testing; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const ZigTerminal = @import("../Terminal.zig"); +const size = @import("../size.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyTerminal +pub const Terminal = ?*ZigTerminal; + +/// C: GhosttyTerminalOptions +pub const Options = extern struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize, +}; + +const NewError = error{ + InvalidValue, + OutOfMemory, +}; + +pub fn new( + alloc_: ?*const CAllocator, + result: *Terminal, + opts: Options, +) callconv(.c) Result { + result.* = new_(alloc_, opts) catch |err| { + result.* = null; + return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn new_( + alloc_: ?*const CAllocator, + opts: Options, +) NewError!*ZigTerminal { + if (opts.cols == 0 or opts.rows == 0) return error.InvalidValue; + + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(ZigTerminal) catch + return error.OutOfMemory; + errdefer alloc.destroy(ptr); + + ptr.* = try .init(alloc, .{ + .cols = opts.cols, + .rows = opts.rows, + .max_scrollback = opts.max_scrollback, + }); + + return ptr; +} + +pub fn free(terminal_: Terminal) callconv(.c) void { + const t = terminal_ orelse return; + + const alloc = t.gpa(); + t.deinit(alloc); + alloc.destroy(t); +} + +test "new/free" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + + try testing.expect(t != null); + free(t); +} + +test "new invalid value" { + var t: Terminal = null; + + try testing.expectEqual(Result.invalid_value, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 0, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + try testing.expect(t == null); + + try testing.expectEqual(Result.invalid_value, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 0, + .max_scrollback = 10_000, + }, + )); + try testing.expect(t == null); +} + +test "free null" { + free(null); +} From 18fdc15357a2f519d93987d09a2957b9369340cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:35:41 -0700 Subject: [PATCH 02/17] vt: ghostty_terminal_vt_write --- include/ghostty/vt/terminal.h | 26 ++++++++++++++++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 30 ++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index f2e68fa96..e59f4c4b8 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -79,6 +79,32 @@ GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, */ void ghostty_terminal_free(GhosttyTerminal terminal); +/** + * 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. + * + * This never fails. Any erroneous input or errors in processing the + * input are logged internally but do not cause this function to fail + * because this input is assumed to be untrusted and from an external + * source; so the primary goal is to keep the terminal state consistent and + * not allow malformed input to corrupt or crash. + * + * @param terminal The terminal handle + * @param data Pointer to the data to write + * @param len Length of the data in bytes + * + * @ingroup terminal + */ +void ghostty_terminal_vt_write(GhosttyTerminal terminal, + const uint8_t* data, + size_t len); + /** @} */ #ifdef __cplusplus diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 9af5d9d70..516c9f882 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -145,6 +145,7 @@ comptime { @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index e77769e24..6d908ed6b 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -55,6 +55,7 @@ pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; +pub const terminal_vt_write = terminal.vt_write; test { _ = color; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 14d66ca0a..4b64c7a80 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -57,6 +57,16 @@ fn new_( return ptr; } +pub fn vt_write( + terminal_: Terminal, + ptr: [*]const u8, + len: usize, +) callconv(.c) void { + const t = terminal_ orelse return; + var stream = t.vtStream(); + stream.nextSlice(ptr[0..len]); +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -110,3 +120,23 @@ test "new invalid value" { test "free null" { free(null); } + +test "vt_write" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + const str = try t.?.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello", str); +} From 8b9afe35a706ea230473c81637c3f43f07d736b7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:45:22 -0700 Subject: [PATCH 03/17] vt: ghostty_terminal_scroll_viewport --- include/ghostty/vt/terminal.h | 59 ++++++++++++++++++++++++++++- src/lib_vt.zig | 1 + src/terminal/Terminal.zig | 22 ++++++++++- src/terminal/c/AGENTS.md | 10 +++++ src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 71 +++++++++++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/terminal/c/AGENTS.md diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index e59f4c4b8..70f98f8a5 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -53,6 +53,45 @@ typedef struct { // future options. } GhosttyTerminalOptions; +/** + * Scroll viewport behavior tag. + * + * @ingroup terminal + */ +typedef enum { + /** Scroll to the top of the scrollback. */ + GHOSTTY_SCROLL_VIEWPORT_TOP, + + /** Scroll to the bottom (active area). */ + GHOSTTY_SCROLL_VIEWPORT_BOTTOM, + + /** Scroll by a delta amount (up is negative). */ + GHOSTTY_SCROLL_VIEWPORT_DELTA, +} GhosttyTerminalScrollViewportTag; + +/** + * Scroll viewport value. + * + * @ingroup terminal + */ +typedef union { + /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ + intptr_t delta; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyTerminalScrollViewportValue; + +/** + * Tagged union for scroll viewport behavior. + * + * @ingroup terminal + */ +typedef struct { + GhosttyTerminalScrollViewportTag tag; + GhosttyTerminalScrollViewportValue value; +} GhosttyTerminalScrollViewport; + /** * Create a new terminal instance. * @@ -102,8 +141,24 @@ void ghostty_terminal_free(GhosttyTerminal terminal); * @ingroup terminal */ void ghostty_terminal_vt_write(GhosttyTerminal terminal, - const uint8_t* data, - size_t len); + const uint8_t* data, + size_t len); + +/** + * Scroll the terminal viewport. + * + * Scrolls the terminal's viewport according to the given behavior. + * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in + * the value union to specify the number of rows to scroll (negative + * for up, positive for down). For other behaviors, the value is ignored. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param behavior The scroll behavior as a tagged union + * + * @ingroup terminal + */ +void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, + GhosttyTerminalScrollViewport behavior); /** @} */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 516c9f882..a4998f1b8 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -146,6 +146,7 @@ comptime { @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); + @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index ec08ec72c..1ea915c67 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5,6 +5,7 @@ const Terminal = @This(); const std = @import("std"); const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); const assert = @import("../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; @@ -35,6 +36,8 @@ const Page = pagepkg.Page; const Cell = pagepkg.Cell; const Row = pagepkg.Row; +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; + const log = std.log.scoped(.terminal); /// Default tabstop interval @@ -1704,7 +1707,7 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { } /// Options for scrolling the viewport of the terminal grid. -pub const ScrollViewport = union(enum) { +pub const ScrollViewport = union(Tag) { /// Scroll to the top of the scrollback top, @@ -1713,6 +1716,23 @@ pub const ScrollViewport = union(enum) { /// Scroll by some delta amount, up is negative. delta: isize, + + pub const Tag = lib.Enum(lib_target, &.{ + "top", + "bottom", + "delta", + }); + + const c_union = lib.TaggedUnion( + lib_target, + @This(), + // Padding: largest variant is isize (8 bytes on 64-bit). + // Use [2]u64 (16 bytes) for future expansion. + [2]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; }; /// Scroll the viewport of the terminal grid. diff --git a/src/terminal/c/AGENTS.md b/src/terminal/c/AGENTS.md new file mode 100644 index 000000000..fa922c6ba --- /dev/null +++ b/src/terminal/c/AGENTS.md @@ -0,0 +1,10 @@ +# libghostty-vt C API + +- C API must be designed with ABI compatibility in mind +- Zig tagged unions must be converted to C ABI compatible unions + via `lib.TaggedUnion`. +- Any functions must be updated all the way through from here to + `src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers + in `include/ghostty/vt.h`. +- In `include/ghostty/vt.h`, always sort the header contents by: + (1) macros, (2) forward declarations, (3) types, (4) functions diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 6d908ed6b..be8da379e 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -56,6 +56,7 @@ pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_vt_write = terminal.vt_write; +pub const terminal_scroll_viewport = terminal.scroll_viewport; test { _ = color; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 4b64c7a80..cf491597b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -67,6 +67,21 @@ pub fn vt_write( stream.nextSlice(ptr[0..len]); } +/// C: GhosttyTerminalScrollViewport +pub const ScrollViewport = ZigTerminal.ScrollViewport.C; + +pub fn scroll_viewport( + terminal_: Terminal, + behavior: ScrollViewport, +) callconv(.c) void { + const t = terminal_ orelse return; + t.scrollViewport(switch (behavior.tag) { + .top => .top, + .bottom => .bottom, + .delta => .{ .delta = behavior.value.delta }, + }); +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -121,6 +136,62 @@ test "free null" { free(null); } +test "scroll_viewport" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + const zt = t.?; + + // Write "hello" on the first line + vt_write(t, "hello", 5); + + // Push "hello" into scrollback with 3 newlines (index = ESC D) + vt_write(t, "\x1bD\x1bD\x1bD", 6); + { + // Viewport should be empty now since hello scrolled off + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll to top: "hello" should be visible again + scroll_viewport(t, .{ .tag = .top, .value = undefined }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } + + // Scroll to bottom: viewport should be empty again + scroll_viewport(t, .{ .tag = .bottom, .value = undefined }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll up by delta to bring "hello" back into view + scroll_viewport(t, .{ .tag = .delta, .value = .{ .delta = -3 } }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } +} + +test "scroll_viewport null" { + scroll_viewport(null, .{ .tag = .top, .value = undefined }); +} + test "vt_write" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From fe6e7fbc6b54c835f9a5229f0b19ee9f96ec5a92 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:54:19 -0700 Subject: [PATCH 04/17] vt: ghostty_terminal_resize --- include/ghostty/vt/terminal.h | 18 +++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 70f98f8a5..233389498 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -118,6 +118,24 @@ GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, */ void ghostty_terminal_free(GhosttyTerminal terminal); +/** + * Resize the terminal to the given dimensions. + * + * Changes the number of columns and rows in the terminal. The primary + * screen will reflow content if wraparound mode is enabled; the alternate + * screen does not reflow. If the dimensions are unchanged, this is a no-op. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param cols New width in cells (must be greater than zero) + * @param rows New height in cells (must be greater than zero) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, + uint16_t cols, + uint16_t rows); + /** * Write VT-encoded data to the terminal for processing. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index a4998f1b8..a76ed8446 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -145,6 +145,7 @@ comptime { @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index be8da379e..f17b9065e 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -55,6 +55,7 @@ pub const paste_is_safe = paste.is_safe; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; +pub const terminal_resize = terminal.resize; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index cf491597b..ef64e7c0e 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -82,6 +82,17 @@ pub fn scroll_viewport( }); } +pub fn resize( + terminal_: Terminal, + cols: size.CellCountInt, + rows: size.CellCountInt, +) callconv(.c) Result { + const t = terminal_ orelse return .invalid_value; + if (cols == 0 or rows == 0) return .invalid_value; + t.resize(t.gpa(), cols, rows) catch return .out_of_memory; + return .success; +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -192,6 +203,45 @@ test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .top, .value = undefined }); } +test "resize" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + try testing.expectEqual(Result.success, resize(t, 40, 12)); + try testing.expectEqual(40, t.?.cols); + try testing.expectEqual(12, t.?.rows); +} + +test "resize null" { + try testing.expectEqual(Result.invalid_value, resize(null, 80, 24)); +} + +test "resize invalid value" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + try testing.expectEqual(Result.invalid_value, resize(t, 0, 24)); + try testing.expectEqual(Result.invalid_value, resize(t, 80, 0)); +} + test "vt_write" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From aa3e6e23a227cfe4ba0026d844b54e7a89ea880b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:55:42 -0700 Subject: [PATCH 05/17] vt: ghostty_terminal_reset --- include/ghostty/vt/terminal.h | 13 +++++++++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/terminal.zig | 30 ++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 233389498..6ecb6e62c 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -118,6 +118,19 @@ GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, */ void ghostty_terminal_free(GhosttyTerminal terminal); +/** + * Perform a full reset of the terminal (RIS). + * + * Resets all terminal state back to its initial configuration, including + * modes, scrollback, scrolling region, and screen contents. The terminal + * dimensions are preserved. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * + * @ingroup terminal + */ +void ghostty_terminal_reset(GhosttyTerminal terminal); + /** * Resize the terminal to the given dimensions. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index a76ed8446..1eddbd886 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -145,6 +145,7 @@ comptime { @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @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_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index f17b9065e..31e1b40eb 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -55,6 +55,7 @@ pub const paste_is_safe = paste.is_safe; 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_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index ef64e7c0e..0af791f91 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -93,6 +93,11 @@ pub fn resize( return .success; } +pub fn reset(terminal_: Terminal) callconv(.c) void { + const t = terminal_ orelse return; + t.fullReset(); +} + pub fn free(terminal_: Terminal) callconv(.c) void { const t = terminal_ orelse return; @@ -203,6 +208,31 @@ test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .top, .value = undefined }); } +test "reset" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + reset(t); + + const str = try t.?.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); +} + +test "reset null" { + reset(null); +} + test "resize" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 34acdfcc4eca388d3d4fa1a5ce03525384db8e3e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 19:59:06 -0700 Subject: [PATCH 06/17] vt: update terminal.h docs --- include/ghostty/vt.h | 2 +- include/ghostty/vt/terminal.h | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index e07ccefaf..8f7323c31 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -28,7 +28,7 @@ * @section groups_sec API Reference * * The API is organized into the following groups: - * - @ref terminal "Terminal Lifecycle" - Create and destroy terminal instances + * - @ref terminal "Terminal" - Complete terminal emulator state and rendering * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 6ecb6e62c..6dc817392 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1,7 +1,7 @@ /** * @file terminal.h * - * Terminal lifecycle management. + * Complete terminal emulator state and rendering. */ #ifndef GHOSTTY_VT_TERMINAL_H @@ -16,12 +16,12 @@ extern "C" { #endif -/** @defgroup terminal Terminal Lifecycle +/** @defgroup terminal Terminal * - * Minimal API for creating and destroying terminal instances. + * Complete terminal emulator state and rendering. * - * This currently only exposes lifecycle operations. Additional terminal - * APIs will be added over time. + * A terminal instance manages the full emulator state including the screen, + * scrollback, cursor, styles, modes, and VT stream processing. * * @{ */ From 8e6bf829a746be199bd30d4670fe855035562433 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 13 Mar 2026 20:06:32 -0700 Subject: [PATCH 07/17] terminal/osc: don't export context/semantic prompts to libvt yet --- src/terminal/osc/parsers/context_signal.zig | 2 ++ src/terminal/osc/parsers/semantic_prompt.zig | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/terminal/osc/parsers/context_signal.zig b/src/terminal/osc/parsers/context_signal.zig index ff82af392..c36c76f21 100644 --- a/src/terminal/osc/parsers/context_signal.zig +++ b/src/terminal/osc/parsers/context_signal.zig @@ -16,6 +16,8 @@ const max_context_id_len = 64; /// A single OSC 3008 context signal command. pub const Command = struct { + pub const C = void; + action: Action, /// The context identifier. Must be 1-64 characters in the 32..126 byte range. id: []const u8, diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index d3a117515..c60ce4cb5 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -14,6 +14,8 @@ const log = std.log.scoped(.osc_semantic_prompt); /// all except one do and the spec does also say to ignore unknown /// options. So, I think this is a fair interpretation. pub const Command = struct { + pub const C = void; + action: Action, options_unvalidated: []const u8, From b5fb7ecaaaa2d788093809614d88b6294baaf672 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 14 Mar 2026 13:48:03 -0700 Subject: [PATCH 08/17] vt: wip formatter api --- include/ghostty/vt.h | 1 + include/ghostty/vt/formatter.h | 156 +++++++++++++++ include/ghostty/vt/result.h | 2 + src/lib_vt.zig | 3 + src/terminal/c/formatter.zig | 344 +++++++++++++++++++++++++++++++++ src/terminal/c/main.zig | 6 + src/terminal/c/result.zig | 1 + src/terminal/formatter.zig | 82 ++++---- 8 files changed, 556 insertions(+), 39 deletions(-) create mode 100644 include/ghostty/vt/formatter.h create mode 100644 src/terminal/c/formatter.zig diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 8f7323c31..378c03453 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -75,6 +75,7 @@ extern "C" { #include #include +#include #include #include #include diff --git a/include/ghostty/vt/formatter.h b/include/ghostty/vt/formatter.h new file mode 100644 index 000000000..24f5212ff --- /dev/null +++ b/include/ghostty/vt/formatter.h @@ -0,0 +1,156 @@ +/** + * @file formatter.h + * + * Format terminal content as plain text, VT sequences, or HTML. + */ + +#ifndef GHOSTTY_VT_FORMATTER_H +#define GHOSTTY_VT_FORMATTER_H + +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup formatter Formatter + * + * Format terminal content as plain text, VT sequences, or HTML. + * + * A formatter captures a reference to a terminal and formatting options. + * It can be used repeatedly to produce output that reflects the current + * terminal state at the time of each format call. + * + * The terminal must outlive the formatter. + * + * @{ + */ + +/** + * Output format. + * + * @ingroup formatter + */ +typedef enum { + /** Plain text (no escape sequences). */ + GHOSTTY_FORMATTER_FORMAT_PLAIN, + + /** VT sequences preserving colors, styles, URLs, etc. */ + GHOSTTY_FORMATTER_FORMAT_VT, + + /** HTML with inline styles. */ + GHOSTTY_FORMATTER_FORMAT_HTML, +} GhosttyFormatterFormat; + +/** + * Extra terminal state to include in styled output. + * + * @ingroup formatter + */ +typedef enum { + /** Emit no extra state. */ + GHOSTTY_FORMATTER_EXTRA_NONE, + + /** Emit style-relevant state (palette, cursor style, hyperlinks). */ + GHOSTTY_FORMATTER_EXTRA_STYLES, + + /** Emit all state to reconstruct terminal as closely as possible. */ + GHOSTTY_FORMATTER_EXTRA_ALL, +} GhosttyFormatterExtra; + +/** + * Opaque handle to a formatter instance. + * + * @ingroup formatter + */ +typedef struct GhosttyFormatter* GhosttyFormatter; + +/** + * Options for creating a terminal formatter. + * + * @ingroup formatter + */ +typedef struct { + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** Extra terminal state to include in styled output. */ + GhosttyFormatterExtra extra; +} GhosttyFormatterTerminalOptions; + +/** + * Create a formatter for a terminal's active screen. + * + * The terminal must outlive the formatter. The formatter stores a borrowed + * reference to the terminal and reads its current state on each format call. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param formatter Pointer to store the created formatter handle + * @param terminal The terminal to format (must not be NULL) + * @param options Formatting options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GhosttyResult ghostty_formatter_terminal_new( + const GhosttyAllocator* allocator, + GhosttyFormatter* formatter, + GhosttyTerminal terminal, + GhosttyFormatterTerminalOptions options); + +/** + * Run the formatter and produce output into the caller-provided buffer. + * + * Each call formats the current terminal state. Pass NULL for buf to + * query the required buffer size without writing any output; in that case + * out_written receives the required size and the return value is + * GHOSTTY_OUT_OF_SPACE. + * + * If the buffer is too small, returns GHOSTTY_OUT_OF_SPACE and sets + * out_written to the required size. The caller can then retry with a + * larger buffer. + * + * @param formatter The formatter handle (must not be NULL) + * @param buf Pointer to the output buffer, or NULL to query size + * @param buf_len Length of the output buffer in bytes + * @param out_written Pointer to receive the number of bytes written, + * or the required size on failure + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GhosttyResult ghostty_formatter_format(GhosttyFormatter formatter, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Free a formatter instance. + * + * Releases all resources associated with the formatter. After this call, + * the formatter handle becomes invalid. + * + * @param formatter The formatter handle to free (may be NULL) + * + * @ingroup formatter + */ +void ghostty_formatter_free(GhosttyFormatter formatter); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_FORMATTER_H */ diff --git a/include/ghostty/vt/result.h b/include/ghostty/vt/result.h index 65938ee76..6a47d35cc 100644 --- a/include/ghostty/vt/result.h +++ b/include/ghostty/vt/result.h @@ -17,6 +17,8 @@ typedef enum { GHOSTTY_OUT_OF_MEMORY = -1, /** Operation failed due to invalid value */ GHOSTTY_INVALID_VALUE = -2, + /** Operation failed because the provided buffer was too small */ + GHOSTTY_OUT_OF_SPACE = -3, } GhosttyResult; #endif /* GHOSTTY_VT_RESULT_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1eddbd886..3e1aa9d8d 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -143,6 +143,9 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); + @export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" }); + @export(&c.formatter_format, .{ .name = "ghostty_formatter_format" }); + @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); diff --git a/src/terminal/c/formatter.zig b/src/terminal/c/formatter.zig new file mode 100644 index 000000000..8b66f781a --- /dev/null +++ b/src/terminal/c/formatter.zig @@ -0,0 +1,344 @@ +const std = @import("std"); +const testing = std.testing; +const lib_alloc = @import("../../lib/allocator.zig"); +const CAllocator = lib_alloc.Allocator; +const terminal_c = @import("terminal.zig"); +const ZigTerminal = @import("../Terminal.zig"); +const formatterpkg = @import("../formatter.zig"); +const Result = @import("result.zig").Result; + +/// Wrapper around formatter that tracks the allocator for C API usage. +const FormatterWrapper = struct { + kind: Kind, + alloc: std.mem.Allocator, + + const Kind = union(enum) { + terminal: formatterpkg.TerminalFormatter, + }; +}; + +/// C: GhosttyFormatter +pub const Formatter = ?*FormatterWrapper; + +/// C: GhosttyFormatterFormat +pub const Format = formatterpkg.Format; + +/// C: GhosttyFormatterExtra +pub const Extra = enum(c_int) { + none = 0, + styles = 1, + all = 2, +}; + +/// C: GhosttyFormatterTerminalOptions +pub const TerminalOptions = extern struct { + emit: Format, + unwrap: bool, + trim: bool, + extra: Extra, +}; + +pub fn terminal_new( + alloc_: ?*const CAllocator, + result: *Formatter, + terminal_: terminal_c.Terminal, + opts: TerminalOptions, +) callconv(.c) Result { + result.* = terminal_new_( + alloc_, + terminal_, + opts, + ) catch |err| { + result.* = null; + return switch (err) { + error.InvalidValue => .invalid_value, + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn terminal_new_( + alloc_: ?*const CAllocator, + terminal_: terminal_c.Terminal, + opts: TerminalOptions, +) error{ + InvalidValue, + OutOfMemory, +}!*FormatterWrapper { + const t = terminal_ orelse return error.InvalidValue; + + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(FormatterWrapper) catch + return error.OutOfMemory; + errdefer alloc.destroy(ptr); + + const extra: formatterpkg.TerminalFormatter.Extra = switch (opts.extra) { + .none => .none, + .styles => .styles, + .all => .all, + }; + + var formatter: formatterpkg.TerminalFormatter = .init(t, .{ + .emit = opts.emit, + .unwrap = opts.unwrap, + .trim = opts.trim, + }); + formatter.extra = extra; + + ptr.* = .{ + .kind = .{ .terminal = formatter }, + .alloc = alloc, + }; + + return ptr; +} + +pub fn format( + formatter_: Formatter, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(.c) Result { + const wrapper = formatter_ orelse return .invalid_value; + + var writer: std.Io.Writer = .fixed(if (out_) |out| + out[0..out_len] + else + &.{}); + + switch (wrapper.kind) { + .terminal => |*t| t.format(&writer) catch |err| switch (err) { + error.WriteFailed => { + // On write failed we always report how much + // space we actually needed. + var discarding: std.Io.Writer.Discarding = .init(&.{}); + t.format(&discarding.writer) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }, + } + + out_written.* = writer.end; + return .success; +} + +pub fn free(formatter_: Formatter) callconv(.c) void { + const wrapper = formatter_ orelse return; + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +test "terminal_new/free" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + try testing.expect(f != null); + free(f); +} + +test "terminal_new invalid_value on null terminal" { + var f: Formatter = null; + try testing.expectEqual(Result.invalid_value, terminal_new( + &lib_alloc.test_allocator, + &f, + null, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + try testing.expect(f == null); +} + +test "free null" { + free(null); +} + +test "format plain" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello", buf[0..written]); +} + +test "format reflects terminal changes" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello", buf[0..written]); + + // Write more data and re-format + terminal_c.vt_write(t, "\r\nWorld", 7); + + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("Hello\nWorld", buf[0..written]); +} + +test "format null returns required size" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + // Pass null buffer to query required size + var required: usize = 0; + try testing.expectEqual(Result.out_of_space, format(f, null, 0, &required)); + try testing.expect(required > 0); + + // Now allocate and format + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expectEqual(required, written); +} + +test "format buffer too small" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + // Buffer too small + var buf: [2]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.out_of_space, format(f, &buf, buf.len, &written)); + // written contains the required size + try testing.expectEqual(@as(usize, 5), written); +} + +test "format null formatter" { + var written: usize = 0; + try testing.expectEqual(Result.invalid_value, format(null, null, 0, &written)); +} + +test "format vt" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Test", 4); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .vt, .unwrap = false, .trim = true, .extra = .styles }, + )); + defer free(f); + + var buf: [65536]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expect(written > 0); + try testing.expect(std.mem.indexOf(u8, buf[0..written], "Test") != null); +} + +test "format html" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Html", 4); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib_alloc.test_allocator, + &f, + t, + .{ .emit = .html, .unwrap = false, .trim = true, .extra = .none }, + )); + defer free(f); + + var buf: [65536]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format(f, &buf, buf.len, &written)); + try testing.expect(written > 0); + try testing.expect(std.mem.indexOf(u8, buf[0..written], "Html") != null); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 31e1b40eb..d2b477f95 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 formatter = @import("formatter.zig"); pub const osc = @import("osc.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); @@ -17,6 +18,10 @@ pub const osc_command_data = osc.commandData; pub const color_rgb_get = color.rgb_get; +pub const formatter_terminal_new = formatter.terminal_new; +pub const formatter_format = formatter.format; +pub const formatter_free = formatter.free; + pub const sgr_new = sgr.new; pub const sgr_free = sgr.free; pub const sgr_reset = sgr.reset; @@ -62,6 +67,7 @@ pub const terminal_scroll_viewport = terminal.scroll_viewport; test { _ = color; + _ = formatter; _ = osc; _ = key_event; _ = key_encode; diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig index e9b5fc5e6..b76326e46 100644 --- a/src/terminal/c/result.zig +++ b/src/terminal/c/result.zig @@ -3,4 +3,5 @@ pub const Result = enum(c_int) { success = 0, out_of_memory = -1, invalid_value = -2, + out_of_space = -3, }; diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index f3b503d29..a68f61934 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1,5 +1,8 @@ const std = @import("std"); +const build_options = @import("terminal_options"); const assert = @import("../quirks.zig").inlineAssert; +const lib = @import("../lib/main.zig"); +const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; const Allocator = std.mem.Allocator; const color = @import("color.zig"); const size = @import("size.zig"); @@ -19,46 +22,47 @@ const Selection = @import("Selection.zig"); const Style = @import("style.zig").Style; /// Formats available. -pub const Format = enum { - /// Plain text. - plain, +pub const Format = lib.Enum(lib_target, &.{ + // Plain text. + "plain", - /// Include VT sequences to preserve colors, styles, URLs, etc. - /// This is predominantly SGR sequences but may contain others as needed. - /// - /// Note that for reference colors, like palette indices, this will - /// vary based on the formatter and you should see the docs. For example, - /// PageFormatter with VT will emit SGR sequences with palette indices, - /// not the color itself. - /// - /// For VT, newlines will be emitted as `\r\n` so that the cursor properly - /// moves back to the beginning prior emitting follow-up lines. - vt, + // Include VT sequences to preserve colors, styles, URLs, etc. + // This is predominantly SGR sequences but may contain others as needed. + // + // Note that for reference colors, like palette indices, this will + // vary based on the formatter and you should see the docs. For example, + // PageFormatter with VT will emit SGR sequences with palette indices, + // not the color itself. + // + // For VT, newlines will be emitted as `\r\n` so that the cursor properly + // moves back to the beginning prior emitting follow-up lines. + "vt", - /// HTML output. - /// - /// This will emit inline styles for as much styling as possible, - /// in the interest of simplicity and ease of editing. This isn't meant - /// to build the most beautiful or efficient HTML, but rather to be - /// stylistically correct. - /// - /// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette - /// indices use CSS variables (var(--vt-palette-N)). The palette colors are - /// emitted by TerminalFormatter.Extra.palette as a