mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 13:30:29 +00:00
libghostty: WASM VT example, add ghostty_type_json (#11992)
This adds a new `example/wasm-vt` example that initializes a terminal, lets you write text to write to it, and shows you the screen state. In doing so, I realized that writing structs in WASM is extremely painful. You had to do manually hardcoded sizes and byte offsets and it's scary as hell! So I added a new `ghostty_type_json` API that returns a C string with JSON-encoded type information about all exported C structures. ## Example <img width="1912" height="1574" alt="CleanShot 2026-03-30 at 10 20 16@2x" src="https://github.com/user-attachments/assets/7cae92bc-3403-4e4c-958c-b7ea58026afe" />
This commit is contained in:
39
example/wasm-vt/README.md
Normal file
39
example/wasm-vt/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# WebAssembly VT Terminal Example
|
||||
|
||||
This example demonstrates how to use the Ghostty VT library from WebAssembly
|
||||
to initialize a terminal, write VT-encoded data to it, and format the
|
||||
terminal contents as plain text.
|
||||
|
||||
## Building
|
||||
|
||||
First, build the WebAssembly module:
|
||||
|
||||
```bash
|
||||
zig build -Demit-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-vt/
|
||||
```
|
||||
342
example/wasm-vt/index.html
Normal file
342
example/wasm-vt/index.html
Normal file
@@ -0,0 +1,342 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ghostty VT Terminal - WebAssembly Example</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
.input-section {
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.input-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
button {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.output {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
.error {
|
||||
background: #fee;
|
||||
border-color: #faa;
|
||||
color: #c00;
|
||||
}
|
||||
.status {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.size-controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.size-controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.size-controls input[type="number"] {
|
||||
width: 60px;
|
||||
padding: 4px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Ghostty VT Terminal - WebAssembly Example</h1>
|
||||
<p>This example demonstrates initializing a terminal, writing VT-encoded data to it, and formatting the output using the Ghostty VT WebAssembly module.</p>
|
||||
|
||||
<div class="status" id="status">Loading WebAssembly module...</div>
|
||||
|
||||
<div class="input-section">
|
||||
<h3>Terminal Size</h3>
|
||||
<div class="size-controls">
|
||||
<label>Cols: <input type="number" id="cols" value="80" min="1" max="500" disabled></label>
|
||||
<label>Rows: <input type="number" id="rows" value="24" min="1" max="500" disabled></label>
|
||||
</div>
|
||||
<h3>VT Input</h3>
|
||||
<textarea id="vtInput" rows="6" disabled>Hello, World!\r\n\x1b[1;32mGreen Bold\x1b[0m and \x1b[4mUnderline\x1b[0m\r\nLine 3: placeholder\r\n\x1b[3;1H\x1b[2KLine 3: Overwritten!</textarea>
|
||||
<p style="font-size: 13px; color: #666; margin-top: 5px;">Use \x1b for ESC, \r\n for CR+LF. Press "Run" to process.</p>
|
||||
<button id="runBtn" disabled>Run</button>
|
||||
</div>
|
||||
|
||||
<div id="output" class="output">Waiting for input...</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 typeLayout = null;
|
||||
|
||||
async function loadWasm() {
|
||||
try {
|
||||
const response = await fetch('../../zig-out/bin/ghostty-vt.wasm');
|
||||
const wasmBytes = await response.arrayBuffer();
|
||||
|
||||
const wasmModule = await WebAssembly.instantiate(wasmBytes, {
|
||||
env: {
|
||||
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;
|
||||
|
||||
// Load the type layout JSON from the library
|
||||
const jsonPtr = wasmInstance.exports.ghostty_type_json();
|
||||
const jsonStr = new TextDecoder().decode(
|
||||
new Uint8Array(wasmMemory.buffer, jsonPtr, wasmMemory.buffer.byteLength - jsonPtr)
|
||||
).split('\0')[0];
|
||||
typeLayout = JSON.parse(jsonStr);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up a field's offset and DataView setter from the type layout JSON.
|
||||
function fieldInfo(structName, fieldName) {
|
||||
const field = typeLayout[structName].fields[fieldName];
|
||||
return field;
|
||||
}
|
||||
|
||||
// Set a field in a DataView using the type layout JSON metadata.
|
||||
function setField(view, structName, fieldName, value) {
|
||||
const field = fieldInfo(structName, fieldName);
|
||||
switch (field.type) {
|
||||
case 'u8': case 'bool': view.setUint8(field.offset, value); break;
|
||||
case 'u16': view.setUint16(field.offset, value, true); break;
|
||||
case 'u32': case 'enum': view.setUint32(field.offset, value, true); break;
|
||||
case 'u64': view.setBigUint64(field.offset, BigInt(value), true); break;
|
||||
default: throw new Error(`Unsupported field type: ${field.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getBuffer() {
|
||||
return wasmMemory.buffer;
|
||||
}
|
||||
|
||||
// Parse escape sequences in the input string (e.g. \x1b, \r, \n)
|
||||
function parseEscapes(str) {
|
||||
return str
|
||||
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
// GHOSTTY_FORMATTER_FORMAT_PLAIN = 0
|
||||
const GHOSTTY_FORMATTER_FORMAT_PLAIN = 0;
|
||||
// GHOSTTY_SUCCESS = 0
|
||||
const GHOSTTY_SUCCESS = 0;
|
||||
|
||||
function run() {
|
||||
const outputDiv = document.getElementById('output');
|
||||
|
||||
try {
|
||||
const cols = parseInt(document.getElementById('cols').value, 10);
|
||||
const rows = parseInt(document.getElementById('rows').value, 10);
|
||||
const vtText = parseEscapes(document.getElementById('vtInput').value);
|
||||
|
||||
const TERM_OPTS_SIZE = typeLayout['GhosttyTerminalOptions'].size;
|
||||
const optsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(TERM_OPTS_SIZE);
|
||||
new Uint8Array(getBuffer(), optsPtr, TERM_OPTS_SIZE).fill(0);
|
||||
const optsView = new DataView(getBuffer(), optsPtr, TERM_OPTS_SIZE);
|
||||
setField(optsView, 'GhosttyTerminalOptions', 'cols', cols);
|
||||
setField(optsView, 'GhosttyTerminalOptions', 'rows', rows);
|
||||
setField(optsView, 'GhosttyTerminalOptions', 'max_scrollback', 0);
|
||||
|
||||
// Allocate pointer to receive the terminal handle
|
||||
const termPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
|
||||
|
||||
// Create terminal
|
||||
const newResult = wasmInstance.exports.ghostty_terminal_new(0, termPtrPtr, optsPtr);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8_array(optsPtr, TERM_OPTS_SIZE);
|
||||
|
||||
if (newResult !== GHOSTTY_SUCCESS) {
|
||||
throw new Error(`ghostty_terminal_new failed with result ${newResult}`);
|
||||
}
|
||||
|
||||
const termPtr = new DataView(getBuffer()).getUint32(termPtrPtr, true);
|
||||
wasmInstance.exports.ghostty_wasm_free_opaque(termPtrPtr);
|
||||
|
||||
// Write VT data to the terminal
|
||||
const vtBytes = new TextEncoder().encode(vtText);
|
||||
const dataPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(vtBytes.length);
|
||||
new Uint8Array(getBuffer()).set(vtBytes, dataPtr);
|
||||
wasmInstance.exports.ghostty_terminal_vt_write(termPtr, dataPtr, vtBytes.length);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8_array(dataPtr, vtBytes.length);
|
||||
|
||||
// Create a plain-text formatter
|
||||
const FMT_OPTS_SIZE = typeLayout['GhosttyFormatterTerminalOptions'].size;
|
||||
const fmtOptsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(FMT_OPTS_SIZE);
|
||||
new Uint8Array(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE).fill(0);
|
||||
const fmtOptsView = new DataView(getBuffer(), fmtOptsPtr, FMT_OPTS_SIZE);
|
||||
setField(fmtOptsView, 'GhosttyFormatterTerminalOptions', 'size', FMT_OPTS_SIZE);
|
||||
setField(fmtOptsView, 'GhosttyFormatterTerminalOptions', 'emit', GHOSTTY_FORMATTER_FORMAT_PLAIN);
|
||||
setField(fmtOptsView, 'GhosttyFormatterTerminalOptions', 'unwrap', 0);
|
||||
setField(fmtOptsView, 'GhosttyFormatterTerminalOptions', 'trim', 1);
|
||||
|
||||
// Set the nested sized-struct `size` fields for extra and extra.screen
|
||||
const extraOffset = fieldInfo('GhosttyFormatterTerminalOptions', 'extra').offset;
|
||||
const extraSize = typeLayout['GhosttyFormatterTerminalExtra'].size;
|
||||
const extraSizeField = fieldInfo('GhosttyFormatterTerminalExtra', 'size');
|
||||
fmtOptsView.setUint32(extraOffset + extraSizeField.offset, extraSize, true);
|
||||
|
||||
const screenOffset = fieldInfo('GhosttyFormatterTerminalExtra', 'screen').offset;
|
||||
const screenSize = typeLayout['GhosttyFormatterScreenExtra'].size;
|
||||
const screenSizeField = fieldInfo('GhosttyFormatterScreenExtra', 'size');
|
||||
fmtOptsView.setUint32(extraOffset + screenOffset + screenSizeField.offset, screenSize, true);
|
||||
|
||||
const fmtPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
|
||||
const fmtResult = wasmInstance.exports.ghostty_formatter_terminal_new(
|
||||
0, fmtPtrPtr, termPtr, fmtOptsPtr
|
||||
);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8_array(fmtOptsPtr, FMT_OPTS_SIZE);
|
||||
|
||||
if (fmtResult !== GHOSTTY_SUCCESS) {
|
||||
wasmInstance.exports.ghostty_terminal_free(termPtr);
|
||||
throw new Error(`ghostty_formatter_terminal_new failed with result ${fmtResult}`);
|
||||
}
|
||||
|
||||
const fmtPtr = new DataView(getBuffer()).getUint32(fmtPtrPtr, true);
|
||||
wasmInstance.exports.ghostty_wasm_free_opaque(fmtPtrPtr);
|
||||
|
||||
// Format with alloc
|
||||
const outPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
|
||||
const outLenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
|
||||
const formatResult = wasmInstance.exports.ghostty_formatter_format_alloc(
|
||||
fmtPtr, 0, outPtrPtr, outLenPtr
|
||||
);
|
||||
|
||||
if (formatResult !== GHOSTTY_SUCCESS) {
|
||||
wasmInstance.exports.ghostty_formatter_free(fmtPtr);
|
||||
wasmInstance.exports.ghostty_terminal_free(termPtr);
|
||||
throw new Error(`ghostty_formatter_format_alloc failed with result ${formatResult}`);
|
||||
}
|
||||
|
||||
const outPtr = new DataView(getBuffer()).getUint32(outPtrPtr, true);
|
||||
const outLen = new DataView(getBuffer()).getUint32(outLenPtr, true);
|
||||
|
||||
const outBytes = new Uint8Array(getBuffer(), outPtr, outLen);
|
||||
const outText = new TextDecoder().decode(outBytes);
|
||||
|
||||
let output = `Terminal: ${cols}x${rows}\n`;
|
||||
output += `Input: ${vtBytes.length} bytes\n`;
|
||||
output += `Output: ${outLen} bytes\n\n`;
|
||||
output += outText;
|
||||
|
||||
outputDiv.className = 'output';
|
||||
outputDiv.textContent = output;
|
||||
|
||||
// Clean up
|
||||
wasmInstance.exports.ghostty_free(0, outPtr, outLen);
|
||||
wasmInstance.exports.ghostty_wasm_free_opaque(outPtrPtr);
|
||||
wasmInstance.exports.ghostty_wasm_free_usize(outLenPtr);
|
||||
wasmInstance.exports.ghostty_formatter_free(fmtPtr);
|
||||
wasmInstance.exports.ghostty_terminal_free(termPtr);
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
outputDiv.className = 'output error';
|
||||
outputDiv.textContent = `Error: ${e.message}\n\nStack trace:\n${e.stack}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const statusDiv = document.getElementById('status');
|
||||
const vtInput = document.getElementById('vtInput');
|
||||
const colsInput = document.getElementById('cols');
|
||||
const rowsInput = document.getElementById('rows');
|
||||
const runBtn = document.getElementById('runBtn');
|
||||
|
||||
try {
|
||||
statusDiv.textContent = 'Loading WebAssembly module...';
|
||||
|
||||
const loaded = await loadWasm();
|
||||
if (!loaded) {
|
||||
throw new Error('Failed to load WebAssembly module');
|
||||
}
|
||||
|
||||
statusDiv.textContent = '';
|
||||
vtInput.disabled = false;
|
||||
colsInput.disabled = false;
|
||||
rowsInput.disabled = false;
|
||||
runBtn.disabled = false;
|
||||
|
||||
runBtn.addEventListener('click', run);
|
||||
|
||||
// Run the default example on load
|
||||
run();
|
||||
return;
|
||||
} catch (e) {
|
||||
statusDiv.textContent = `Error: ${e.message}`;
|
||||
statusDiv.style.color = '#c00';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -83,4 +83,39 @@ typedef struct {
|
||||
#define GHOSTTY_INIT_SIZED(type) \
|
||||
((type){ .size = sizeof(type) })
|
||||
|
||||
/**
|
||||
* Return a pointer to a null-terminated JSON string describing the
|
||||
* layout of every C API struct for the current target.
|
||||
*
|
||||
* This is primarily useful for language bindings that can't easily
|
||||
* set C struct fields and need to do so via byte offsets. For example,
|
||||
* WebAssembly modules can't share struct definitions with the host.
|
||||
*
|
||||
* Example (abbreviated):
|
||||
* @code{.json}
|
||||
* {
|
||||
* "GhosttyMouseEncoderSize": {
|
||||
* "size": 40,
|
||||
* "align": 8,
|
||||
* "fields": {
|
||||
* "size": { "offset": 0, "size": 8, "type": "u64" },
|
||||
* "screen_width": { "offset": 8, "size": 4, "type": "u32" },
|
||||
* "screen_height": { "offset": 12, "size": 4, "type": "u32" },
|
||||
* "cell_width": { "offset": 16, "size": 4, "type": "u32" },
|
||||
* "cell_height": { "offset": 20, "size": 4, "type": "u32" },
|
||||
* "padding_top": { "offset": 24, "size": 4, "type": "u32" },
|
||||
* "padding_bottom": { "offset": 28, "size": 4, "type": "u32" },
|
||||
* "padding_right": { "offset": 32, "size": 4, "type": "u32" },
|
||||
* "padding_left": { "offset": 36, "size": 4, "type": "u32" }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* @endcode
|
||||
*
|
||||
* The returned pointer is valid for the lifetime of the process.
|
||||
*
|
||||
* @return Pointer to the null-terminated JSON string.
|
||||
*/
|
||||
GHOSTTY_EXPORT const char *ghostty_type_json(void);
|
||||
|
||||
#endif /* GHOSTTY_VT_TYPES_H */
|
||||
|
||||
@@ -219,6 +219,7 @@ comptime {
|
||||
@export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" });
|
||||
@export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" });
|
||||
@export(&c.build_info, .{ .name = "ghostty_build_info" });
|
||||
@export(&c.type_json, .{ .name = "ghostty_type_json" });
|
||||
@export(&c.alloc_alloc, .{ .name = "ghostty_alloc" });
|
||||
@export(&c.alloc_free, .{ .name = "ghostty_free" });
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ pub const cell = @import("cell.zig");
|
||||
pub const color = @import("color.zig");
|
||||
pub const focus = @import("focus.zig");
|
||||
pub const formatter = @import("formatter.zig");
|
||||
pub const grid_ref = @import("grid_ref.zig");
|
||||
pub const types = @import("types.zig");
|
||||
pub const modes = @import("modes.zig");
|
||||
pub const osc = @import("osc.zig");
|
||||
pub const render = @import("render.zig");
|
||||
@@ -141,7 +143,8 @@ pub const terminal_mode_set = terminal.mode_set;
|
||||
pub const terminal_get = terminal.get;
|
||||
pub const terminal_grid_ref = terminal.grid_ref;
|
||||
|
||||
const grid_ref = @import("grid_ref.zig");
|
||||
pub const type_json = types.get_json;
|
||||
|
||||
pub const grid_ref_cell = grid_ref.grid_ref_cell;
|
||||
pub const grid_ref_row = grid_ref.grid_ref_row;
|
||||
pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes;
|
||||
@@ -168,6 +171,7 @@ test {
|
||||
_ = size_report;
|
||||
_ = style;
|
||||
_ = terminal;
|
||||
_ = types;
|
||||
|
||||
// We want to make sure we run the tests for the C allocator interface.
|
||||
_ = @import("../../lib/allocator.zig");
|
||||
|
||||
199
src/terminal/c/types.zig
Normal file
199
src/terminal/c/types.zig
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Comptime-generated metadata describing the layout of all C API
|
||||
//! extern structs for the current target.
|
||||
//!
|
||||
//! This is embedded in the binary as a const string and exposed via
|
||||
//! `ghostty_type_json` so that WASM (and other FFI) consumers can
|
||||
//! build structs without hardcoding byte offsets.
|
||||
const std = @import("std");
|
||||
const lib = @import("../lib.zig");
|
||||
|
||||
const terminal = @import("terminal.zig");
|
||||
const formatter = @import("formatter.zig");
|
||||
const render = @import("render.zig");
|
||||
const style_c = @import("style.zig");
|
||||
const mouse_encode = @import("mouse_encode.zig");
|
||||
const grid_ref = @import("grid_ref.zig");
|
||||
|
||||
/// All C API structs and their Ghostty C names.
|
||||
pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{
|
||||
.{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
|
||||
.{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
|
||||
.{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
|
||||
.{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
|
||||
.{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
|
||||
.{ "GhosttyStyle", StructInfo.init(style_c.Style) },
|
||||
.{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
|
||||
.{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) },
|
||||
.{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
|
||||
});
|
||||
|
||||
/// The comptime-generated JSON string of all structs.
|
||||
pub const json: [:0]const u8 = json: {
|
||||
@setEvalBranchQuota(50000);
|
||||
var counter: std.Io.Writer.Discarding = .init(&.{});
|
||||
jsonWriteAll(&counter.writer) catch unreachable;
|
||||
|
||||
var buf: [counter.count:0]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
jsonWriteAll(&writer) catch unreachable;
|
||||
const final = buf;
|
||||
break :json final[0..writer.end :0];
|
||||
};
|
||||
|
||||
/// Returns a pointer to the comptime-generated JSON string describing
|
||||
/// the layout of all C API extern structs, and writes its length to `len`.
|
||||
/// Exported as `ghostty_type_json` for FFI consumers.
|
||||
pub fn get_json() callconv(lib.calling_conv) [*:0]const u8 {
|
||||
return json.ptr;
|
||||
}
|
||||
|
||||
/// Meta information about a struct that we expose to ease writing
|
||||
/// bindings in some languages, particularly WASM where we can't
|
||||
/// easily share struct definitions and need to hardcode byte offsets.
|
||||
pub const StructInfo = struct {
|
||||
name: []const u8,
|
||||
size: usize,
|
||||
@"align": usize,
|
||||
fields: []const FieldInfo,
|
||||
|
||||
pub const FieldInfo = struct {
|
||||
name: []const u8,
|
||||
offset: usize,
|
||||
size: usize,
|
||||
type: []const u8,
|
||||
};
|
||||
|
||||
pub fn init(comptime T: type) StructInfo {
|
||||
comptime {
|
||||
const fields = @typeInfo(T).@"struct".fields;
|
||||
const field_infos: [fields.len]FieldInfo = blk: {
|
||||
var infos: [fields.len]FieldInfo = undefined;
|
||||
for (fields, 0..) |field, i| infos[i] = .{
|
||||
.name = field.name,
|
||||
.offset = @offsetOf(T, field.name),
|
||||
.size = @sizeOf(field.type),
|
||||
.type = typeName(field.type),
|
||||
};
|
||||
break :blk infos;
|
||||
};
|
||||
|
||||
return .{
|
||||
.name = @typeName(T),
|
||||
.size = @sizeOf(T),
|
||||
.@"align" = @alignOf(T),
|
||||
.fields = &field_infos,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jsonStringify(
|
||||
self: *const StructInfo,
|
||||
jws: anytype,
|
||||
) std.Io.Writer.Error!void {
|
||||
try jws.beginObject();
|
||||
try jws.objectField("size");
|
||||
try jws.write(self.size);
|
||||
try jws.objectField("align");
|
||||
try jws.write(self.@"align");
|
||||
try jws.objectField("fields");
|
||||
try jws.beginObject();
|
||||
for (self.fields) |field| {
|
||||
try jws.objectField(field.name);
|
||||
try jws.beginObject();
|
||||
try jws.objectField("offset");
|
||||
try jws.write(field.offset);
|
||||
try jws.objectField("size");
|
||||
try jws.write(field.size);
|
||||
try jws.objectField("type");
|
||||
try jws.write(field.type);
|
||||
try jws.endObject();
|
||||
}
|
||||
try jws.endObject();
|
||||
try jws.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
fn jsonWriteAll(writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
var jws: std.json.Stringify = .{ .writer = writer };
|
||||
try jws.beginObject();
|
||||
for (structs.keys(), structs.values()) |name, *info| {
|
||||
try jws.objectField(name);
|
||||
try info.jsonStringify(&jws);
|
||||
}
|
||||
try jws.endObject();
|
||||
}
|
||||
|
||||
fn typeName(comptime T: type) []const u8 {
|
||||
return switch (@typeInfo(T)) {
|
||||
.bool => "bool",
|
||||
.int => |info| switch (info.signedness) {
|
||||
.signed => switch (info.bits) {
|
||||
8 => "i8",
|
||||
16 => "i16",
|
||||
32 => "i32",
|
||||
64 => "i64",
|
||||
else => @compileError("unsupported signed int size"),
|
||||
},
|
||||
.unsigned => switch (info.bits) {
|
||||
8 => "u8",
|
||||
16 => "u16",
|
||||
32 => "u32",
|
||||
64 => "u64",
|
||||
else => @compileError("unsupported unsigned int size"),
|
||||
},
|
||||
},
|
||||
.@"enum" => "enum",
|
||||
.@"struct" => "struct",
|
||||
.pointer => "pointer",
|
||||
.array => "array",
|
||||
else => "opaque",
|
||||
};
|
||||
}
|
||||
|
||||
test "json parses" {
|
||||
const parsed = try std.json.parseFromSlice(
|
||||
std.json.Value,
|
||||
std.testing.allocator,
|
||||
json,
|
||||
.{},
|
||||
);
|
||||
defer parsed.deinit();
|
||||
|
||||
const root = parsed.value.object;
|
||||
|
||||
// Verify we have all expected structs
|
||||
try std.testing.expect(root.contains("GhosttyTerminalOptions"));
|
||||
try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions"));
|
||||
|
||||
// Verify GhosttyTerminalOptions fields
|
||||
const term_opts = root.get("GhosttyTerminalOptions").?.object;
|
||||
try std.testing.expect(term_opts.contains("size"));
|
||||
try std.testing.expect(term_opts.contains("align"));
|
||||
try std.testing.expect(term_opts.contains("fields"));
|
||||
|
||||
const fields = term_opts.get("fields").?.object;
|
||||
try std.testing.expect(fields.contains("cols"));
|
||||
try std.testing.expect(fields.contains("rows"));
|
||||
try std.testing.expect(fields.contains("max_scrollback"));
|
||||
|
||||
// Verify field offsets make sense (cols should be at 0)
|
||||
const cols = fields.get("cols").?.object;
|
||||
try std.testing.expectEqual(0, cols.get("offset").?.integer);
|
||||
}
|
||||
|
||||
test "struct sizes are non-zero" {
|
||||
const parsed = try std.json.parseFromSlice(
|
||||
std.json.Value,
|
||||
std.testing.allocator,
|
||||
json,
|
||||
.{},
|
||||
);
|
||||
defer parsed.deinit();
|
||||
|
||||
var it = parsed.value.object.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const struct_info = entry.value_ptr.object;
|
||||
const size = struct_info.get("size").?.integer;
|
||||
try std.testing.expect(size > 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user