mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-03 12:28:13 +08:00
lib-vt: Wasm SGR helpers and example (#9362)
This adds some convenience functions for parsing SGR sequences WebAssembly and adds an example demonstrating SGR parsing in the browser.
This commit is contained in:
committed by
GitHub
parent
19d1377659
commit
7d7c0bf5cd
@@ -458,7 +458,7 @@
|
||||
// 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);
|
||||
const utf8Ptr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(utf8Bytes.length);
|
||||
new Uint8Array(getBuffer()).set(utf8Bytes, utf8Ptr);
|
||||
wasmInstance.exports.ghostty_key_event_set_utf8(eventPtr, utf8Ptr, utf8Bytes.length);
|
||||
}
|
||||
@@ -477,7 +477,7 @@
|
||||
|
||||
const required = new DataView(getBuffer()).getUint32(requiredPtr, true);
|
||||
|
||||
const bufPtr = wasmInstance.exports.ghostty_wasm_alloc_buffer(required);
|
||||
const bufPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(required);
|
||||
const writtenPtr = wasmInstance.exports.ghostty_wasm_alloc_usize();
|
||||
const encodeResult = wasmInstance.exports.ghostty_key_encoder_encode(
|
||||
encoderPtr, eventPtr, bufPtr, required, writtenPtr
|
||||
|
||||
39
example/wasm-sgr/README.md
Normal file
39
example/wasm-sgr/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# WebAssembly SGR Parser Example
|
||||
|
||||
This example demonstrates how to use the Ghostty VT library from WebAssembly
|
||||
to parse terminal SGR (Select Graphic Rendition) sequences and extract text
|
||||
styling attributes.
|
||||
|
||||
## 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-sgr/
|
||||
```
|
||||
457
example/wasm-sgr/index.html
Normal file
457
example/wasm-sgr/index.html
Normal file
@@ -0,0 +1,457 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ghostty VT SGR Parser - 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;
|
||||
}
|
||||
.attribute {
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
background: white;
|
||||
border-left: 3px solid #0066cc;
|
||||
}
|
||||
.attribute-name {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Ghostty VT SGR Parser - WebAssembly Example</h1>
|
||||
<p>This example demonstrates parsing terminal SGR (Select Graphic Rendition) sequences using the Ghostty VT WebAssembly module.</p>
|
||||
|
||||
<div class="status" id="status">Loading WebAssembly module...</div>
|
||||
|
||||
<div class="input-section">
|
||||
<h3>SGR Sequence</h3>
|
||||
<label for="sequence">Enter SGR sequence (numbers separated by ':' or ';'):</label>
|
||||
<textarea id="sequence" rows="2" disabled>4:3;38;2;51;51;51;48;2;170;170;170;58;2;255;97;136</textarea>
|
||||
<p style="font-size: 13px; color: #666; margin-top: 5px;">The parser runs live as you type.</p>
|
||||
</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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// SGR attribute tag values from include/ghostty/vt/sgr.h
|
||||
const SGR_ATTR_TAGS = {
|
||||
UNSET: 0,
|
||||
UNKNOWN: 1,
|
||||
BOLD: 2,
|
||||
RESET_BOLD: 3,
|
||||
ITALIC: 4,
|
||||
RESET_ITALIC: 5,
|
||||
FAINT: 6,
|
||||
UNDERLINE: 7,
|
||||
RESET_UNDERLINE: 8,
|
||||
UNDERLINE_COLOR: 9,
|
||||
UNDERLINE_COLOR_256: 10,
|
||||
RESET_UNDERLINE_COLOR: 11,
|
||||
OVERLINE: 12,
|
||||
RESET_OVERLINE: 13,
|
||||
BLINK: 14,
|
||||
RESET_BLINK: 15,
|
||||
INVERSE: 16,
|
||||
RESET_INVERSE: 17,
|
||||
INVISIBLE: 18,
|
||||
RESET_INVISIBLE: 19,
|
||||
STRIKETHROUGH: 20,
|
||||
RESET_STRIKETHROUGH: 21,
|
||||
DIRECT_COLOR_FG: 22,
|
||||
DIRECT_COLOR_BG: 23,
|
||||
BG_8: 24,
|
||||
FG_8: 25,
|
||||
RESET_FG: 26,
|
||||
RESET_BG: 27,
|
||||
BRIGHT_BG_8: 28,
|
||||
BRIGHT_FG_8: 29,
|
||||
BG_256: 30,
|
||||
FG_256: 31
|
||||
};
|
||||
|
||||
// Underline style values
|
||||
const UNDERLINE_STYLES = {
|
||||
0: 'none',
|
||||
1: 'single',
|
||||
2: 'double',
|
||||
3: 'curly',
|
||||
4: 'dotted',
|
||||
5: 'dashed'
|
||||
};
|
||||
|
||||
function getTagName(tag) {
|
||||
for (const [name, value] of Object.entries(SGR_ATTR_TAGS)) {
|
||||
if (value === tag) return name;
|
||||
}
|
||||
return `UNKNOWN(${tag})`;
|
||||
}
|
||||
|
||||
function parseSGR() {
|
||||
const outputDiv = document.getElementById('output');
|
||||
|
||||
try {
|
||||
const sequenceText = document.getElementById('sequence').value.trim();
|
||||
|
||||
if (!sequenceText) {
|
||||
outputDiv.className = 'output';
|
||||
outputDiv.textContent = 'Enter an SGR sequence to parse...';
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the raw sequence into parameters and separators
|
||||
const params = [];
|
||||
const separators = [];
|
||||
let currentNum = '';
|
||||
|
||||
for (let i = 0; i < sequenceText.length; i++) {
|
||||
const char = sequenceText[i];
|
||||
|
||||
if (char === ':' || char === ';') {
|
||||
if (currentNum) {
|
||||
const num = parseInt(currentNum, 10);
|
||||
if (isNaN(num) || num < 0 || num > 65535) {
|
||||
throw new Error(`Invalid parameter: ${currentNum}`);
|
||||
}
|
||||
params.push(num);
|
||||
separators.push(char);
|
||||
currentNum = '';
|
||||
}
|
||||
} else if (char >= '0' && char <= '9') {
|
||||
currentNum += char;
|
||||
} else if (char !== ' ' && char !== '\t' && char !== '\n') {
|
||||
throw new Error(`Invalid character in sequence: '${char}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last number
|
||||
if (currentNum) {
|
||||
const num = parseInt(currentNum, 10);
|
||||
if (isNaN(num) || num < 0 || num > 65535) {
|
||||
throw new Error(`Invalid parameter: ${currentNum}`);
|
||||
}
|
||||
params.push(num);
|
||||
}
|
||||
|
||||
if (params.length === 0) {
|
||||
outputDiv.className = 'output error';
|
||||
outputDiv.textContent = 'Error: No parameters found in sequence';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create SGR parser
|
||||
const parserPtrPtr = wasmInstance.exports.ghostty_wasm_alloc_opaque();
|
||||
const result = wasmInstance.exports.ghostty_sgr_new(0, parserPtrPtr);
|
||||
|
||||
if (result !== 0) {
|
||||
throw new Error(`ghostty_sgr_new failed with result ${result}`);
|
||||
}
|
||||
|
||||
const parserPtr = new DataView(getBuffer()).getUint32(parserPtrPtr, true);
|
||||
|
||||
// Allocate and set parameters
|
||||
const paramsPtr = wasmInstance.exports.ghostty_wasm_alloc_u16_array(params.length);
|
||||
const paramsView = new Uint16Array(getBuffer(), paramsPtr, params.length);
|
||||
params.forEach((p, i) => paramsView[i] = p);
|
||||
|
||||
// Allocate and set separators (or use null if empty)
|
||||
let sepsPtr = 0;
|
||||
if (separators.length > 0) {
|
||||
sepsPtr = wasmInstance.exports.ghostty_wasm_alloc_u8_array(separators.length);
|
||||
const sepsView = new Uint8Array(getBuffer(), sepsPtr, separators.length);
|
||||
separators.forEach((s, i) => sepsView[i] = s.charCodeAt(0));
|
||||
}
|
||||
|
||||
// Set parameters in parser
|
||||
const setResult = wasmInstance.exports.ghostty_sgr_set_params(
|
||||
parserPtr,
|
||||
paramsPtr,
|
||||
sepsPtr,
|
||||
params.length
|
||||
);
|
||||
|
||||
if (setResult !== 0) {
|
||||
throw new Error(`ghostty_sgr_set_params failed with result ${setResult}`);
|
||||
}
|
||||
|
||||
// Build output
|
||||
let output = 'Parsing SGR sequence:\n';
|
||||
output += 'ESC[';
|
||||
params.forEach((p, i) => {
|
||||
if (i > 0) output += separators[i - 1];
|
||||
output += p;
|
||||
});
|
||||
output += 'm\n\n';
|
||||
|
||||
// Iterate through attributes
|
||||
const attrPtr = wasmInstance.exports.ghostty_wasm_alloc_sgr_attribute();
|
||||
let count = 0;
|
||||
|
||||
while (wasmInstance.exports.ghostty_sgr_next(parserPtr, attrPtr)) {
|
||||
count++;
|
||||
|
||||
// Use the new ghostty_sgr_attribute_tag getter function
|
||||
const tag = wasmInstance.exports.ghostty_sgr_attribute_tag(attrPtr);
|
||||
|
||||
// Use ghostty_sgr_attribute_value to get a pointer to the value union
|
||||
const valuePtr = wasmInstance.exports.ghostty_sgr_attribute_value(attrPtr);
|
||||
|
||||
output += `Attribute ${count}: `;
|
||||
|
||||
switch (tag) {
|
||||
case SGR_ATTR_TAGS.UNDERLINE: {
|
||||
const view = new DataView(getBuffer(), valuePtr, 4);
|
||||
const style = view.getUint32(0, true);
|
||||
output += `Underline style = ${UNDERLINE_STYLES[style] || `unknown(${style})`}\n`;
|
||||
break;
|
||||
}
|
||||
|
||||
case SGR_ATTR_TAGS.DIRECT_COLOR_FG: {
|
||||
// Use ghostty_color_rgb_get to extract RGB components
|
||||
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
|
||||
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
|
||||
|
||||
const r = new Uint8Array(getBuffer(), rPtr, 1)[0];
|
||||
const g = new Uint8Array(getBuffer(), gPtr, 1)[0];
|
||||
const b = new Uint8Array(getBuffer(), bPtr, 1)[0];
|
||||
|
||||
output += `Foreground RGB = (${r}, ${g}, ${b})\n`;
|
||||
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
|
||||
break;
|
||||
}
|
||||
|
||||
case SGR_ATTR_TAGS.DIRECT_COLOR_BG: {
|
||||
// Use ghostty_color_rgb_get to extract RGB components
|
||||
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
|
||||
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
|
||||
|
||||
const r = new Uint8Array(getBuffer(), rPtr, 1)[0];
|
||||
const g = new Uint8Array(getBuffer(), gPtr, 1)[0];
|
||||
const b = new Uint8Array(getBuffer(), bPtr, 1)[0];
|
||||
|
||||
output += `Background RGB = (${r}, ${g}, ${b})\n`;
|
||||
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
|
||||
break;
|
||||
}
|
||||
|
||||
case SGR_ATTR_TAGS.UNDERLINE_COLOR: {
|
||||
// Use ghostty_color_rgb_get to extract RGB components
|
||||
const rPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
const gPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
const bPtr = wasmInstance.exports.ghostty_wasm_alloc_u8();
|
||||
|
||||
wasmInstance.exports.ghostty_color_rgb_get(valuePtr, rPtr, gPtr, bPtr);
|
||||
|
||||
const r = new Uint8Array(getBuffer(), rPtr, 1)[0];
|
||||
const g = new Uint8Array(getBuffer(), gPtr, 1)[0];
|
||||
const b = new Uint8Array(getBuffer(), bPtr, 1)[0];
|
||||
|
||||
output += `Underline color RGB = (${r}, ${g}, ${b})\n`;
|
||||
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(rPtr);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(gPtr);
|
||||
wasmInstance.exports.ghostty_wasm_free_u8(bPtr);
|
||||
break;
|
||||
}
|
||||
|
||||
case SGR_ATTR_TAGS.FG_8:
|
||||
case SGR_ATTR_TAGS.BG_8:
|
||||
case SGR_ATTR_TAGS.FG_256:
|
||||
case SGR_ATTR_TAGS.BG_256:
|
||||
case SGR_ATTR_TAGS.UNDERLINE_COLOR_256: {
|
||||
const view = new DataView(getBuffer(), valuePtr, 1);
|
||||
const color = view.getUint8(0);
|
||||
const colorType = tag === SGR_ATTR_TAGS.FG_8 ? 'Foreground 8-color' :
|
||||
tag === SGR_ATTR_TAGS.BG_8 ? 'Background 8-color' :
|
||||
tag === SGR_ATTR_TAGS.FG_256 ? 'Foreground 256-color' :
|
||||
tag === SGR_ATTR_TAGS.BG_256 ? 'Background 256-color' :
|
||||
'Underline 256-color';
|
||||
output += `${colorType} = ${color}\n`;
|
||||
break;
|
||||
}
|
||||
|
||||
case SGR_ATTR_TAGS.BOLD:
|
||||
output += 'Bold\n';
|
||||
break;
|
||||
|
||||
case SGR_ATTR_TAGS.ITALIC:
|
||||
output += 'Italic\n';
|
||||
break;
|
||||
|
||||
case SGR_ATTR_TAGS.UNSET:
|
||||
output += 'Reset all attributes\n';
|
||||
break;
|
||||
|
||||
case SGR_ATTR_TAGS.UNKNOWN:
|
||||
output += 'Unknown attribute\n';
|
||||
break;
|
||||
|
||||
default:
|
||||
output += `Other attribute (tag=${getTagName(tag)})\n`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
output += `\nTotal attributes parsed: ${count}`;
|
||||
|
||||
outputDiv.className = 'output';
|
||||
outputDiv.textContent = output;
|
||||
|
||||
// Cleanup
|
||||
wasmInstance.exports.ghostty_wasm_free_sgr_attribute(attrPtr);
|
||||
wasmInstance.exports.ghostty_sgr_free(parserPtr);
|
||||
|
||||
} catch (e) {
|
||||
console.error('Parse 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 sequenceInput = document.getElementById('sequence');
|
||||
|
||||
try {
|
||||
statusDiv.textContent = 'Loading WebAssembly module...';
|
||||
|
||||
const loaded = await loadWasm();
|
||||
if (!loaded) {
|
||||
throw new Error('Failed to load WebAssembly module');
|
||||
}
|
||||
|
||||
statusDiv.textContent = '';
|
||||
sequenceInput.disabled = false;
|
||||
|
||||
// Parse live as user types
|
||||
sequenceInput.addEventListener('input', parseSGR);
|
||||
|
||||
// Parse the default example on load
|
||||
parseSGR();
|
||||
} catch (e) {
|
||||
statusDiv.textContent = `Error: ${e.message}`;
|
||||
statusDiv.style.color = '#c00';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user