From c0e8b09ba45af47722f0b6f211e18221b51493f2 Mon Sep 17 00:00:00 2001 From: Uzair Aftab Date: Fri, 14 Aug 2026 15:24:02 +0200 Subject: [PATCH] build: export a C library Expose the terminal input parser over a C ABI, modeled on ghostty's libghostty-vt. Events are opaque parser-owned handles read through accessor functions so payloads can grow without breaking the ABI. zig build lib produces static and shared libraries and installs include/vaxis.h. --- README.md | 35 +++ build.zig | 72 ++++++ build.zig.zon | 1 + examples/c/parse.c | 116 +++++++++ include/vaxis.h | 360 ++++++++++++++++++++++++++ src/c_api.zig | 619 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1203 insertions(+) create mode 100644 examples/c/parse.c create mode 100644 include/vaxis.h create mode 100644 src/c_api.zig diff --git a/README.md b/README.md index 24910b1..225492f 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,41 @@ pub fn main(init: std.process.Init) !void { } ``` +## C library + +libvaxis can be built as a C library exposing the terminal input parser: +feed it bytes read from a tty and it produces key, mouse, paste, focus, and +terminal-capability events. + +```sh +zig build lib +``` + +This installs `libvaxis.a`, `libvaxis.so`, and the `vaxis.h` header into +`zig-out`. The full interface is documented in +[include/vaxis.h](include/vaxis.h); a complete example lives at +[examples/c/parse.c](examples/c/parse.c). + +Events are opaque handles read through accessor functions, so event +payloads can grow without breaking the ABI. Event data is owned by the +parser and valid until the next parse call; nothing is freed by the caller. + +```c +#include + +vaxis_parser *parser = vaxis_parser_new(); + +const vaxis_event *event; +size_t consumed; +// bytes read from the tty +const uint8_t input[] = "\x1b[97:65;2u"; +vaxis_parser_parse(parser, input, sizeof(input) - 1, &event, &consumed); +// vaxis_event_get_type(event) == VAXIS_EVENT_KEY_PRESS +// vaxis_event_key_matches(event, 'a', VAXIS_MOD_SHIFT) == true + +vaxis_parser_free(parser); +``` + ## Contributing Contributions are welcome. Please submit a PR on Github, diff --git a/build.zig b/build.zig index f2c2a7c..e118cdc 100644 --- a/build.zig +++ b/build.zig @@ -1,5 +1,16 @@ const std = @import("std"); +/// The package version, single-sourced from build.zig.zon. +const version_string = blk: { + const zon = @embedFile("build.zig.zon"); + const marker = ".version = \""; + const start = (std.mem.indexOf(u8, zon, marker) orelse + @compileError("no version in build.zig.zon")) + marker.len; + const end = std.mem.indexOfScalarPos(u8, zon, start, '"') orelse + @compileError("unterminated version in build.zig.zon"); + break :blk zon[start..end]; +}; + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -42,6 +53,41 @@ pub fn build(b: *std.Build) void { return; } + // Exposes the terminal input parser over a C ABI (see include/vaxis.h), + const c_api_options = b.addOptions(); + c_api_options.addOption([]const u8, "version", version_string); + const c_api_mod = b.createModule(.{ + .root_source_file = b.path("src/c_api.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .imports = &.{ + .{ .name = "vaxis", .module = vaxis_mod }, + .{ .name = "build_options", .module = c_api_options.createModule() }, + }, + }); + // For the @cImport-based layout test in src/c_api.zig + c_api_mod.addIncludePath(b.path("include")); + + const lib_step = b.step("lib", "Build the C library (static and shared)"); + const static_lib = b.addLibrary(.{ + // the DLL import library is also named vaxis.lib on Windows + .name = if (target.result.os.tag == .windows) "vaxis-static" else "vaxis", + .linkage = .static, + .root_module = c_api_mod, + .use_llvm = use_llvm, + }); + static_lib.installHeadersDirectory(b.path("include"), "", .{}); + const shared_lib = b.addLibrary(.{ + .name = "vaxis", + .linkage = .dynamic, + .root_module = c_api_mod, + .use_llvm = use_llvm, + .version = std.SemanticVersion.parse(version_string) catch unreachable, + }); + lib_step.dependOn(&b.addInstallArtifact(static_lib, .{}).step); + lib_step.dependOn(&b.addInstallArtifact(shared_lib, .{}).step); + // Examples const Example = enum { cli, @@ -141,6 +187,32 @@ pub fn build(b: *std.Build) void { b.installArtifact(tests); tests_step.dependOn(&tests_run.step); + // C API tests: Zig unit tests plus a C program linked against the + // static library + const c_api_tests = b.addTest(.{ + .use_llvm = use_llvm, + .root_module = c_api_mod, + }); + tests_step.dependOn(&b.addRunArtifact(c_api_tests).step); + + const c_test_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .link_libc = true, + }); + c_test_mod.addCSourceFile(.{ + .file = b.path("examples/c/parse.c"), + .flags = &.{"-std=c99"}, + }); + c_test_mod.addIncludePath(b.path("include")); + c_test_mod.linkLibrary(static_lib); + const c_test = b.addExecutable(.{ + .name = "example-c-parse", + .root_module = c_test_mod, + .use_llvm = use_llvm, + }); + tests_step.dependOn(&b.addRunArtifact(c_test).step); + // Docs const docs_step = b.step("docs", "Build the vaxis library docs"); const docs_obj = b.addObject(.{ diff --git a/build.zig.zon b/build.zig.zon index e4ce169..ec391ed 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -18,6 +18,7 @@ "LICENSE", "build.zig", "build.zig.zon", + "include", "src", }, } diff --git a/examples/c/parse.c b/examples/c/parse.c new file mode 100644 index 0000000..090f042 --- /dev/null +++ b/examples/c/parse.c @@ -0,0 +1,116 @@ +/* Example for the libvaxis C API. + * + * Built and run as part of `zig build test`. Feeds a handful of terminal + * escape sequences through the parser and checks the resulting events. + */ +#include +#include + +#include + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAILED: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +static vaxis_result parse(vaxis_parser *parser, const char *input, + const vaxis_event **event, size_t *consumed) { + return vaxis_parser_parse(parser, (const uint8_t *)input, strlen(input), + event, consumed); +} + +int main(void) { + vaxis_parser *parser = vaxis_parser_new(); + CHECK(parser != NULL); + + const vaxis_event *event = NULL; + size_t n = 0; + + /* Plain text keypress */ + CHECK(parse(parser, "a", &event, &n) == VAXIS_OK); + CHECK(n == 1); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_KEY_PRESS); + CHECK(vaxis_event_key_codepoint(event) == 'a'); + vaxis_string text = vaxis_event_key_text(event); + CHECK(text.len == 1); + CHECK(memcmp(text.ptr, "a", 1) == 0); + CHECK(vaxis_event_key_matches(event, 'a', 0)); + + /* Arrow key */ + CHECK(parse(parser, "\x1b[A", &event, &n) == VAXIS_OK); + CHECK(n == 3); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_KEY_PRESS); + CHECK(vaxis_event_key_codepoint(event) == VAXIS_KEY_UP); + + /* Kitty keyboard: shift+a with alternate codepoint reporting */ + CHECK(parse(parser, "\x1b[97:65;2u", &event, &n) == VAXIS_OK); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_KEY_PRESS); + CHECK(vaxis_event_key_codepoint(event) == 'a'); + CHECK(vaxis_event_key_shifted_codepoint(event) == 'A'); + CHECK((vaxis_event_key_mods(event) & VAXIS_MOD_SHIFT) != 0); + CHECK(vaxis_event_key_matches(event, 'a', VAXIS_MOD_SHIFT)); + + /* SGR mouse motion */ + CHECK(parse(parser, "\x1b[<35;1;1m", &event, &n) == VAXIS_OK); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_MOUSE); + CHECK(vaxis_event_mouse_col(event) == 0); + CHECK(vaxis_event_mouse_row(event) == 0); + CHECK(vaxis_event_mouse_button(event) == VAXIS_MOUSE_NONE); + CHECK(vaxis_event_mouse_type(event) == VAXIS_MOUSE_MOTION); + /* Accessors for other event types return zero values */ + CHECK(vaxis_event_key_codepoint(event) == 0); + CHECK(vaxis_event_key_text(event).ptr == NULL); + + /* In-band window resize */ + CHECK(parse(parser, "\x1b[48;24;80;480;1440t", &event, &n) == VAXIS_OK); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_WINSIZE); + CHECK(vaxis_event_winsize_rows(event) == 24); + CHECK(vaxis_event_winsize_cols(event) == 80); + CHECK(vaxis_event_winsize_x_pixel(event) == 1440); + CHECK(vaxis_event_winsize_y_pixel(event) == 480); + + /* Background color report */ + CHECK(parse(parser, "\x1b]11;rgb:ffff/8080/0000\x1b\\", &event, &n) == + VAXIS_OK); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_COLOR_REPORT); + CHECK(vaxis_event_color_report_kind(event) == VAXIS_COLOR_BG); + vaxis_rgb rgb = vaxis_event_color_report_rgb(event); + CHECK(rgb.r == 0xff && rgb.g == 0x80 && rgb.b == 0x00); + + /* OSC 52 paste; the text is parser-owned, nothing to free */ + CHECK(parse(parser, "\x1b]52;c;b3NjNTIgcGFzdGU=\x1b\\", &event, &n) == + VAXIS_OK); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_PASTE); + vaxis_string paste = vaxis_event_paste_text(event); + CHECK(paste.len == strlen("osc52 paste")); + CHECK(memcmp(paste.ptr, "osc52 paste", paste.len) == 0); + + /* Focus events */ + CHECK(parse(parser, "\x1b[I", &event, &n) == VAXIS_OK); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_FOCUS_IN); + CHECK(parse(parser, "\x1b[O", &event, &n) == VAXIS_OK); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_FOCUS_OUT); + + /* Incomplete sequence: no event, nothing consumed */ + CHECK(parse(parser, "\x1b[", &event, &n) == VAXIS_OK); + CHECK(event == NULL); + CHECK(vaxis_event_get_type(event) == VAXIS_EVENT_NONE); + CHECK(n == 0); + + /* Malformed-but-recognized sequence: consumed with no event, not an + * error */ + CHECK(parse(parser, "\x1b]4;1;rgb:zz/zz/zz\x1b\\", &event, &n) == VAXIS_OK); + CHECK(event == NULL); + CHECK(n == 20); + + /* Key name lookup */ + CHECK(vaxis_key_from_name("enter", 5) == VAXIS_KEY_ENTER); + CHECK(vaxis_key_from_name("nope", 4) == 0); + + vaxis_parser_free(parser); + printf("libvaxis %s: all C API checks passed\n", vaxis_version()); + return 0; +} diff --git a/include/vaxis.h b/include/vaxis.h new file mode 100644 index 0000000..8b42f7a --- /dev/null +++ b/include/vaxis.h @@ -0,0 +1,360 @@ +/* libvaxis C API + * + * A C interface to the vaxis terminal input parser. This exposes the same + * escape-sequence parser used by the Zig library: feed it bytes read from a + * tty and it produces key, mouse, paste, focus, and terminal-capability + * events. + * + * Events are opaque handles read through vaxis_event_* accessors. Event + * data is owned by the parser and valid until the next parse call on that + * parser; the caller never frees anything. Copy what you need longer. + * + * Build with `zig build lib`, which produces libvaxis.a / libvaxis.so and + * installs this header. + */ + +#ifndef VAXIS_H +#define VAXIS_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Force a fixed underlying type on enums where the compiler supports it so + * that enum values have a stable ABI regardless of consumer compiler flags + * (e.g. -fshort-enums). Where it isn't supported, the VAXIS_ENUM_MAX_VALUE + * sentinel member pins the enum to int width. */ +#if defined(__cplusplus) && \ + (__cplusplus >= 201103L || (defined(_MSC_VER) && _MSC_VER >= 1700)) +#define VAXIS_ENUM_TYPED : int +#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +#define VAXIS_ENUM_TYPED : int +#elif defined(__clang__) +#if __has_extension(c_fixed_enum) +#define VAXIS_ENUM_TYPED : int +#else +#define VAXIS_ENUM_TYPED +#endif +#elif defined(__GNUC__) && __GNUC__ >= 13 +#define VAXIS_ENUM_TYPED : int +#else +#define VAXIS_ENUM_TYPED +#endif +#define VAXIS_ENUM_MAX_VALUE INT_MAX + +/* Return codes. Zero is success; errors are negative. */ +typedef enum VAXIS_ENUM_TYPED { + VAXIS_OK = 0, + VAXIS_ERR_INVALID = -1, /* invalid argument (e.g. NULL pointer) */ + VAXIS_ERR_OOM = -2, /* out of memory */ + /* Reserved: the current parser substitutes invalid UTF-8 rather than + * reporting it, so this code is not produced today. */ + VAXIS_ERR_INVALID_UTF8 = -3, +} vaxis_result; + +/* Event types */ +typedef enum VAXIS_ENUM_TYPED { + VAXIS_EVENT_NONE = 0, + VAXIS_EVENT_KEY_PRESS = 1, + VAXIS_EVENT_KEY_RELEASE = 2, + VAXIS_EVENT_MOUSE = 3, + VAXIS_EVENT_MOUSE_LEAVE = 4, + VAXIS_EVENT_FOCUS_IN = 5, + VAXIS_EVENT_FOCUS_OUT = 6, + VAXIS_EVENT_PASTE_START = 7, /* bracketed paste start */ + VAXIS_EVENT_PASTE_END = 8, /* bracketed paste end */ + VAXIS_EVENT_PASTE = 9, /* OSC 52 paste */ + VAXIS_EVENT_COLOR_REPORT = 10, /* OSC 4/10/11/12 response */ + VAXIS_EVENT_COLOR_SCHEME = 11, /* light/dark scheme report */ + VAXIS_EVENT_WINSIZE = 12, /* in-band window resize */ + + /* Discovered terminal capabilities */ + VAXIS_EVENT_CAP_KITTY_KEYBOARD = 13, + VAXIS_EVENT_CAP_KITTY_GRAPHICS = 14, + VAXIS_EVENT_CAP_RGB = 15, + VAXIS_EVENT_CAP_SGR_PIXELS = 16, + VAXIS_EVENT_CAP_UNICODE = 17, + VAXIS_EVENT_CAP_DA1 = 18, + VAXIS_EVENT_CAP_COLOR_SCHEME_UPDATES = 19, + VAXIS_EVENT_CAP_MULTI_CURSOR = 20, + + /* Sentinel to pin the enum to int width; never produced. */ + VAXIS_EVENT_TYPE_MAX_VALUE = VAXIS_ENUM_MAX_VALUE, +} vaxis_event_type; + +/* Key modifier bits (vaxis_event_key_mods) */ +#define VAXIS_MOD_SHIFT (1 << 0) +#define VAXIS_MOD_ALT (1 << 1) +#define VAXIS_MOD_CTRL (1 << 2) +#define VAXIS_MOD_SUPER (1 << 3) +#define VAXIS_MOD_HYPER (1 << 4) +#define VAXIS_MOD_META (1 << 5) +#define VAXIS_MOD_CAPS_LOCK (1 << 6) +#define VAXIS_MOD_NUM_LOCK (1 << 7) + +/* Mouse buttons (vaxis_event_mouse_button) */ +#define VAXIS_MOUSE_LEFT 0 +#define VAXIS_MOUSE_MIDDLE 1 +#define VAXIS_MOUSE_RIGHT 2 +#define VAXIS_MOUSE_NONE 3 +#define VAXIS_MOUSE_WHEEL_UP 64 +#define VAXIS_MOUSE_WHEEL_DOWN 65 +#define VAXIS_MOUSE_WHEEL_RIGHT 66 +#define VAXIS_MOUSE_WHEEL_LEFT 67 +#define VAXIS_MOUSE_BUTTON_8 128 +#define VAXIS_MOUSE_BUTTON_9 129 +#define VAXIS_MOUSE_BUTTON_10 130 +#define VAXIS_MOUSE_BUTTON_11 131 + +/* Mouse event types (vaxis_event_mouse_type) */ +#define VAXIS_MOUSE_PRESS 0 +#define VAXIS_MOUSE_RELEASE 1 +#define VAXIS_MOUSE_MOTION 2 +#define VAXIS_MOUSE_DRAG 3 + +/* Mouse modifier bits (vaxis_event_mouse_mods) */ +#define VAXIS_MOUSE_MOD_SHIFT (1 << 0) +#define VAXIS_MOUSE_MOD_ALT (1 << 1) +#define VAXIS_MOUSE_MOD_CTRL (1 << 2) + +/* Color report kinds (vaxis_event_color_report_kind) */ +#define VAXIS_COLOR_FG 0 +#define VAXIS_COLOR_BG 1 +#define VAXIS_COLOR_CURSOR 2 +#define VAXIS_COLOR_INDEX 3 + +/* Color schemes (vaxis_event_color_scheme) */ +#define VAXIS_COLOR_SCHEME_DARK 0 +#define VAXIS_COLOR_SCHEME_LIGHT 1 + +/* A borrowed byte slice. `ptr` is NULL when empty. */ +typedef struct { + const uint8_t *ptr; + size_t len; +} vaxis_string; + +/* An RGB color value. */ +typedef struct { + uint8_t r; + uint8_t g; + uint8_t b; +} vaxis_rgb; + +/* An opaque terminal input parser. Not thread-safe: use one parser per + * input stream. */ +typedef struct vaxis_parser vaxis_parser; + +/* An opaque parsed event, owned by the parser that produced it and valid + * until the next parse call. Read it through the accessors below. */ +typedef struct vaxis_event vaxis_event; + +/* Create a parser. Returns NULL on allocation failure. */ +vaxis_parser *vaxis_parser_new(void); + +/* Destroy a parser created with vaxis_parser_new, along with any event it + * currently owns. NULL is a no-op. */ +void vaxis_parser_free(vaxis_parser *parser); + +/* Parse the first event from `input`. On success returns VAXIS_OK, stores + * the bytes consumed in `consumed`, and stores an event handle in `event`, + * or NULL when no event was produced: + * + * - *event == NULL, *consumed == 0: incomplete sequence; read more bytes + * and retry with the whole buffer. The caller owns accumulation and + * should cap the buffer. + * - *event == NULL, *consumed > 0: an unknown, ignored, or malformed + * sequence; skip those bytes and continue. + * + * A lone ESC byte parses as an escape key press: wait briefly for more + * input before parsing a buffer that ends in ESC. + * + * On error returns a VAXIS_ERR_* code and consumes nothing. */ +vaxis_result vaxis_parser_parse(vaxis_parser *parser, const uint8_t *input, + size_t input_len, const vaxis_event **event, + size_t *consumed); + +/* The type of an event. Accessors are NULL-safe: a NULL event has type + * VAXIS_EVENT_NONE, and accessors return zero values when the event is + * NULL or not of the matching type. */ +vaxis_event_type vaxis_event_get_type(const vaxis_event *event); + +/* Key accessors (VAXIS_EVENT_KEY_PRESS, VAXIS_EVENT_KEY_RELEASE). Key + * text longer than 256 bytes is truncated at a codepoint boundary and is + * always valid UTF-8. Shifted and base-layout codepoints are 0 when not + * present. */ +uint32_t vaxis_event_key_codepoint(const vaxis_event *event); +uint32_t vaxis_event_key_shifted_codepoint(const vaxis_event *event); +uint32_t vaxis_event_key_base_layout_codepoint(const vaxis_event *event); +uint8_t vaxis_event_key_mods(const vaxis_event *event); +vaxis_string vaxis_event_key_text(const vaxis_event *event); + +/* Match a key event against a codepoint + modifiers, using the same loose + * matching as the Zig library. Always false for non-key events. */ +bool vaxis_event_key_matches(const vaxis_event *event, uint32_t codepoint, + uint8_t mods); + +/* Mouse accessors (VAXIS_EVENT_MOUSE). col/row are 0-indexed and may be + * negative for terminals that report out-of-window coordinates. */ +int16_t vaxis_event_mouse_col(const vaxis_event *event); +int16_t vaxis_event_mouse_row(const vaxis_event *event); +uint8_t vaxis_event_mouse_button(const vaxis_event *event); +uint8_t vaxis_event_mouse_mods(const vaxis_event *event); +uint8_t vaxis_event_mouse_type(const vaxis_event *event); + +/* Paste accessor (VAXIS_EVENT_PASTE). */ +vaxis_string vaxis_event_paste_text(const vaxis_event *event); + +/* Color report accessors (VAXIS_EVENT_COLOR_REPORT). `index` is only + * meaningful when the kind is VAXIS_COLOR_INDEX. */ +uint8_t vaxis_event_color_report_kind(const vaxis_event *event); +uint8_t vaxis_event_color_report_index(const vaxis_event *event); +vaxis_rgb vaxis_event_color_report_rgb(const vaxis_event *event); + +/* Color scheme accessor (VAXIS_EVENT_COLOR_SCHEME); a + * VAXIS_COLOR_SCHEME_* value. */ +uint8_t vaxis_event_color_scheme(const vaxis_event *event); + +/* Window size accessors (VAXIS_EVENT_WINSIZE). */ +uint16_t vaxis_event_winsize_rows(const vaxis_event *event); +uint16_t vaxis_event_winsize_cols(const vaxis_event *event); +uint16_t vaxis_event_winsize_x_pixel(const vaxis_event *event); +uint16_t vaxis_event_winsize_y_pixel(const vaxis_event *event); + +/* Look up a key codepoint by name ("enter", "f1", "kp_0", ...). Returns 0 + * for unknown names. */ +uint32_t vaxis_key_from_name(const char *name, size_t name_len); + +/* The libvaxis version as a static string, e.g. "0.6.0". */ +const char *vaxis_version(void); + +/* Key codepoints. Special keys use the Kitty keyboard protocol's private + * use area assignments; a handful are plain ASCII. */ +#define VAXIS_KEY_TAB 0x09 +#define VAXIS_KEY_ENTER 0x0D +#define VAXIS_KEY_ESCAPE 0x1B +#define VAXIS_KEY_SPACE 0x20 +#define VAXIS_KEY_BACKSPACE 0x7F + +/* A key which generated text but cannot be expressed as a single + * codepoint (e.g. a multi-codepoint grapheme). Inspect the key text + * instead. */ +#define VAXIS_KEY_MULTICODEPOINT 1114113 + +#define VAXIS_KEY_INSERT 57348 +#define VAXIS_KEY_DELETE 57349 +#define VAXIS_KEY_LEFT 57350 +#define VAXIS_KEY_RIGHT 57351 +#define VAXIS_KEY_UP 57352 +#define VAXIS_KEY_DOWN 57353 +#define VAXIS_KEY_PAGE_UP 57354 +#define VAXIS_KEY_PAGE_DOWN 57355 +#define VAXIS_KEY_HOME 57356 +#define VAXIS_KEY_END 57357 +#define VAXIS_KEY_CAPS_LOCK 57358 +#define VAXIS_KEY_SCROLL_LOCK 57359 +#define VAXIS_KEY_NUM_LOCK 57360 +#define VAXIS_KEY_PRINT_SCREEN 57361 +#define VAXIS_KEY_PAUSE 57362 +#define VAXIS_KEY_MENU 57363 +#define VAXIS_KEY_F1 57364 +#define VAXIS_KEY_F2 57365 +#define VAXIS_KEY_F3 57366 +#define VAXIS_KEY_F4 57367 +#define VAXIS_KEY_F5 57368 +#define VAXIS_KEY_F6 57369 +#define VAXIS_KEY_F7 57370 +#define VAXIS_KEY_F8 57371 +#define VAXIS_KEY_F9 57372 +#define VAXIS_KEY_F10 57373 +#define VAXIS_KEY_F11 57374 +#define VAXIS_KEY_F12 57375 +#define VAXIS_KEY_F13 57376 +#define VAXIS_KEY_F14 57377 +#define VAXIS_KEY_F15 57378 +#define VAXIS_KEY_F16 57379 +#define VAXIS_KEY_F17 57380 +#define VAXIS_KEY_F18 57381 +#define VAXIS_KEY_F19 57382 +#define VAXIS_KEY_F20 57383 +#define VAXIS_KEY_F21 57384 +#define VAXIS_KEY_F22 57385 +#define VAXIS_KEY_F23 57386 +#define VAXIS_KEY_F24 57387 +#define VAXIS_KEY_F25 57388 +#define VAXIS_KEY_F26 57389 +#define VAXIS_KEY_F27 57390 +#define VAXIS_KEY_F28 57391 +#define VAXIS_KEY_F29 57392 +#define VAXIS_KEY_F30 57393 +#define VAXIS_KEY_F31 57394 +#define VAXIS_KEY_F32 57395 +#define VAXIS_KEY_F33 57396 +#define VAXIS_KEY_F34 57397 +#define VAXIS_KEY_F35 57398 +#define VAXIS_KEY_KP_0 57399 +#define VAXIS_KEY_KP_1 57400 +#define VAXIS_KEY_KP_2 57401 +#define VAXIS_KEY_KP_3 57402 +#define VAXIS_KEY_KP_4 57403 +#define VAXIS_KEY_KP_5 57404 +#define VAXIS_KEY_KP_6 57405 +#define VAXIS_KEY_KP_7 57406 +#define VAXIS_KEY_KP_8 57407 +#define VAXIS_KEY_KP_9 57408 +#define VAXIS_KEY_KP_DECIMAL 57409 +#define VAXIS_KEY_KP_DIVIDE 57410 +#define VAXIS_KEY_KP_MULTIPLY 57411 +#define VAXIS_KEY_KP_SUBTRACT 57412 +#define VAXIS_KEY_KP_ADD 57413 +#define VAXIS_KEY_KP_ENTER 57414 +#define VAXIS_KEY_KP_EQUAL 57415 +#define VAXIS_KEY_KP_SEPARATOR 57416 +#define VAXIS_KEY_KP_LEFT 57417 +#define VAXIS_KEY_KP_RIGHT 57418 +#define VAXIS_KEY_KP_UP 57419 +#define VAXIS_KEY_KP_DOWN 57420 +#define VAXIS_KEY_KP_PAGE_UP 57421 +#define VAXIS_KEY_KP_PAGE_DOWN 57422 +#define VAXIS_KEY_KP_HOME 57423 +#define VAXIS_KEY_KP_END 57424 +#define VAXIS_KEY_KP_INSERT 57425 +#define VAXIS_KEY_KP_DELETE 57426 +#define VAXIS_KEY_KP_BEGIN 57427 +#define VAXIS_KEY_MEDIA_PLAY 57428 +#define VAXIS_KEY_MEDIA_PAUSE 57429 +#define VAXIS_KEY_MEDIA_PLAY_PAUSE 57430 +#define VAXIS_KEY_MEDIA_REVERSE 57431 +#define VAXIS_KEY_MEDIA_STOP 57432 +#define VAXIS_KEY_MEDIA_FAST_FORWARD 57433 +#define VAXIS_KEY_MEDIA_REWIND 57434 +#define VAXIS_KEY_MEDIA_TRACK_NEXT 57435 +#define VAXIS_KEY_MEDIA_TRACK_PREVIOUS 57436 +#define VAXIS_KEY_MEDIA_RECORD 57437 +#define VAXIS_KEY_LOWER_VOLUME 57438 +#define VAXIS_KEY_RAISE_VOLUME 57439 +#define VAXIS_KEY_MUTE_VOLUME 57440 +#define VAXIS_KEY_LEFT_SHIFT 57441 +#define VAXIS_KEY_LEFT_CONTROL 57442 +#define VAXIS_KEY_LEFT_ALT 57443 +#define VAXIS_KEY_LEFT_SUPER 57444 +#define VAXIS_KEY_LEFT_HYPER 57445 +#define VAXIS_KEY_LEFT_META 57446 +#define VAXIS_KEY_RIGHT_SHIFT 57447 +#define VAXIS_KEY_RIGHT_CONTROL 57448 +#define VAXIS_KEY_RIGHT_ALT 57449 +#define VAXIS_KEY_RIGHT_SUPER 57450 +#define VAXIS_KEY_RIGHT_HYPER 57451 +#define VAXIS_KEY_RIGHT_META 57452 +#define VAXIS_KEY_ISO_LEVEL_3_SHIFT 57453 +#define VAXIS_KEY_ISO_LEVEL_5_SHIFT 57454 + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* VAXIS_H */ diff --git a/src/c_api.zig b/src/c_api.zig new file mode 100644 index 0000000..79f63d3 --- /dev/null +++ b/src/c_api.zig @@ -0,0 +1,619 @@ +//! Main file for the C API for libvaxis. +//! The interface is documented in include/vaxis.h +//! Events are opaque handles read through accessor +//! functions so payloads can grow without breaking the ABI. +//! +//! Functions are plain callconv(.c) fns so they can be called from tests; +//! the comptime block below exports each one with a vaxis_ prefix. +const std = @import("std"); +const vaxis = @import("vaxis"); + +const Color = vaxis.Color; +const Key = vaxis.Key; +const Mouse = vaxis.Mouse; +const Parser = vaxis.Parser; +const Winsize = vaxis.Winsize; + +const allocator = std.heap.c_allocator; + +comptime { + // Export every public function as vaxis_, but only when building + // the C library, not when imported as a Zig module. + if (@import("root") == @This()) { + for (@typeInfo(@This()).@"struct".decls) |decl| { + const field = @field(@This(), decl.name); + if (@typeInfo(@TypeOf(field)) == .@"fn") { + @export(&field, .{ .name = "vaxis_" ++ decl.name }); + } + } + } +} + +/// C: vaxis_result +pub const Result = enum(c_int) { + ok = 0, + err_invalid = -1, + err_oom = -2, + err_invalid_utf8 = -3, +}; + +/// C: vaxis_event_type. Values are ABI: append only, never reorder. +pub const EventType = enum(c_int) { + none = 0, + key_press = 1, + key_release = 2, + mouse = 3, + mouse_leave = 4, + focus_in = 5, + focus_out = 6, + paste_start = 7, + paste_end = 8, + paste = 9, + color_report = 10, + color_scheme = 11, + winsize = 12, + cap_kitty_keyboard = 13, + cap_kitty_graphics = 14, + cap_rgb = 15, + cap_sgr_pixels = 16, + cap_unicode = 17, + cap_da1 = 18, + cap_color_scheme_updates = 19, + cap_multi_cursor = 20, +}; + +comptime { + // EventType and vaxis.Event must stay in sync in both directions + for (@typeInfo(vaxis.Event).@"union".fields) |field| { + if (!@hasField(EventType, field.name)) + @compileError("vaxis.Event variant missing from EventType: " ++ field.name); + } + for (@typeInfo(EventType).@"enum".fields) |field| { + if (std.mem.eql(u8, field.name, "none")) continue; + if (!@hasField(vaxis.Event, field.name)) + @compileError("EventType tag is not a vaxis.Event variant: " ++ field.name); + } +} + +/// C: vaxis_string. A borrowed byte slice +pub const CString = extern struct { + ptr: ?[*]const u8, + len: usize, + + const empty: CString = .{ .ptr = null, .len = 0 }; + + fn init(bytes: []const u8) CString { + if (bytes.len == 0) return .empty; + return .{ .ptr = bytes.ptr, .len = bytes.len }; + } +}; + +/// C: vaxis_rgb +pub const CRgb = extern struct { + r: u8, + g: u8, + b: u8, +}; + +/// The storage behind the opaque vaxis_event handle. Payloads are the +/// native vaxis types; accessors convert at the boundary +const CEvent = struct { + type: EventType = .none, + key: Key = .{ .codepoint = 0 }, + mouse: Mouse = .{ .col = 0, .row = 0, .button = .none, .mods = .{}, .type = .press }, + paste: []const u8 = "", + color_report: Color.Report = .{ .kind = .fg, .value = .{ 0, 0, 0 } }, + color_scheme: Color.Scheme = .dark, + winsize: Winsize = .{ .rows = 0, .cols = 0, .x_pixel = 0, .y_pixel = 0 }, +}; + +/// vaxis_parser. Owns the current event and everything it points at +const CParser = struct { + parser: Parser, + event: CEvent, + text_buf: [256]u8, + paste: ?[]const u8, +}; + +pub fn parser_new() callconv(.c) ?*CParser { + const parser = allocator.create(CParser) catch return null; + parser.* = .{ + .parser = .{}, + .event = .{}, + .text_buf = undefined, + .paste = null, + }; + return parser; +} + +pub fn parser_free(parser: ?*CParser) callconv(.c) void { + const p = parser orelse return; + if (p.paste) |paste| allocator.free(paste); + allocator.destroy(p); +} + +pub fn parser_parse( + parser: ?*CParser, + input: ?[*]const u8, + input_len: usize, + event: ?*?*const CEvent, + consumed: ?*usize, +) callconv(.c) Result { + const p = parser orelse return .err_invalid; + const out_event = event orelse return .err_invalid; + const out_consumed = consumed orelse return .err_invalid; + + out_event.* = null; + out_consumed.* = 0; + + // the previous event is only valid until this call + p.event = .{}; + if (p.paste) |paste| { + allocator.free(paste); + p.paste = null; + } + + if (input_len == 0) return .ok; + const in = input orelse return .err_invalid; + + const result = p.parser.parse(in[0..input_len], allocator) catch |err| { + return switch (err) { + error.OutOfMemory => .err_oom, + error.InvalidUTF8 => .err_invalid_utf8, + }; + }; + out_consumed.* = result.n; + if (result.event) |ev| { + convertEvent(p, ev); + out_event.* = &p.event; + } + return .ok; +} + +// Event accessors are NULL-safe and return zero values when the event is +// not of the matching type + +pub fn event_get_type(event: ?*const CEvent) callconv(.c) EventType { + const e = event orelse return .none; + return e.type; +} + +fn keyOf(event: ?*const CEvent) ?*const Key { + const e = event orelse return null; + return switch (e.type) { + .key_press, .key_release => &e.key, + else => null, + }; +} + +pub fn event_key_codepoint(event: ?*const CEvent) callconv(.c) u32 { + const key = keyOf(event) orelse return 0; + return key.codepoint; +} + +pub fn event_key_shifted_codepoint(event: ?*const CEvent) callconv(.c) u32 { + const key = keyOf(event) orelse return 0; + return key.shifted_codepoint orelse 0; +} + +pub fn event_key_base_layout_codepoint(event: ?*const CEvent) callconv(.c) u32 { + const key = keyOf(event) orelse return 0; + return key.base_layout_codepoint orelse 0; +} + +pub fn event_key_mods(event: ?*const CEvent) callconv(.c) u8 { + const key = keyOf(event) orelse return 0; + return @bitCast(key.mods); +} + +pub fn event_key_text(event: ?*const CEvent) callconv(.c) CString { + const key = keyOf(event) orelse return .empty; + return .init(key.text orelse ""); +} + +pub fn event_key_matches(event: ?*const CEvent, codepoint: u32, mods: u8) callconv(.c) bool { + const key = keyOf(event) orelse return false; + if (codepoint > std.math.maxInt(u21)) return false; + return key.matches(@intCast(codepoint), @bitCast(mods)); +} + +fn mouseOf(event: ?*const CEvent) ?*const Mouse { + const e = event orelse return null; + return if (e.type == .mouse) &e.mouse else null; +} + +pub fn event_mouse_col(event: ?*const CEvent) callconv(.c) i16 { + const mouse = mouseOf(event) orelse return 0; + return mouse.col; +} + +pub fn event_mouse_row(event: ?*const CEvent) callconv(.c) i16 { + const mouse = mouseOf(event) orelse return 0; + return mouse.row; +} + +pub fn event_mouse_button(event: ?*const CEvent) callconv(.c) u8 { + const mouse = mouseOf(event) orelse return 0; + return @intFromEnum(mouse.button); +} + +pub fn event_mouse_mods(event: ?*const CEvent) callconv(.c) u8 { + const mouse = mouseOf(event) orelse return 0; + return @as(u3, @bitCast(mouse.mods)); +} + +pub fn event_mouse_type(event: ?*const CEvent) callconv(.c) u8 { + const mouse = mouseOf(event) orelse return 0; + return @intFromEnum(mouse.type); +} + +pub fn event_paste_text(event: ?*const CEvent) callconv(.c) CString { + const e = event orelse return .empty; + if (e.type != .paste) return .empty; + return .init(e.paste); +} + +pub fn event_color_report_kind(event: ?*const CEvent) callconv(.c) u8 { + const e = event orelse return 0; + if (e.type != .color_report) return 0; + return @intFromEnum(std.meta.activeTag(e.color_report.kind)); +} + +pub fn event_color_report_index(event: ?*const CEvent) callconv(.c) u8 { + const e = event orelse return 0; + if (e.type != .color_report) return 0; + return switch (e.color_report.kind) { + .index => |idx| idx, + else => 0, + }; +} + +pub fn event_color_report_rgb(event: ?*const CEvent) callconv(.c) CRgb { + const zero: CRgb = .{ .r = 0, .g = 0, .b = 0 }; + const e = event orelse return zero; + if (e.type != .color_report) return zero; + const value = e.color_report.value; + return .{ .r = value[0], .g = value[1], .b = value[2] }; +} + +pub fn event_color_scheme(event: ?*const CEvent) callconv(.c) u8 { + const e = event orelse return 0; + if (e.type != .color_scheme) return 0; + return @intFromEnum(e.color_scheme); +} + +pub fn event_winsize_rows(event: ?*const CEvent) callconv(.c) u16 { + const e = event orelse return 0; + if (e.type != .winsize) return 0; + return e.winsize.rows; +} + +pub fn event_winsize_cols(event: ?*const CEvent) callconv(.c) u16 { + const e = event orelse return 0; + if (e.type != .winsize) return 0; + return e.winsize.cols; +} + +pub fn event_winsize_x_pixel(event: ?*const CEvent) callconv(.c) u16 { + const e = event orelse return 0; + if (e.type != .winsize) return 0; + return e.winsize.x_pixel; +} + +pub fn event_winsize_y_pixel(event: ?*const CEvent) callconv(.c) u16 { + const e = event orelse return 0; + if (e.type != .winsize) return 0; + return e.winsize.y_pixel; +} + +pub fn key_from_name(name: ?[*]const u8, name_len: usize) callconv(.c) u32 { + const n = name orelse return 0; + return Key.name_map.get(n[0..name_len]) orelse 0; +} + +pub fn version() callconv(.c) [*:0]const u8 { + // single-sourced from build.zig.zon + return std.fmt.comptimePrint("{s}", .{@import("build_options").version}); +} + +fn convertEvent(p: *CParser, event: vaxis.Event) void { + // the tag mapping is comptime-checked: a vaxis.Event variant without a + // matching EventType tag fails to compile + p.event.type = switch (event) { + inline else => |_, tag| @field(EventType, @tagName(tag)), + }; + switch (event) { + .key_press, .key_release => |key| p.event.key = copyKey(p, key), + .mouse => |mouse| p.event.mouse = mouse, + .paste => |text| { + p.paste = text; + p.event.paste = text; + }, + .color_report => |report| p.event.color_report = report, + .color_scheme => |scheme| p.event.color_scheme = scheme, + .winsize => |winsize| p.event.winsize = winsize, + else => {}, + } +} + +fn copyKey(p: *CParser, key: Key) Key { + var out = key; + out.text = null; + if (key.text) |text| { + // Copy the text so it stays valid until the next parse call. + // Oversized text is truncated at a codepoint boundary + var n = @min(text.len, p.text_buf.len); + while (n < text.len and n > 0 and text[n] & 0xC0 == 0x80) n -= 1; + @memcpy(p.text_buf[0..n], text[0..n]); + out.text = p.text_buf[0..n]; + } + return out; +} + +const testing = std.testing; + +fn parseBytes(p: *CParser, input: []const u8, event: *?*const CEvent, n: *usize) Result { + return parser_parse(p, input.ptr, input.len, event, n); +} + +fn comptimeUpper(comptime name: []const u8) []const u8 { + comptime { + var out: [name.len]u8 = undefined; + for (name, 0..) |char, i| out[i] = std.ascii.toUpper(char); + const final = out; + return &final; + } +} + +fn asInt(value: anytype) c_int { + return switch (@typeInfo(@TypeOf(value))) { + .@"enum" => @intFromEnum(value), + else => @intCast(value), + }; +} + +test "c api: conformance with vaxis.h" { + @setEvalBranchQuota(100_000); + const c = @cImport(@cInclude("vaxis.h")); + + // the only transparent structs in the ABI + try testing.expectEqual(@sizeOf(c.vaxis_string), @sizeOf(CString)); + try testing.expectEqual(@alignOf(c.vaxis_string), @alignOf(CString)); + try testing.expectEqual(@offsetOf(c.vaxis_string, "ptr"), @offsetOf(CString, "ptr")); + try testing.expectEqual(@offsetOf(c.vaxis_string, "len"), @offsetOf(CString, "len")); + try testing.expectEqual(@sizeOf(c.vaxis_rgb), @sizeOf(CRgb)); + inline for (.{ "r", "g", "b" }) |field| { + try testing.expectEqual(@offsetOf(c.vaxis_rgb, field), @offsetOf(CRgb, field)); + } + + // every event type has a matching VAXIS_EVENT_* value + inline for (@typeInfo(EventType).@"enum".fields) |field| { + try testing.expectEqual( + asInt(@field(c, "VAXIS_EVENT_" ++ comptimeUpper(field.name))), + field.value, + ); + } + + // result codes: ok is VAXIS_OK, errors are VAXIS_ERR_* + inline for (@typeInfo(Result).@"enum".fields) |field| { + const c_name = comptime if (std.mem.eql(u8, field.name, "ok")) + "VAXIS_OK" + else + "VAXIS_" ++ comptimeUpper(field.name); + try testing.expectEqual(asInt(@field(c, c_name)), field.value); + } + + // every u21 key constant has a matching VAXIS_KEY_* define + inline for (@typeInfo(Key).@"struct".decls) |decl| { + if (@TypeOf(@field(Key, decl.name)) == u21) { + try testing.expectEqual( + asInt(@field(c, "VAXIS_KEY_" ++ comptimeUpper(decl.name))), + @field(Key, decl.name), + ); + } + } + + // modifier bits are the packed struct bit positions + inline for (@typeInfo(Key.Modifiers).@"struct".fields, 0..) |field, i| { + try testing.expectEqual( + asInt(@field(c, "VAXIS_MOD_" ++ comptimeUpper(field.name))), + @as(u8, 1) << i, + ); + } + inline for (@typeInfo(Mouse.Modifiers).@"struct".fields, 0..) |field, i| { + try testing.expectEqual( + asInt(@field(c, "VAXIS_MOUSE_MOD_" ++ comptimeUpper(field.name))), + @as(u8, 1) << i, + ); + } + + // mouse buttons, mouse event types, color kinds, and color schemes + inline for (@typeInfo(Mouse.Button).@"enum".fields) |field| { + try testing.expectEqual( + asInt(@field(c, "VAXIS_MOUSE_" ++ comptimeUpper(field.name))), + field.value, + ); + } + inline for (@typeInfo(Mouse.Type).@"enum".fields) |field| { + try testing.expectEqual( + asInt(@field(c, "VAXIS_MOUSE_" ++ comptimeUpper(field.name))), + field.value, + ); + } + inline for (@typeInfo(std.meta.Tag(Color.Kind)).@"enum".fields) |field| { + try testing.expectEqual( + asInt(@field(c, "VAXIS_COLOR_" ++ comptimeUpper(field.name))), + field.value, + ); + } + inline for (@typeInfo(Color.Scheme).@"enum".fields) |field| { + try testing.expectEqual( + asInt(@field(c, "VAXIS_COLOR_SCHEME_" ++ comptimeUpper(field.name))), + field.value, + ); + } +} + +test "c api: plain keypress with text" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, "a", &event, &n)); + try testing.expectEqual(@as(usize, 1), n); + try testing.expectEqual(EventType.key_press, event_get_type(event)); + try testing.expectEqual(@as(u32, 'a'), event_key_codepoint(event)); + const text = event_key_text(event); + try testing.expectEqualStrings("a", text.ptr.?[0..text.len]); + try testing.expect(event_key_matches(event, 'a', 0)); + try testing.expect(!event_key_matches(event, 'b', 0)); +} + +test "c api: kitty shift+a" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + const input = "\x1b[97:65;2u"; + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, input, &event, &n)); + try testing.expectEqual(input.len, n); + try testing.expectEqual(EventType.key_press, event_get_type(event)); + try testing.expectEqual(@as(u32, 'a'), event_key_codepoint(event)); + try testing.expectEqual(@as(u32, 'A'), event_key_shifted_codepoint(event)); + try testing.expectEqual(@as(u8, 1), event_key_mods(event)); // VAXIS_MOD_SHIFT + try testing.expect(event_key_matches(event, 'a', 1)); + try testing.expect(event_key_matches(event, 'A', 0)); +} + +test "c api: sgr mouse motion" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + const input = "\x1b[<35;1;1m"; + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, input, &event, &n)); + try testing.expectEqual(EventType.mouse, event_get_type(event)); + try testing.expectEqual(@as(i16, 0), event_mouse_col(event)); + try testing.expectEqual(@as(i16, 0), event_mouse_row(event)); + try testing.expectEqual(@as(u8, 3), event_mouse_button(event)); // VAXIS_MOUSE_NONE + try testing.expectEqual(@as(u8, 2), event_mouse_type(event)); // VAXIS_MOUSE_MOTION + // key accessors return zero values for a mouse event + try testing.expectEqual(@as(u32, 0), event_key_codepoint(event)); + try testing.expectEqual(@as(usize, 0), event_key_text(event).len); +} + +test "c api: osc 52 paste is parser-owned" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + const input = "\x1b]52;c;b3NjNTIgcGFzdGU=\x1b\\"; + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, input, &event, &n)); + try testing.expectEqual(EventType.paste, event_get_type(event)); + const text = event_paste_text(event); + try testing.expectEqualStrings("osc52 paste", text.ptr.?[0..text.len]); + // the next parse releases the paste; free must not double free + try testing.expectEqual(.ok, parseBytes(parser, "a", &event, &n)); + try testing.expectEqual(EventType.key_press, event_get_type(event)); +} + +test "c api: incomplete sequence yields null event and zero consumed" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + const input = "\x1b["; + var event: ?*const CEvent = null; + var n: usize = 1; + try testing.expectEqual(.ok, parseBytes(parser, input, &event, &n)); + try testing.expectEqual(@as(?*const CEvent, null), event); + try testing.expectEqual(EventType.none, event_get_type(event)); + try testing.expectEqual(@as(usize, 0), n); +} + +test "c api: in-band resize" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + const input = "\x1b[48;24;80;480;1440t"; + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, input, &event, &n)); + try testing.expectEqual(EventType.winsize, event_get_type(event)); + try testing.expectEqual(@as(u16, 24), event_winsize_rows(event)); + try testing.expectEqual(@as(u16, 80), event_winsize_cols(event)); + try testing.expectEqual(@as(u16, 1440), event_winsize_x_pixel(event)); + try testing.expectEqual(@as(u16, 480), event_winsize_y_pixel(event)); +} + +test "c api: color report" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + const input = "\x1b]11;rgb:ffff/8080/0000\x1b\\"; + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, input, &event, &n)); + try testing.expectEqual(EventType.color_report, event_get_type(event)); + try testing.expectEqual(@as(u8, 1), event_color_report_kind(event)); // VAXIS_COLOR_BG + const rgb = event_color_report_rgb(event); + try testing.expectEqual(@as(u8, 0xff), rgb.r); + try testing.expectEqual(@as(u8, 0x80), rgb.g); + try testing.expectEqual(@as(u8, 0x00), rgb.b); +} + +test "c api: malformed osc payload is consumed with a null event" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + const input = "\x1b]4;1;rgb:zz/zz/zz\x1b\\"; + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, input, &event, &n)); + try testing.expectEqual(@as(?*const CEvent, null), event); + try testing.expectEqual(input.len, n); +} + +test "c api: oversized grapheme text is truncated at a utf8 boundary" { + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + + // one grapheme cluster larger than the 256 byte text buffer + const input = ("\xE2\x98\xBA\xE2\x80\x8D" ** 60) ++ "\xE2\x98\xBA"; + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.ok, parseBytes(parser, input, &event, &n)); + try testing.expectEqual(input.len, n); + try testing.expectEqual(EventType.key_press, event_get_type(event)); + try testing.expectEqual(@as(u32, Key.multicodepoint), event_key_codepoint(event)); + + const text = event_key_text(event); + try testing.expect(text.len < input.len); // truncated + try testing.expect(text.len <= 256); + try testing.expect(std.unicode.utf8ValidateSlice(text.ptr.?[0..text.len])); // but never split +} + +test "c api: key name lookup" { + try testing.expectEqual(@as(u32, Key.enter), key_from_name("enter", 5)); + try testing.expectEqual(@as(u32, Key.f1), key_from_name("f1", 2)); + try testing.expectEqual(@as(u32, 0), key_from_name("not_a_key", 9)); +} + +test "c api: null arguments" { + var event: ?*const CEvent = null; + var n: usize = 0; + try testing.expectEqual(.err_invalid, parser_parse(null, "a", 1, &event, &n)); + const parser = parser_new() orelse return error.OutOfMemory; + defer parser_free(parser); + try testing.expectEqual(.err_invalid, parser_parse(parser, null, 1, &event, &n)); + try testing.expectEqual(.err_invalid, parser_parse(parser, "a", 1, null, &n)); + try testing.expectEqual(.err_invalid, parser_parse(parser, "a", 1, &event, null)); + // accessors are NULL-safe + try testing.expectEqual(EventType.none, event_get_type(null)); + try testing.expectEqual(@as(u32, 0), event_key_codepoint(null)); + try testing.expectEqual(@as(?[*]const u8, null), event_key_text(null).ptr); + try testing.expect(!event_key_matches(null, 'a', 0)); +} -- 2.51.2