These docs describe the main branch, including unreleased changes. Read the 0.6.0 docs.

C

The C interface exposes native resources, scene nodes, and ordered output through opentui.h. This guide builds a box with a text label, renders Hello, changes the label to Ready, and renders again. The program writes output to files, so you can run it without changing your terminal’s state.

It links the native library and needs no JavaScript runtime. The C ABI reference lists every declaration in opentui.h with the release that added it.

Build the library#

Use Zig 0.16.0 and Bun for the repository’s build scripts. From the repository root:

bun install
cd packages/native
bun run build

The build puts the header and libraries in lib/<target>/ inside the native workspace. On Linux x86_64 with glibc, that directory is lib/x86_64-linux/. The C commands below use that target.

The C application binary interface (ABI) is experimental and currently uses OT_CONTEXT_ABI_VERSION == 1. Use the header, library, and bindings from the same revision. The version number can stay the same between development revisions that are not compatible. The native workspace is private. It is not an npm package that you can install.

Create the scene#

The five C blocks in this section form one complete program. Add them to native-hello.c in the native workspace directory, in the order shown.

Create the owner and output session#

The scene needs memory for its nodes and a queue for its output. A Context owns native resources. A Session owns one output queue and, after attachment, its renderer. Attaching a renderer gives the Session a 12-by-3 cell area. It does not set terminal modes or write output.

The RECORD macro fills in the size and ABI version that every versioned record needs. CHECK reports a native error and jumps to cleanup. Both macros are helpers in this example, not library APIs.

#include "opentui.h"
#include <inttypes.h>
#include <stdio.h>

#define RECORD(type) .struct_size = sizeof(type), .abi_version = OT_CONTEXT_ABI_VERSION
#define CHECK(call) do { if (!check(#call, (call))) goto cleanup; } while (0)

static int check(const char *operation, ot_status status) {
    if (status == OT_OK) return 1;
    fprintf(stderr, "%s returned %" PRId32 "\n", operation, status);
    return 0;
}

static int write_frame(ot_context *context, const ot_handle *session, const char *path);

int main(void) {
    int result = 1;
    ot_context *context = NULL;
    ot_handle session = {0}, root = {0}, box = {0}, label = {0};
    const ot_context_options owner = {
        RECORD(ot_context_options), .object_capacity = 8, .render_cells_max = 36,
    };
    const ot_session_options output = {
        RECORD(ot_session_options), .chunk_size = 4096, .span_capacity = 2, .max_bytes = 8192,
    };
    const ot_session_renderer_options renderer = {
        RECORD(ot_session_renderer_options), .width = 12, .height = 3, .remote = 1,
    };

    CHECK(ot_context_create(&owner, &context));
    CHECK(ot_session_create(context, &output, &session));
    CHECK(ot_session_attach_renderer(context, &session, &renderer));

The Context starts with eight native object slots and allows at most 36 cells in each drawing buffer or renderer. The Session allocates two 4,096-byte output chunks. These limits suit this small example. They are not a total memory budget. remote = 1 stops the renderer from inheriting local terminal environment settings.

Add the box and label#

Create the root first. This creates the Session’s scene. Creating another node kind first returns OT_INVALID_PHASE. Other nodes start detached. ot_scene_move_node attaches a node to a parent at the given child index.

    CHECK(ot_scene_create_node(context, &session, OT_SCENE_ROOT, 1, &root));
    CHECK(ot_scene_create_node(context, &session, OT_SCENE_BOX, 2, &box));
    CHECK(ot_scene_create_node(context, &session, OT_SCENE_TEXT, 3, &label));

    const ot_scene_paint_options paint = {
        RECORD(ot_scene_paint_options),
        .opacity = 1, .border_sides = 15, .border_style = 0,
        .border_color = {255, 255, 255, 255},
    };
    CHECK(ot_scene_set_paint(context, &box, &paint));
    CHECK(ot_scene_set_text(context, &label, (const uint8_t *)"Hello", 5));
    CHECK(ot_scene_move_node(context, &box, &root, 0));
    CHECK(ot_scene_move_node(context, &label, &box, 0));

The node kind selects its native drawing behavior. OT_SCENE_BOX draws a box, and OT_SCENE_TEXT draws and measures text. The numbers 1, 2, and 3 are public node numbers that the application chooses. Hook requests and hit results use them. A public node number must not be zero. The returned ot_handle values identify the native objects in later calls.

border_sides = 15 selects all four sides: left 1, bottom 2, right 4, and top 8. Border style 0 is single-line. The border also reserves one layout cell on each side. Default column layout and stretching place the label inside the full-width box.

The paint record replaces all paint properties of the node. It is not a patch, so set opacity = 1 for an opaque node. Text setters copy UTF-8 bytes. The final 5 is the byte length of Hello, without a terminating NUL.

Render, change, and render again#

write_frame below paints the scene and delivers its output to a file. It completes all output before it returns, so the next call can render another frame.

    if (!write_frame(context, &session, "hello.ansi")) goto cleanup;
    CHECK(ot_scene_set_text(context, &label, (const uint8_t *)"Ready", 5));
    if (!write_frame(context, &session, "ready.ansi")) goto cleanup;
    CHECK(ot_session_close(context, &session));
    result = 0;

cleanup:
    if (result != 0 && session.context_id != 0) {
        check("ot_session_cancel", ot_session_cancel(context, &session));
    }
    if (context != NULL && !check("ot_context_destroy", ot_context_destroy(context))) result = 1;
    return result;
}

The second frame uses the same root, box, and label. ot_scene_set_text replaces the label’s stored content. It does not draw or send output by itself.

Context destruction releases the remaining nodes and the Session. If an operation fails, ot_session_cancel discards pending output before destruction. This program never sets terminal modes, so it has no terminal restoration to deliver.

Paint and encode the frame#

ot_scene_paint calculates layout and paints the scene into the Session’s next cell buffer. It returns a DONE request that names the painted draft. Native code keeps the draft until you commit or cancel it. ot_scene_frame_commit consumes that draft, encodes its cells, and adds the output to the Session queue. The 0 force argument selects normal diff output, not a full repaint.

static ot_status submit_frame(ot_context *context, const ot_handle *session, uint32_t *outcome) {
    const uint16_t background[4] = {0, 0, 0, 255};
    ot_scene_frame_request frame = {0};
    frame.struct_size = sizeof(frame);
    frame.abi_version = OT_CONTEXT_ABI_VERSION;
    ot_status status = ot_scene_paint(context, session, background, 0, 0, &frame);
    if (status != OT_OK) return status;
    return ot_scene_frame_commit(context, session, &frame, 0, outcome);
}

ot_scene_paint works only when no node has an update, resize, layout-changed, or paint hook. Otherwise it returns OT_UNSUPPORTED_RESOURCE. The host-drawing extension below uses frame steps, so the program can draw between native calls.

Deliver the queued bytes#

You can deliver output directly with ot_session_drain_stdout, or with a native writer that you pass to ot_session_drain_output. These operations record completion themselves and need no host byte buffer. The example below uses the copy route, which also works with asynchronous host streams. ot_session_read_output copies bytes into your buffer and returns an output ticket for that copy. Call ot_session_complete_output with success only after those bytes reach the destination. Here, fwrite and fflush deliver them to a file. A terminal or network writer can take their place.

static int write_frame(ot_context *context, const ot_handle *session, const char *path) {
    FILE *file = fopen(path, "wb");
    if (file == NULL) { perror(path); return 0; }
    int result = 0;
    uint32_t outcome = 0;
    CHECK(submit_frame(context, session, &outcome));
    if (outcome != OT_RENDER_PENDING && outcome != OT_RENDER_PRESENTED) {
        fprintf(stderr, "frame not accepted: %" PRIu32 "\n", outcome);
        goto cleanup;
    }

    for (;;) {
        uint8_t bytes[512];
        ot_output_ticket ticket = {0};
        CHECK(ot_session_read_output(context, session, bytes, sizeof(bytes), &ticket));
        if (ticket.byte_count == 0) break;
        int delivered = fwrite(bytes, 1, ticket.byte_count, file) == ticket.byte_count
            && fflush(file) == 0;
        CHECK(ot_session_complete_output(context, session, &ticket, delivered));
        if (!delivered) { perror(path); goto cleanup; }
    }
    result = 1;

cleanup:
    if (fclose(file) != 0) { perror(path); result = 0; }
    return result;
}

OT_RENDER_PENDING means that the frame’s output still needs completion. OT_RENDER_PRESENTED means that its output is already complete. The example treats skipped or failed outcomes as errors, so it never loses an update without a message. The loop drains the finite queue that these frames produce. It does not wait for input or run an application event loop.

Compile and run#

From the native workspace directory on Linux x86_64 glibc:

cc -std=c11 -Wall -Wextra -Werror -Ilib/x86_64-linux native-hello.c \
  -Llib/x86_64-linux -Wl,-rpath,"$PWD/lib/x86_64-linux" -lopentui -o native-hello
./native-hello

The program creates or replaces hello.ansi and ready.ansi in the current directory. They contain terminal instructions, not plain-text screenshots. Written in order to a 12-by-3 terminal, they produce the two screens in Native rendering. The second file updates the first frame. It is not a standalone screen. Use View both frames to display them in a temporary terminal screen.

Add host drawing#

The program can draw at a specific position in the frame while native code still walks the scene. Add an after-paint hook to the label, then record a mark that native code draws after the label’s text:

In main, add this block after mounting the label and before the first write_frame call:

    const ot_scene_hooks hooks = {
        RECORD(ot_scene_hooks), .flags = OT_SCENE_HOOK_RENDER_AFTER, .generation = 1,
    };
    CHECK(ot_scene_set_hooks(context, &label, &hooks));

This sets hook flags on the label. It does not install a function pointer. The generation must be above zero. Each later ot_scene_set_hooks call for the label must use a larger generation, or it returns OT_STALE_FRAME.

Replace submit_frame with this function. Keep write_frame and the cleanup unchanged:

struct mark_recording {
    ot_scene_record_slot slot;
    ot_scene_record_draw draw;
    ot_buffer_draw_text_record text;
    uint8_t bytes[4];
};

static ot_status submit_frame(ot_context *context, const ot_handle *session, uint32_t *outcome) {
    const ot_scene_frame_options options = {
        RECORD(ot_scene_frame_options), .background = {0, 0, 0, 255},
        .max_layout_rounds = 8, .max_host_requests = 64,
    };
    ot_scene_frame_request request = {RECORD(ot_scene_frame_request)};
    ot_scene_frame_geometry geometry = {RECORD(ot_scene_frame_geometry)};
    ot_status status = ot_scene_frame_step_with_geometry(context, session, NULL, &options,
        UINT32_MAX, NULL, 0, &request, &geometry);
    if (status != OT_OK) return status;
    if (request.kind != OT_SCENE_FRAME_RECORD) return OT_UNSUPPORTED_RESOURCE;

    ot_scene_paint_slot slot;
    uint32_t slot_count = 0;
    status = ot_scene_frame_get_paint_slots(context, session, &request, &slot, 1, &slot_count);
    if (status != OT_OK) return status;
    if (slot_count != 1 || slot.num != 3) return OT_UNSUPPORTED_RESOURCE;

    const struct mark_recording recording = {
        .slot = {
            .header = {.size = sizeof(ot_scene_record_slot), .operation = OT_SCENE_RECORD_SLOT},
            .slot = 0, .phase = OT_SCENE_RECORD_PHASE_AFTER,
        },
        .draw = {
            .header = {
                .size = sizeof(struct mark_recording) - sizeof(ot_scene_record_slot),
                .operation = OT_SCENE_RECORD_DRAW,
            },
            .text_length = 1,
        },
        .text = {
            .header = { RECORD(ot_buffer_draw_text_record), .operation = OT_BUFFER_DRAW_TEXT },
            .x = 10, .y = 1, .foreground = {255, 255, 255, 255},
        },
        .bytes = "!",
    };
    status = ot_scene_frame_step_with_geometry(context, session, &request, &options,
        UINT32_MAX, (const uint8_t *)&recording, sizeof recording, &request, &geometry);
    if (status != OT_OK) return status;
    return ot_scene_frame_commit(context, session, &request, 0, outcome);
}

The first call prepares the frame. The label has a paint hook, so the call does not paint. It returns a RECORD request (kind OT_SCENE_FRAME_RECORD) instead. ot_scene_frame_get_paint_slots lists the nodes to record, in paint order. Here the one slot is the label, number 3. The second call passes back that exact request with the recording. Native code then paints the whole frame and returns a DONE request for commit. The previous and out_request arguments can point to the same record, as above.

The recording has two records: a SLOT record for the label’s after phase, then a DRAW record. The SLOT record names the slot by its index in the slot list, 0, not by its node number. The DRAW record contains an ot_buffer_draw_text_record followed by its text bytes. Each record’s size counts the whole record, rounded up to a multiple of 8. The text is one byte, so the record ends with three zero padding bytes.

max_layout_rounds = 8 and max_host_requests = 64 are limits that this example chooses. The C interface has no defaults for them. Zero returns OT_INVALID_ARGUMENT. The UINT32_MAX work quota (max_work_items) selects synchronous preparation from the start.

Recorded commands use cell coordinates in the Session’s next buffer and draw inside the slot’s clip. The coordinates here are fixed for this 12-by-3 area. A dynamic layout can use the slot’s paint geometry. Any error returns to main’s Session cancellation path. The frame contract covers slots, recordings, and paint order.

Recompile and run with the same commands as before. The second output file now updates the screen to:

Initialize records and check results#

The C interface checks records, handles, resource kinds, and call phases. An OT_OK return means that the call succeeded. It does not mean that all later work is complete. Rendering and pumping also return outcomes that tell the host what work remains.

For each versioned record, set its exact struct_size and abi_version. Set unused fields and reserved storage to zero. Plain values such as ot_handle and ot_output_ticket do not have those fields. Use the declarations in opentui.h, not packed copies of them.

Status What to do
OT_INVALID_ARGUMENT, OT_UNSUPPORTED_VERSION Correct the input or use matching artifacts
OT_WRONG_CONTEXT, OT_WRONG_SESSION, OT_WRONG_KIND, OT_STALE_HANDLE Check the object’s owner, kind, and lifetime
OT_WRONG_THREAD Call from the Context’s creating thread
OT_CONTEXT_BUSY, OT_FRAME_BUSY Finish conflicting work or release the relevant borrow
OT_OUTPUT_BACKPRESSURE Deliver pending output before retrying an admissible write
OT_STALE_FRAME, OT_STALE_OUTPUT, OT_STALE_LEASE Stop using the old ticket or lease. A stale lease still needs release

This is not the full status list. Each operation’s contract in opentui.h defines what a failure leaves behind. For example, a failed ot_session_write accepts no bytes, but ot_scene_flush can keep a prefix of its changes without rollback. Do not assume that every failure leaves output fields or drawing cells unchanged.

Text setters take UTF-8 byte counts. In ot_scene_text_info, byte_count counts bytes, but text_length counts display columns. Layout and hit coordinates count terminal cells. Most copy queries do not add a terminating NUL. Read each query’s contract before you allocate its output storage.

Build for another target#

The library build defaults to the host CPU architecture and operating system. On Linux, it selects glibc even when the host uses musl. For a musl consumer, select a musl library target. -Dlibrary-target=<target> builds one Zig target, such as x86_64-linux-musl. The native workspace’s build.zig lists the supported targets and their lib/ directory names. Another target string builds into a lib/ directory with that name. -Dall builds every supported target and needs the build dependencies of each. For example, the macOS targets need a macOS SDK.

Platform Shared library Static library
Linux libopentui.so libopentui.a
macOS libopentui.dylib libopentui.a
Windows opentui.dll, with opentui.lib for imports opentui-static.lib

Static consumers also need the platform and C++ runtime libraries.

To run the C acceptance test against the host’s shared and static libraries, run this from the native workspace:

zig build test-abi --summary all

To check C layouts on every supported target, run bun run check:abi --all-targets from the core package. These layout checks do not test runtime or terminal behavior on those targets.

You now have a program that changes a native scene and completes its output. Resources and ownership explains how to add independently owned text or drawing buffers. Frames and output defines frame requests, output completion, and terminal lifecycle work.

Changes0.6.0
0.6.0
native: The legacy native exports, such as createRenderer and bufferDrawText, are removed. Use the ot_* functions in opentui.h. In Zig, the global grapheme and link pools are removed, so OptimizedBuffer.init() and CliRenderer.create() require a link_pool. GraphemePool.acquire() and LinkPool.acquire() replace alloc(). They return an ID that holds one reference, which decref() releases. See C and Zig. (#1479)
Added NativeError, NativeStatus, OT_BUFFER_DRAW_TEXT, OT_CONTEXT_ABI_VERSION, OT_CONTEXT_BUSY, OT_FRAME_BUSY and 27 more.