diff --git a/AGENTS.md b/AGENTS.md index 5a885923e..a3e752816 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ A file for [guiding coding agents](https://agents.md/). ## libghostty-vt - Build: `zig build lib-vt` +- Build Wasm Module: `zig build lib-vt -Dtarget=wasm32-freestanding` - Test: `zig build test-lib-vt` - Test filter: `zig build test-lib-vt -Dtest-filter=` - When working on libghostty-vt, do not build the full app. diff --git a/Doxyfile b/Doxyfile index 63e73334d..1703e6fac 100644 --- a/Doxyfile +++ b/Doxyfile @@ -17,6 +17,16 @@ INLINE_SOURCES = NO REFERENCES_RELATION = YES REFERENCED_BY_RELATION = YES +#--------------------------------------------------------------------------- +# Preprocessor +#--------------------------------------------------------------------------- + +# Enable preprocessing to handle #ifdef guards +ENABLE_PREPROCESSING = YES +MACRO_EXPANSION = YES +EXPAND_ONLY_PREDEF = YES +PREDEFINED = __wasm__ + #--------------------------------------------------------------------------- # C API Optimization #--------------------------------------------------------------------------- diff --git a/example/wasm-key-encode/README.md b/example/wasm-key-encode/README.md new file mode 100644 index 000000000..ec8332e68 --- /dev/null +++ b/example/wasm-key-encode/README.md @@ -0,0 +1,76 @@ +# WebAssembly Key Encoder Example + +This example demonstrates how to use the Ghostty VT library from WebAssembly to encode key events into terminal escape sequences. + +## What It Does + +The example demonstrates using the Ghostty VT library from WebAssembly to encode key events: + +1. Loads the `ghostty-vt.wasm` module +2. Creates a key encoder with Kitty keyboard protocol support +3. Creates a key event for left ctrl release +4. Queries the required buffer size (optional) +5. Encodes the event into a terminal escape sequence +6. Displays the result in both hexadecimal and string format + +## Building + +First, build the WebAssembly module: + +```bash +zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +``` + +This will create `zig-out/bin/ghostty-vt.wasm`. + +## Running + +**Important:** You must serve this via HTTP, not open it as a file directly. Browsers block loading WASM files from `file://` URLs. + +From the **root of the ghostty repository**, serve with a local HTTP server: + +```bash +# Using Python (recommended) +python3 -m http.server 8000 + +# Or using Node.js +npx serve . + +# Or using PHP +php -S localhost:8000 +``` + +Then open your browser to: + +``` +http://localhost:8000/example/wasm-key-encode/ +``` + +Click "Run Example" to see the key encoding in action. + +## Expected Output + +``` +Encoding event: left ctrl release with all Kitty flags enabled +Required buffer size: 12 bytes +Encoded 12 bytes +Hex: 1b 5b 35 37 3a 33 3b 32 3a 33 75 +String: \x1b[57:3;2:3u +``` + +## Notes + +- The example uses the convenience allocator functions exported by the wasm module +- Error handling is included to demonstrate proper usage patterns +- The encoded sequence `\x1b[57:3;2:3u` is a Kitty keyboard protocol sequence for left ctrl release with all features enabled +- The `env.log` function must be provided by the host environment for logging support + +## Current Limitations + +The current C API is verbose when called from WebAssembly because: + +- Functions use output pointers requiring manual memory allocation in JavaScript +- Options must be set via pointers to values +- Buffer sizes require pointer parameters + +See `WASM_API_PLAN.md` for proposed improvements to make the API more wasm-friendly. diff --git a/example/wasm-key-encode/index.html b/example/wasm-key-encode/index.html new file mode 100644 index 000000000..714988b4d --- /dev/null +++ b/example/wasm-key-encode/index.html @@ -0,0 +1,687 @@ + + + + + + Ghostty VT Key Encoder - WebAssembly Example + + + +

Ghostty VT Key Encoder - WebAssembly Example

+

This example demonstrates encoding key events into terminal escape sequences using the Ghostty VT WebAssembly module.

+ +
+ ⚠️ Warning: + This is an example of the libghostty-vt WebAssembly API. The JavaScript + keyboard event mapping to the libghostty-vt API may not be perfect + and may result in encoding inaccuracies for certain keys or layouts. + Do not use this as a key encoding reference. +
+ +
Loading WebAssembly module...
+ +
+

Key Action

+
+ + + +
+
+ +
+

Kitty Keyboard Protocol Flags

+
+ + + + + +
+
+ + + +
Waiting for key events...
+ +

Note: This example must be served via HTTP (not opened directly as a file). See the README for instructions.

+ + + + diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index cd357f0fa..e6d922009 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -32,6 +32,7 @@ * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref paste "Paste Utilities" - Validate paste data safety * - @ref allocator "Memory Management" - Memory management and custom allocators + * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions * * @section examples_sec Examples * @@ -69,6 +70,7 @@ extern "C" { #include #include #include +#include #ifdef __cplusplus } diff --git a/include/ghostty/vt/wasm.h b/include/ghostty/vt/wasm.h new file mode 100644 index 000000000..7edee529f --- /dev/null +++ b/include/ghostty/vt/wasm.h @@ -0,0 +1,141 @@ +/** + * @file wasm.h + * + * WebAssembly utility functions for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_WASM_H +#define GHOSTTY_VT_WASM_H + +#ifdef __wasm__ + +#include +#include + +/** @defgroup wasm WebAssembly Utilities + * + * Convenience functions for allocating various types in WebAssembly builds. + * **These are only available the libghostty-vt wasm module.** + * + * Ghostty relies on pointers to various types for ABI compatibility, and + * creating those pointers in Wasm can be tedious. These functions provide + * a purely additive set of utilities that simplify memory management in + * Wasm environments without changing the core C library API. + * + * @note These functions always use the default allocator. If you need + * custom allocation strategies, you should allocate types manually using + * your custom allocator. This is a very rare use case in the WebAssembly + * world so these are optimized for simplicity. + * + * ## Example Usage + * + * Here's a simple example of using the Wasm utilities with the key encoder: + * + * @code + * const { exports } = wasmInstance; + * const view = new DataView(wasmMemory.buffer); + * + * // Create key encoder + * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); + * exports.ghostty_key_encoder_new(null, encoderPtr); + * const encoder = view.getUint32(encoder, true); + * + * // Configure encoder with Kitty protocol flags + * const flagsPtr = exports.ghostty_wasm_alloc_u8(); + * view.setUint8(flagsPtr, 0x1F); + * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); + * + * // Allocate output buffer and size pointer + * const bufferSize = 32; + * const bufPtr = exports.ghostty_wasm_alloc_buffer(bufferSize); + * const writtenPtr = exports.ghostty_wasm_alloc_usize(); + * + * // Encode the key event + * exports.ghostty_key_encoder_encode( + * encoder, eventPtr, bufPtr, bufferSize, writtenPtr + * ); + * + * // Read encoded output + * const bytesWritten = view.getUint32(writtenPtr, true); + * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); + * @endcode + * + * @remark The code above is pretty ugly! This is the lowest level interface + * to the libghostty-vt Wasm module. In practice, this should be wrapped + * in a higher-level API that abstracts away all this. + * + * @{ + */ + +/** + * Allocate an opaque pointer. This can be used for any opaque pointer + * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. + * + * @return Pointer to allocated opaque pointer, or NULL if allocation failed + * @ingroup wasm + */ +void** ghostty_wasm_alloc_opaque(void); + +/** + * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_opaque(void **ptr); + +/** + * Allocate a buffer of the specified length. + * + * @param len Number of bytes to allocate + * @return Pointer to allocated buffer, or NULL if allocation failed + * @ingroup wasm + */ +uint8_t* ghostty_wasm_alloc_buffer(size_t len); + +/** + * Free a buffer allocated by ghostty_wasm_alloc_buffer(). + * + * @param ptr Pointer to the buffer to free, or NULL (NULL is safely ignored) + * @param len Length of the buffer (must match the length passed to alloc) + * @ingroup wasm + */ +void ghostty_wasm_free_buffer(uint8_t *ptr, size_t len); + +/** + * Allocate a single uint8_t value. + * + * @return Pointer to allocated uint8_t, or NULL if allocation failed + * @ingroup wasm + */ +uint8_t* ghostty_wasm_alloc_u8(void); + +/** + * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_u8(uint8_t *ptr); + +/** + * Allocate a single size_t value. + * + * @return Pointer to allocated size_t, or NULL if allocation failed + * @ingroup wasm + */ +size_t* ghostty_wasm_alloc_usize(void); + +/** + * Free a size_t allocated by ghostty_wasm_alloc_usize(). + * + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @ingroup wasm + */ +void ghostty_wasm_free_usize(size_t *ptr); + +/** @} */ + +#endif /* __wasm__ */ + +#endif /* GHOSTTY_VT_WASM_H */ diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig index f411deb19..4d6655600 100644 --- a/src/input/key_encode.zig +++ b/src/input/key_encode.zig @@ -77,7 +77,7 @@ pub fn encode( event: key.KeyEvent, opts: Options, ) std.Io.Writer.Error!void { - //std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); + std.log.warn("KEYENCODER event={} opts={}", .{ event, opts }); return if (opts.kitty_flags.int() != 0) try kitty( writer, event, diff --git a/src/lib/allocator.zig b/src/lib/allocator.zig index ccea7ae29..fc56033a2 100644 --- a/src/lib/allocator.zig +++ b/src/lib/allocator.zig @@ -2,6 +2,9 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; +/// Convenience functions +pub const convenience = @import("allocator/convenience.zig"); + /// Useful alias since they're required to create Zig allocators pub const ZigVTable = std.mem.Allocator.VTable; diff --git a/src/lib/allocator/convenience.zig b/src/lib/allocator/convenience.zig new file mode 100644 index 000000000..19543ad0e --- /dev/null +++ b/src/lib/allocator/convenience.zig @@ -0,0 +1,50 @@ +//! This contains convenience functions for allocating various types. +//! +//! The primary use case for this is Wasm builds. Ghostty relies a lot on +//! pointers to various types for ABI compatibility and creating those pointers +//! in Wasm is tedious. This file contains a purely additive set of functions +//! that can be exposed to the Wasm module without changing the API from the +//! C library. +//! +//! Given these are convenience methods, they always use the default allocator. +//! If a caller is using a custom allocator, they have the expertise to +//! allocate these types manually using their custom allocator. + +// Get our default allocator at comptime since it is known. +const default = @import("../allocator.zig").default; +const alloc = default(null); + +pub const Opaque = *anyopaque; + +pub fn allocOpaque() callconv(.c) ?*Opaque { + return alloc.create(*anyopaque) catch return null; +} + +pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} + +pub fn allocBuffer(len: usize) callconv(.c) ?[*]u8 { + const slice = alloc.alloc(u8, len) catch return null; + return slice.ptr; +} + +pub fn freeBuffer(ptr: ?[*]u8, len: usize) callconv(.c) void { + if (ptr) |p| alloc.free(p[0..len]); +} + +pub fn allocU8() callconv(.c) ?*u8 { + return alloc.create(u8) catch return null; +} + +pub fn freeU8(ptr: ?*u8) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} + +pub fn allocUsize() callconv(.c) ?*usize { + return alloc.create(usize) catch return null; +} + +pub fn freeUsize(ptr: ?*usize) callconv(.c) void { + if (ptr) |p| alloc.destroy(p); +} diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 322f391ab..c66e5ab39 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -126,6 +126,19 @@ comptime { @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); + + // On Wasm we need to export our allocator convenience functions. + if (builtin.target.cpu.arch.isWasm()) { + const alloc = @import("lib/allocator/convenience.zig"); + @export(&alloc.allocOpaque, .{ .name = "ghostty_wasm_alloc_opaque" }); + @export(&alloc.freeOpaque, .{ .name = "ghostty_wasm_free_opaque" }); + @export(&alloc.allocBuffer, .{ .name = "ghostty_wasm_alloc_buffer" }); + @export(&alloc.freeBuffer, .{ .name = "ghostty_wasm_free_buffer" }); + @export(&alloc.allocU8, .{ .name = "ghostty_wasm_alloc_u8" }); + @export(&alloc.freeU8, .{ .name = "ghostty_wasm_free_u8" }); + @export(&alloc.allocUsize, .{ .name = "ghostty_wasm_alloc_usize" }); + @export(&alloc.freeUsize, .{ .name = "ghostty_wasm_free_usize" }); + } } } diff --git a/src/terminal/c/key_encode.zig b/src/terminal/c/key_encode.zig index f5f6ff054..47bd904e0 100644 --- a/src/terminal/c/key_encode.zig +++ b/src/terminal/c/key_encode.zig @@ -10,6 +10,8 @@ const OptionAsAlt = @import("../../input/config.zig").OptionAsAlt; const Result = @import("result.zig").Result; const KeyEvent = @import("key_event.zig").Event; +const log = std.log.scoped(.key_encode); + /// Wrapper around key encoding options that tracks the allocator for C API usage. const KeyEncoderWrapper = struct { opts: key_encode.Options, @@ -70,6 +72,13 @@ pub fn setopt( option: Option, value: ?*const anyopaque, ) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + log.warn("setopt invalid option value={d}", .{@intFromEnum(option)}); + return; + }; + } + return switch (option) { inline else => |comptime_option| setoptTyped( encoder_, @@ -95,7 +104,15 @@ fn setoptTyped( const bits: u5 = @truncate(value.*); break :flags @bitCast(bits); }, - .macos_option_as_alt => opts.macos_option_as_alt = value.*, + .macos_option_as_alt => { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(OptionAsAlt, @intFromEnum(value.*)) catch { + log.warn("setopt invalid OptionAsAlt value={d}", .{@intFromEnum(value.*)}); + return; + }; + } + opts.macos_option_as_alt = value.*; + }, } } diff --git a/src/terminal/c/key_event.zig b/src/terminal/c/key_event.zig index 5befe4384..b52932fdd 100644 --- a/src/terminal/c/key_event.zig +++ b/src/terminal/c/key_event.zig @@ -6,6 +6,8 @@ const CAllocator = lib_alloc.Allocator; const key = @import("../../input/key.zig"); const Result = @import("result.zig").Result; +const log = std.log.scoped(.key_event); + /// Wrapper around KeyEvent that tracks the allocator for C API usage. /// The UTF-8 text is not owned by this wrapper - the caller is responsible /// for ensuring the lifetime of any UTF-8 text set via set_utf8. @@ -36,6 +38,13 @@ pub fn free(event_: Event) callconv(.c) void { } pub fn set_action(event_: Event, action: key.Action) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(key.Action, @intFromEnum(action)) catch { + log.warn("set_action invalid action value={d}", .{@intFromEnum(action)}); + return; + }; + } + const event: *key.KeyEvent = &event_.?.event; event.action = action; } @@ -46,6 +55,13 @@ pub fn get_action(event_: Event) callconv(.c) key.Action { } pub fn set_key(event_: Event, k: key.Key) callconv(.c) void { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(key.Key, @intFromEnum(k)) catch { + log.warn("set_key invalid key value={d}", .{@intFromEnum(k)}); + return; + }; + } + const event: *key.KeyEvent = &event_.?.event; event.key = k; } diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 1311eaff8..124fc3b7c 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -6,6 +6,8 @@ const CAllocator = lib_alloc.Allocator; const osc = @import("../osc.zig"); const Result = @import("result.zig").Result; +const log = std.log.scoped(.osc); + /// C: GhosttyOscParser pub const Parser = ?*osc.Parser; @@ -68,6 +70,13 @@ pub fn commandData( data: CommandData, out: ?*anyopaque, ) callconv(.c) bool { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(CommandData, @intFromEnum(data)) catch { + log.warn("commandData invalid data value={d}", .{@intFromEnum(data)}); + return false; + }; + } + return switch (data) { inline else => |comptime_data| commandDataTyped( command_,