diff --git a/example/c-vt-render/README.md b/example/c-vt-render/README.md new file mode 100644 index 000000000..deb4db388 --- /dev/null +++ b/example/c-vt-render/README.md @@ -0,0 +1,17 @@ +# Example: `ghostty-vt` Render State + +This contains a simple example of how to use the `ghostty-vt` render-state API +to create a render state, update it from terminal content, and clean it up. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-render/build.zig b/example/c-vt-render/build.zig new file mode 100644 index 000000000..15e3e5405 --- /dev/null +++ b/example/c-vt-render/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_render", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-render/build.zig.zon b/example/c-vt-render/build.zig.zon new file mode 100644 index 000000000..3919970f9 --- /dev/null +++ b/example/c-vt-render/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_render, + .version = "0.0.0", + .fingerprint = 0xb10e18b2fab773c9, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-render/src/main.c b/example/c-vt-render/src/main.c new file mode 100644 index 000000000..b5c22edf5 --- /dev/null +++ b/example/c-vt-render/src/main.c @@ -0,0 +1,45 @@ +#include +#include +#include +#include + +//! [render-state-update] +int main(void) { + GhosttyResult result; + + GhosttyTerminal terminal = NULL; + GhosttyTerminalOptions terminal_opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 10000, + }; + result = ghostty_terminal_new(NULL, &terminal, terminal_opts); + assert(result == GHOSTTY_SUCCESS); + + GhosttyRenderState render_state = NULL; + result = ghostty_render_state_new(NULL, &render_state); + assert(result == GHOSTTY_SUCCESS); + + const char* first_frame = "first frame\r\n"; + ghostty_terminal_vt_write( + terminal, + (const uint8_t*)first_frame, + strlen(first_frame)); + result = ghostty_render_state_update(render_state, terminal); + assert(result == GHOSTTY_SUCCESS); + + const char* second_frame = "second frame\r\n"; + ghostty_terminal_vt_write( + terminal, + (const uint8_t*)second_frame, + strlen(second_frame)); + result = ghostty_render_state_update(render_state, terminal); + assert(result == GHOSTTY_SUCCESS); + + printf("Render state was updated successfully.\n"); + + ghostty_render_state_free(render_state); + ghostty_terminal_free(terminal); + return 0; +} +//! [render-state-update] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index a3d0ec57d..912d6e217 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -29,6 +29,7 @@ * * The API is organized into the following groups: * - @ref terminal "Terminal" - Complete terminal emulator state and rendering + * - @ref render "Render State" - Incremental render state updates for custom renderers * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences @@ -101,6 +102,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/render.h b/include/ghostty/vt/render.h new file mode 100644 index 000000000..898994a65 --- /dev/null +++ b/include/ghostty/vt/render.h @@ -0,0 +1,100 @@ +/** + * @file render.h + * + * Render state for creating high performance renderers. + */ + +#ifndef GHOSTTY_VT_RENDER_H +#define GHOSTTY_VT_RENDER_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup render Render State + * + * Represents the state required to render a visible screen (a viewport) + * of a terminal instance. This is stateful and optimized for repeated + * updates from a single terminal instance and only updating dirty regions + * of the screen. + * + * The key design principle of this API is that it only needs read/write + * access to the terminal instance during the update call. This allows + * the render state to minimally impact terminal IO performance and also + * allows the renderer to be safely multi-threaded (as long as a lock is + * held during the update call to ensure exclusive access to the terminal + * instance). + * + * The basic usage of this API is: + * + * 1. Create an empty render state + * 2. Update it from a terminal instance whenever you need. + * 3. Read from the render state to get the data needed to draw your frame. + * + * ## Example + * + * @snippet c-vt-render/src/main.c render-state-update + * + * @{ + */ + +/** + * Opaque handle to a render state instance. + * + * @ingroup render + */ +typedef struct GhosttyRenderState* GhosttyRenderState; + +/** + * Create a new render state instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param state Pointer to store the created render state handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_new(const GhosttyAllocator* allocator, + GhosttyRenderState* state); + +/** + * Update a render state instance from a terminal. + * + * This consumes terminal/screen dirty state in the same way as the internal + * render state update path. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires + * allocation and that allocation fails + * + * @ingroup render + */ +GhosttyResult ghostty_render_state_update(GhosttyRenderState state, + GhosttyTerminal terminal); + +/** + * Free a render state instance. + * + * Releases all resources associated with the render state. After this call, + * the render state handle becomes invalid. + * + * @param state The render state handle to free (may be NULL) + * + * @ingroup render + */ +void ghostty_render_state_free(GhosttyRenderState state); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_RENDER_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig index d6cfe49ea..8abad5fcf 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -187,6 +187,9 @@ comptime { @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); + @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); + @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); + @export(&c.render_state_free, .{ .name = "ghostty_render_state_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/main.zig b/src/terminal/c/main.zig index 8964610df..f909260a8 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -4,6 +4,7 @@ pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); +pub const render = @import("render.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); pub const mouse_event = @import("mouse_event.zig"); @@ -35,6 +36,10 @@ pub const formatter_format_buf = formatter.format_buf; pub const formatter_format_alloc = formatter.format_alloc; pub const formatter_free = formatter.free; +pub const render_state_new = render.new; +pub const render_state_free = render.free; +pub const render_state_update = render.update; + pub const sgr_new = sgr.new; pub const sgr_free = sgr.free; pub const sgr_reset = sgr.reset; @@ -126,6 +131,7 @@ test { _ = formatter; _ = modes; _ = osc; + _ = render; _ = key_event; _ = key_encode; _ = mouse_event; diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig new file mode 100644 index 000000000..76d884032 --- /dev/null +++ b/src/terminal/c/render.zig @@ -0,0 +1,107 @@ +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 renderpkg = @import("../render.zig"); +const Result = @import("result.zig").Result; + +const RenderStateWrapper = struct { + alloc: std.mem.Allocator, + state: renderpkg.RenderState = .empty, +}; + +/// C: GhosttyRenderState +pub const RenderState = ?*RenderStateWrapper; + +pub fn new( + alloc_: ?*const CAllocator, + result: *RenderState, +) callconv(.c) Result { + result.* = new_(alloc_) catch |err| { + result.* = null; + return switch (err) { + error.OutOfMemory => .out_of_memory, + }; + }; + + return .success; +} + +fn new_(alloc_: ?*const CAllocator) error{OutOfMemory}!*RenderStateWrapper { + const alloc = lib_alloc.default(alloc_); + const ptr = alloc.create(RenderStateWrapper) catch + return error.OutOfMemory; + ptr.* = .{ .alloc = alloc }; + return ptr; +} + +pub fn update( + state_: RenderState, + terminal_: terminal_c.Terminal, +) callconv(.c) Result { + const state = state_ orelse return .invalid_value; + const t = terminal_ orelse return .invalid_value; + + state.state.update(state.alloc, t) catch return .out_of_memory; + return .success; +} + +pub fn free(state_: RenderState) callconv(.c) void { + const state = state_ orelse return; + const alloc = state.alloc; + state.state.deinit(alloc); + alloc.destroy(state); +} + +test "render: new/free" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + try testing.expect(state != null); + free(state); +} + +test "render: free null" { + free(null); +} + +test "render: update invalid value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, update(null, null)); + try testing.expectEqual(Result.invalid_value, update(state, null)); +} + +test "render: update" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib_alloc.test_allocator, + &terminal, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + terminal_c.vt_write(terminal, "hello", 5); + try testing.expectEqual(Result.success, update(state, terminal)); +}