lib-vt: wasm convenience functions and a simple example (#9309)

This adds a set of Wasm convenience functions to ease memory management.
These are all prefixed with `ghostty_wasm` and are documented as part of
the standard Doxygen docs.

I also added a very simple single-page HTML example that demonstrates
how to use the Wasm module for key encoding.

This also adds a bunch of safety checks to the C API to verify that
valid values are actually passed to the function. This is an easy to hit
bug.

**AI disclosure:** The example is AI-written with Amp. I read through
all the code and understand it but I can't claim there isn't a better
way, I'm far from a JS expert. It is simple and works currently though.
Happy to see improvements if anyone wants to contribute.
pull/9313/head
Mitchell Hashimoto 2025-10-22 14:25:52 -07:00 committed by GitHub
parent 9dc2e5978f
commit c133fac7e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1027 additions and 2 deletions

View File

@ -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=<test name>`
- When working on libghostty-vt, do not build the full app.

View File

@ -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
#---------------------------------------------------------------------------

View File

@ -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.

View File

@ -0,0 +1,687 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ghostty VT Key Encoder - WebAssembly Example</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
line-height: 1.6;
}
h1 {
color: #333;
}
.output {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
font-family: 'Courier New', monospace;
white-space: pre-wrap;
word-break: break-all;
}
.error {
background: #fee;
border-color: #faa;
color: #c00;
}
button {
background: #0066cc;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #0052a3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.key-input {
width: 100%;
padding: 15px;
font-size: 16px;
border: 2px solid #0066cc;
border-radius: 4px;
margin: 20px 0;
box-sizing: border-box;
}
.key-input:focus {
outline: none;
border-color: #0052a3;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.status {
color: #666;
font-size: 14px;
margin: 10px 0;
}
.controls {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
}
.controls h3 {
margin-top: 0;
margin-bottom: 10px;
font-size: 16px;
}
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
cursor: pointer;
}
.radio-group {
display: flex;
gap: 15px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.radio-group input[type="radio"] {
cursor: pointer;
}
.warning {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
color: #856404;
}
.warning strong {
display: block;
margin-bottom: 5px;
}
</style>
</head>
<body>
<h1>Ghostty VT Key Encoder - WebAssembly Example</h1>
<p>This example demonstrates encoding key events into terminal escape sequences using the Ghostty VT WebAssembly module.</p>
<div class="warning">
<strong>⚠️ Warning:</strong>
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.
</div>
<div class="status" id="status">Loading WebAssembly module...</div>
<div class="controls">
<h3>Key Action</h3>
<div class="radio-group">
<label><input type="radio" name="action" value="1" checked> Press</label>
<label><input type="radio" name="action" value="0"> Release</label>
<label><input type="radio" name="action" value="2"> Repeat</label>
</div>
</div>
<div class="controls">
<h3>Kitty Keyboard Protocol Flags</h3>
<div class="checkbox-group">
<label><input type="checkbox" id="flag_disambiguate" checked> Disambiguate</label>
<label><input type="checkbox" id="flag_report_events" checked> Report Events</label>
<label><input type="checkbox" id="flag_report_alternates" checked> Report Alternates</label>
<label><input type="checkbox" id="flag_report_all_as_escapes" checked> Report All As Escapes</label>
<label><input type="checkbox" id="flag_report_text" checked> Report Text</label>
</div>
</div>
<input type="text" class="key-input" id="keyInput" placeholder="Focus here and press any key combination (e.g., Ctrl+A, Shift+Enter)..." disabled>
<div id="output" class="output">Waiting for key events...</div>
<p><strong>Note:</strong> This example must be served via HTTP (not opened directly as a file). See the README for instructions.</p>
<script>
let wasmInstance = null;
let wasmMemory = null;
let encoderPtr = null;
let lastKeyEvent = null;
async function loadWasm() {
try {
// Load the wasm module - adjust path as needed
const response = await fetch('../../zig-out/bin/ghostty-vt.wasm');
const wasmBytes = await response.arrayBuffer();
// Instantiate the wasm module
const wasmModule = await WebAssembly.instantiate(wasmBytes, {
env: {
// Logging function for wasm module
log: (ptr, len) => {
const bytes = new Uint8Array(wasmModule.instance.exports.memory.buffer, ptr, len);
const text = new TextDecoder().decode(bytes);
console.log('[wasm]', text);
}
}
});
wasmInstance = wasmModule.instance;
wasmMemory = wasmInstance.exports.memory;
return true;
} catch (e) {
console.error('Failed to load WASM:', e);
if (window.location.protocol === 'file:') {
throw new Error('Cannot load WASM from file:// protocol. Please serve via HTTP (see README)');
}
return false;
}
}
function getBuffer() {
return wasmMemory.buffer;
}
function formatHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
}
function formatString(bytes) {
let result = '';
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0x1b) {
result += '\\x1b';
} else {
result += String.fromCharCode(bytes[i]);
}
}
return result;
}
// Map W3C KeyboardEvent.code values to Ghostty key codes
// Based on include/ghostty/vt/key/event.h
const keyCodeMap = {
// Writing System Keys
'Backquote': 1, // GHOSTTY_KEY_BACKQUOTE
'Backslash': 2, // GHOSTTY_KEY_BACKSLASH
'BracketLeft': 3, // GHOSTTY_KEY_BRACKET_LEFT
'BracketRight': 4, // GHOSTTY_KEY_BRACKET_RIGHT
'Comma': 5, // GHOSTTY_KEY_COMMA
'Digit0': 6, // GHOSTTY_KEY_DIGIT_0
'Digit1': 7, // GHOSTTY_KEY_DIGIT_1
'Digit2': 8, // GHOSTTY_KEY_DIGIT_2
'Digit3': 9, // GHOSTTY_KEY_DIGIT_3
'Digit4': 10, // GHOSTTY_KEY_DIGIT_4
'Digit5': 11, // GHOSTTY_KEY_DIGIT_5
'Digit6': 12, // GHOSTTY_KEY_DIGIT_6
'Digit7': 13, // GHOSTTY_KEY_DIGIT_7
'Digit8': 14, // GHOSTTY_KEY_DIGIT_8
'Digit9': 15, // GHOSTTY_KEY_DIGIT_9
'Equal': 16, // GHOSTTY_KEY_EQUAL
'IntlBackslash': 17, // GHOSTTY_KEY_INTL_BACKSLASH
'IntlRo': 18, // GHOSTTY_KEY_INTL_RO
'IntlYen': 19, // GHOSTTY_KEY_INTL_YEN
'KeyA': 20, // GHOSTTY_KEY_A
'KeyB': 21, // GHOSTTY_KEY_B
'KeyC': 22, // GHOSTTY_KEY_C
'KeyD': 23, // GHOSTTY_KEY_D
'KeyE': 24, // GHOSTTY_KEY_E
'KeyF': 25, // GHOSTTY_KEY_F
'KeyG': 26, // GHOSTTY_KEY_G
'KeyH': 27, // GHOSTTY_KEY_H
'KeyI': 28, // GHOSTTY_KEY_I
'KeyJ': 29, // GHOSTTY_KEY_J
'KeyK': 30, // GHOSTTY_KEY_K
'KeyL': 31, // GHOSTTY_KEY_L
'KeyM': 32, // GHOSTTY_KEY_M
'KeyN': 33, // GHOSTTY_KEY_N
'KeyO': 34, // GHOSTTY_KEY_O
'KeyP': 35, // GHOSTTY_KEY_P
'KeyQ': 36, // GHOSTTY_KEY_Q
'KeyR': 37, // GHOSTTY_KEY_R
'KeyS': 38, // GHOSTTY_KEY_S
'KeyT': 39, // GHOSTTY_KEY_T
'KeyU': 40, // GHOSTTY_KEY_U
'KeyV': 41, // GHOSTTY_KEY_V
'KeyW': 42, // GHOSTTY_KEY_W
'KeyX': 43, // GHOSTTY_KEY_X
'KeyY': 44, // GHOSTTY_KEY_Y
'KeyZ': 45, // GHOSTTY_KEY_Z
'Minus': 46, // GHOSTTY_KEY_MINUS
'Period': 47, // GHOSTTY_KEY_PERIOD
'Quote': 48, // GHOSTTY_KEY_QUOTE
'Semicolon': 49, // GHOSTTY_KEY_SEMICOLON
'Slash': 50, // GHOSTTY_KEY_SLASH
// Functional Keys
'AltLeft': 51, // GHOSTTY_KEY_ALT_LEFT
'AltRight': 52, // GHOSTTY_KEY_ALT_RIGHT
'Backspace': 53, // GHOSTTY_KEY_BACKSPACE
'CapsLock': 54, // GHOSTTY_KEY_CAPS_LOCK
'ContextMenu': 55, // GHOSTTY_KEY_CONTEXT_MENU
'ControlLeft': 56, // GHOSTTY_KEY_CONTROL_LEFT
'ControlRight': 57, // GHOSTTY_KEY_CONTROL_RIGHT
'Enter': 58, // GHOSTTY_KEY_ENTER
'MetaLeft': 59, // GHOSTTY_KEY_META_LEFT
'MetaRight': 60, // GHOSTTY_KEY_META_RIGHT
'ShiftLeft': 61, // GHOSTTY_KEY_SHIFT_LEFT
'ShiftRight': 62, // GHOSTTY_KEY_SHIFT_RIGHT
'Space': 63, // GHOSTTY_KEY_SPACE
'Tab': 64, // GHOSTTY_KEY_TAB
'Convert': 65, // GHOSTTY_KEY_CONVERT
'KanaMode': 66, // GHOSTTY_KEY_KANA_MODE
'NonConvert': 67, // GHOSTTY_KEY_NON_CONVERT
// Control Pad Section
'Delete': 68, // GHOSTTY_KEY_DELETE
'End': 69, // GHOSTTY_KEY_END
'Help': 70, // GHOSTTY_KEY_HELP
'Home': 71, // GHOSTTY_KEY_HOME
'Insert': 72, // GHOSTTY_KEY_INSERT
'PageDown': 73, // GHOSTTY_KEY_PAGE_DOWN
'PageUp': 74, // GHOSTTY_KEY_PAGE_UP
// Arrow Pad Section
'ArrowDown': 75, // GHOSTTY_KEY_ARROW_DOWN
'ArrowLeft': 76, // GHOSTTY_KEY_ARROW_LEFT
'ArrowRight': 77, // GHOSTTY_KEY_ARROW_RIGHT
'ArrowUp': 78, // GHOSTTY_KEY_ARROW_UP
// Numpad Section
'NumLock': 79, // GHOSTTY_KEY_NUM_LOCK
'Numpad0': 80, // GHOSTTY_KEY_NUMPAD_0
'Numpad1': 81, // GHOSTTY_KEY_NUMPAD_1
'Numpad2': 82, // GHOSTTY_KEY_NUMPAD_2
'Numpad3': 83, // GHOSTTY_KEY_NUMPAD_3
'Numpad4': 84, // GHOSTTY_KEY_NUMPAD_4
'Numpad5': 85, // GHOSTTY_KEY_NUMPAD_5
'Numpad6': 86, // GHOSTTY_KEY_NUMPAD_6
'Numpad7': 87, // GHOSTTY_KEY_NUMPAD_7
'Numpad8': 88, // GHOSTTY_KEY_NUMPAD_8
'Numpad9': 89, // GHOSTTY_KEY_NUMPAD_9
'NumpadAdd': 90, // GHOSTTY_KEY_NUMPAD_ADD
'NumpadBackspace': 91, // GHOSTTY_KEY_NUMPAD_BACKSPACE
'NumpadClear': 92, // GHOSTTY_KEY_NUMPAD_CLEAR
'NumpadClearEntry': 93, // GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY
'NumpadComma': 94, // GHOSTTY_KEY_NUMPAD_COMMA
'NumpadDecimal': 95, // GHOSTTY_KEY_NUMPAD_DECIMAL
'NumpadDivide': 96, // GHOSTTY_KEY_NUMPAD_DIVIDE
'NumpadEnter': 97, // GHOSTTY_KEY_NUMPAD_ENTER
'NumpadEqual': 98, // GHOSTTY_KEY_NUMPAD_EQUAL
'NumpadMemoryAdd': 99, // GHOSTTY_KEY_NUMPAD_MEMORY_ADD
'NumpadMemoryClear': 100,// GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR
'NumpadMemoryRecall': 101,// GHOSTTY_KEY_NUMPAD_MEMORY_RECALL
'NumpadMemoryStore': 102,// GHOSTTY_KEY_NUMPAD_MEMORY_STORE
'NumpadMemorySubtract': 103,// GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT
'NumpadMultiply': 104, // GHOSTTY_KEY_NUMPAD_MULTIPLY
'NumpadParenLeft': 105, // GHOSTTY_KEY_NUMPAD_PAREN_LEFT
'NumpadParenRight': 106, // GHOSTTY_KEY_NUMPAD_PAREN_RIGHT
'NumpadSubtract': 107, // GHOSTTY_KEY_NUMPAD_SUBTRACT
'NumpadSeparator': 108, // GHOSTTY_KEY_NUMPAD_SEPARATOR
'NumpadUp': 109, // GHOSTTY_KEY_NUMPAD_UP
'NumpadDown': 110, // GHOSTTY_KEY_NUMPAD_DOWN
'NumpadRight': 111, // GHOSTTY_KEY_NUMPAD_RIGHT
'NumpadLeft': 112, // GHOSTTY_KEY_NUMPAD_LEFT
'NumpadBegin': 113, // GHOSTTY_KEY_NUMPAD_BEGIN
'NumpadHome': 114, // GHOSTTY_KEY_NUMPAD_HOME
'NumpadEnd': 115, // GHOSTTY_KEY_NUMPAD_END
'NumpadInsert': 116, // GHOSTTY_KEY_NUMPAD_INSERT
'NumpadDelete': 117, // GHOSTTY_KEY_NUMPAD_DELETE
'NumpadPageUp': 118, // GHOSTTY_KEY_NUMPAD_PAGE_UP
'NumpadPageDown': 119, // GHOSTTY_KEY_NUMPAD_PAGE_DOWN
// Function Section
'Escape': 120, // GHOSTTY_KEY_ESCAPE
'F1': 121, // GHOSTTY_KEY_F1
'F2': 122, // GHOSTTY_KEY_F2
'F3': 123, // GHOSTTY_KEY_F3
'F4': 124, // GHOSTTY_KEY_F4
'F5': 125, // GHOSTTY_KEY_F5
'F6': 126, // GHOSTTY_KEY_F6
'F7': 127, // GHOSTTY_KEY_F7
'F8': 128, // GHOSTTY_KEY_F8
'F9': 129, // GHOSTTY_KEY_F9
'F10': 130, // GHOSTTY_KEY_F10
'F11': 131, // GHOSTTY_KEY_F11
'F12': 132, // GHOSTTY_KEY_F12
'F13': 133, // GHOSTTY_KEY_F13
'F14': 134, // GHOSTTY_KEY_F14
'F15': 135, // GHOSTTY_KEY_F15
'F16': 136, // GHOSTTY_KEY_F16
'F17': 137, // GHOSTTY_KEY_F17
'F18': 138, // GHOSTTY_KEY_F18
'F19': 139, // GHOSTTY_KEY_F19
'F20': 140, // GHOSTTY_KEY_F20
'F21': 141, // GHOSTTY_KEY_F21
'F22': 142, // GHOSTTY_KEY_F22
'F23': 143, // GHOSTTY_KEY_F23
'F24': 144, // GHOSTTY_KEY_F24
'F25': 145, // GHOSTTY_KEY_F25
'Fn': 146, // GHOSTTY_KEY_FN
'FnLock': 147, // GHOSTTY_KEY_FN_LOCK
'PrintScreen': 148, // GHOSTTY_KEY_PRINT_SCREEN
'ScrollLock': 149, // GHOSTTY_KEY_SCROLL_LOCK
'Pause': 150, // GHOSTTY_KEY_PAUSE
// Media Keys
'BrowserBack': 151, // GHOSTTY_KEY_BROWSER_BACK
'BrowserFavorites': 152, // GHOSTTY_KEY_BROWSER_FAVORITES
'BrowserForward': 153, // GHOSTTY_KEY_BROWSER_FORWARD
'BrowserHome': 154, // GHOSTTY_KEY_BROWSER_HOME
'BrowserRefresh': 155, // GHOSTTY_KEY_BROWSER_REFRESH
'BrowserSearch': 156, // GHOSTTY_KEY_BROWSER_SEARCH
'BrowserStop': 157, // GHOSTTY_KEY_BROWSER_STOP
'Eject': 158, // GHOSTTY_KEY_EJECT
'LaunchApp1': 159, // GHOSTTY_KEY_LAUNCH_APP_1
'LaunchApp2': 160, // GHOSTTY_KEY_LAUNCH_APP_2
'LaunchMail': 161, // GHOSTTY_KEY_LAUNCH_MAIL
'MediaPlayPause': 162, // GHOSTTY_KEY_MEDIA_PLAY_PAUSE
'MediaSelect': 163, // GHOSTTY_KEY_MEDIA_SELECT
'MediaStop': 164, // GHOSTTY_KEY_MEDIA_STOP
'MediaTrackNext': 165, // GHOSTTY_KEY_MEDIA_TRACK_NEXT
'MediaTrackPrevious': 166,// GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS
'Power': 167, // GHOSTTY_KEY_POWER
'Sleep': 168, // GHOSTTY_KEY_SLEEP
'AudioVolumeDown': 169, // GHOSTTY_KEY_AUDIO_VOLUME_DOWN
'AudioVolumeMute': 170, // GHOSTTY_KEY_AUDIO_VOLUME_MUTE
'AudioVolumeUp': 171, // GHOSTTY_KEY_AUDIO_VOLUME_UP
'WakeUp': 172, // GHOSTTY_KEY_WAKE_UP
// Legacy, Non-standard, and Special Keys
'Copy': 173, // GHOSTTY_KEY_COPY
'Cut': 174, // GHOSTTY_KEY_CUT
'Paste': 175, // GHOSTTY_KEY_PASTE
};
function encodeKeyEvent(event) {
if (!encoderPtr) return null;
try {
// Create key event
const eventPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
const result = wasmInstance.exports.ghostty_key_event_new(0, eventPtrPtr);
if (result !== 0) {
throw new Error(`ghostty_key_event_new failed with result ${result}`);
}
const eventPtr = new DataView(getBuffer()).getUint32(eventPtrPtr, true);
// Get action from radio buttons
const actionRadio = document.querySelector('input[name="action"]:checked');
const action = parseInt(actionRadio.value);
wasmInstance.exports.ghostty_key_event_set_action(eventPtr, action);
// Map key code from event.code (preferred, layout-independent)
let keyCode = keyCodeMap[event.code] || 0; // GHOSTTY_KEY_UNIDENTIFIED = 0
wasmInstance.exports.ghostty_key_event_set_key(eventPtr, keyCode);
// Map modifiers with left/right side information
let mods = 0;
if (event.shiftKey) {
mods |= 0x01; // GHOSTTY_MODS_SHIFT
if (event.code === 'ShiftRight') mods |= 0x40; // GHOSTTY_MODS_SHIFT_SIDE
}
if (event.ctrlKey) {
mods |= 0x02; // GHOSTTY_MODS_CTRL
if (event.code === 'ControlRight') mods |= 0x80; // GHOSTTY_MODS_CTRL_SIDE
}
if (event.altKey) {
mods |= 0x04; // GHOSTTY_MODS_ALT
if (event.code === 'AltRight') mods |= 0x100; // GHOSTTY_MODS_ALT_SIDE
}
if (event.metaKey) {
mods |= 0x08; // GHOSTTY_MODS_SUPER
if (event.code === 'MetaRight') mods |= 0x200; // GHOSTTY_MODS_SUPER_SIDE
}
wasmInstance.exports.ghostty_key_event_set_mods(eventPtr, mods);
// Set UTF-8 text from the key event (the actual character produced)
if (event.key.length === 1) {
const utf8Bytes = new TextEncoder().encode(event.key);
const utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc_buffer(utf8Bytes.length);
new Uint8Array(getBuffer()).set(utf8Bytes, utf8Ptr);
wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length);
}
// Set unshifted codepoint
const unshiftedCodepoint = getUnshiftedCodepoint(event);
if (unshiftedCodepoint !== 0) {
wasmInstance.exports.ghostty_key_event_set_unshifted_codepoint(eventPtr, unshiftedCodepoint);
}
// Encode the key event
const requiredPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
wasmInstance.exports.ghostty_key_encoder_encode(
encoderPtr, eventPtr, 0, 0, requiredPtr
);
const required = new DataView(getBuffer()).getUint32(requiredPtr, true);
const bufPtr = wasmInstance.exports.ghostty_wasm_alloc_buffer(required);
const writtenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
const encodeResult = wasmInstance.exports.ghostty_key_encoder_encode(
encoderPtr, eventPtr, bufPtr, required, writtenPtr
);
if (encodeResult !== 0) {
return null; // No encoding for this key
}
const written = new DataView(getBuffer()).getUint32(writtenPtr, true);
const encoded = new Uint8Array(getBuffer()).slice(bufPtr, bufPtr + written);
return {
bytes: Array.from(encoded),
hex: formatHex(encoded),
string: formatString(encoded)
};
} catch (e) {
console.error('Encoding error:', e);
return null;
}
}
function getUnshiftedCodepoint(event) {
// Derive unshifted codepoint from the physical key code
const code = event.code;
// Letter keys (KeyA-KeyZ) -> lowercase letters
if (code.startsWith('Key')) {
const letter = code.substring(3).toLowerCase();
return letter.codePointAt(0);
}
// Digit keys (Digit0-Digit9) -> the digit itself
if (code.startsWith('Digit')) {
const digit = code.substring(5);
return digit.codePointAt(0);
}
// Space
if (code === 'Space') {
return ' '.codePointAt(0);
}
// Symbol keys -> unshifted character
const unshiftedSymbols = {
'Minus': '-', 'Equal': '=', 'BracketLeft': '[', 'BracketRight': ']',
'Backslash': '\\', 'Semicolon': ';', 'Quote': "'",
'Backquote': '`', 'Comma': ',', 'Period': '.', 'Slash': '/'
};
if (unshiftedSymbols[code]) {
return unshiftedSymbols[code].codePointAt(0);
}
// Fallback: use the produced character's codepoint
if (event.key.length > 0) {
return event.key.codePointAt(0) || 0;
}
return 0;
}
function getKittyFlags() {
let flags = 0;
if (document.getElementById('flag_disambiguate').checked) flags |= 0x01;
if (document.getElementById('flag_report_events').checked) flags |= 0x02;
if (document.getElementById('flag_report_alternates').checked) flags |= 0x04;
if (document.getElementById('flag_report_all_as_escapes').checked) flags |= 0x08;
if (document.getElementById('flag_report_text').checked) flags |= 0x10;
return flags;
}
function updateEncoderFlags() {
if (!encoderPtr) return;
const flags = getKittyFlags();
const flagsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
new DataView(getBuffer()).setUint8(flagsPtr, flags);
wasmInstance.exports.ghostty_key_encoder_setopt(
encoderPtr,
5, // GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS
flagsPtr
);
// Re-encode last key with new flags
reencodeLastKey();
}
function displayEncoding(event) {
const outputDiv = document.getElementById('output');
const encoded = encodeKeyEvent(event);
const actionRadio = document.querySelector('input[name="action"]:checked');
const actionName = actionRadio.parentElement.textContent.trim();
let output = `Action: ${actionName}\n`;
output += `Key: ${event.key} (code: ${event.code})\n`;
output += `Modifiers: `;
const mods = [];
if (event.shiftKey) mods.push('Shift');
if (event.ctrlKey) mods.push('Ctrl');
if (event.altKey) mods.push('Alt');
if (event.metaKey) mods.push('Meta');
output += mods.length ? mods.join('+') : 'none';
output += '\n';
// Show Kitty flags state
const flags = [];
if (document.getElementById('flag_disambiguate').checked) flags.push('Disambiguate');
if (document.getElementById('flag_report_events').checked) flags.push('Report Events');
if (document.getElementById('flag_report_alternates').checked) flags.push('Report Alternates');
if (document.getElementById('flag_report_all_as_escapes').checked) flags.push('Report All As Escapes');
if (document.getElementById('flag_report_text').checked) flags.push('Report Text');
output += 'Kitty Flags:\n';
if (flags.length) {
flags.forEach(flag => output += ` - ${flag}\n`);
} else {
output += ' - none\n';
}
output += '\n';
if (encoded) {
output += `Encoded ${encoded.bytes.length} bytes\n`;
output += `Hex: ${encoded.hex}\n`;
output += `String: ${encoded.string}`;
} else {
output += 'No encoding for this key event';
}
outputDiv.textContent = output;
}
function handleKeyEvent(event) {
// Allow modifier keys to be pressed without clearing input
// Only prevent default for keys we want to capture
if (event.key !== 'Tab' && event.key !== 'F5') {
event.preventDefault();
}
lastKeyEvent = event;
displayEncoding(event);
}
function reencodeLastKey() {
if (lastKeyEvent) {
displayEncoding(lastKeyEvent);
}
}
async function init() {
const statusDiv = document.getElementById('status');
const keyInput = document.getElementById('keyInput');
const outputDiv = document.getElementById('output');
try {
statusDiv.textContent = 'Loading WebAssembly module...';
const loaded = await loadWasm();
if (!loaded) {
throw new Error('Failed to load WebAssembly module');
}
// Create key encoder
const encoderPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
const result = wasmInstance.exports.ghostty_key_encoder_new(0, encoderPtrPtr);
if (result !== 0) {
throw new Error(`ghostty_key_encoder_new failed with result ${result}`);
}
encoderPtr = new DataView(getBuffer()).getUint32(encoderPtrPtr, true);
// Set kitty flags based on checkboxes
updateEncoderFlags();
statusDiv.textContent = '';
keyInput.disabled = false;
keyInput.focus();
// Listen for key events (only keydown since action is selected manually)
keyInput.addEventListener('keydown', handleKeyEvent);
// Listen for flag changes
const flagCheckboxes = document.querySelectorAll('.checkbox-group input[type="checkbox"]');
flagCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', updateEncoderFlags);
});
// Listen for action changes
const actionRadios = document.querySelectorAll('input[name="action"]');
actionRadios.forEach(radio => {
radio.addEventListener('change', reencodeLastKey);
});
} catch (e) {
statusDiv.textContent = `Error: ${e.message}`;
statusDiv.style.color = '#c00';
outputDiv.className = 'output error';
outputDiv.textContent = `Error: ${e.message}\n\nStack trace:\n${e.stack}`;
}
}
// Initialize on page load
window.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>

View File

@ -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 <ghostty/vt/osc.h>
#include <ghostty/vt/key.h>
#include <ghostty/vt/paste.h>
#include <ghostty/vt/wasm.h>
#ifdef __cplusplus
}

141
include/ghostty/vt/wasm.h Normal file
View File

@ -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 <stddef.h>
#include <stdint.h>
/** @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 */

View File

@ -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,

View File

@ -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;

View File

@ -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);
}

View File

@ -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" });
}
}
}

View File

@ -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.*;
},
}
}

View File

@ -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;
}

View File

@ -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_,