[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

✨ add terminal input parser

Add a complete VT/ANSI terminal input parser that converts VT200/SGR/urxvt
mouse protocols, UTF-8 decoding, and escape sequence into input events

Charles Lowell (Mar 24, 2026, 4:15 PM -0500) 1a965b6b ae32caf4

+2156 -57
+69 -3
README.md
··· 1 1 # clayterm 2 2 3 - A terminal rendering backend for [Clay](https://github.com/nicbarker/clay), 4 - compiled to WebAssembly. 3 + A terminal rendering backend for [Clay](https://github.com/nicbarker/clay), and 4 + a terminal input event parser compiled to WebAssembly. 5 + 6 + ## Architecture 7 + 8 + ### Output 5 9 6 10 With every frame, the entire UI tree is packed into a flat byte array and sent 7 11 to WASM in a single call. On the C side, Clay runs layout, render commands are ··· 30 34 +---------------+ +---------------------------+ 31 35 ``` 32 36 37 + ### Input 38 + 39 + Raw bytes from stdin are fed into a WASM-based parser that recognizes VT/ANSI 40 + escape sequences, UTF-8 codepoints, and mouse protocols (VT200, SGR, urxvt). The 41 + parser maintains its own internal buffer so partial sequences that arrive across 42 + read boundaries are reassembled automatically. A lone ESC byte is held for a 43 + configurable latency window (default 25ms) before being emitted, giving 44 + multi-byte sequences time to arrive. 45 + 46 + ``` 47 + TypeScript WASM (C) 48 + +---------------+ +---------------------------+ 49 + | | raw byte array| | 50 + | stdin.read | =============> | trie match (keys/seqs) | 51 + | | | -> mouse protocol parse | 52 + | | | -> UTF-8 decode | 53 + +---------------+ | -> ESC latency timer | 54 + | -> event array | 55 + +---------------+ | | 56 + | | events[] | | 57 + | CharEvent | <============= | | 58 + | KeyEvent | | | 59 + | MouseEvent | +---------------------------+ 60 + | DragEvent | 61 + | WheelEvent | 62 + | ResizeEvent | 63 + +---------------+ 64 + ``` 65 + 33 66 ## Usage 67 + 68 + ### Output 34 69 35 70 ```typescript 36 71 import { close, createTerm, grow, open, rgba, text } from "clayterm"; ··· 57 92 close(), 58 93 ]); 59 94 60 - Deno.stdout.writeSync(ansi); 95 + process.stdout.write(ansi); 96 + ``` 97 + 98 + ### Input 99 + 100 + ```typescript 101 + import { createInput } from "clayterm/input"; 102 + 103 + const input = await createInput({ escLatency: 25 }); 104 + 105 + process.stdin.setRawMode(true); 106 + let timer: ReturnType<typeof setTimeout> | undefined; 107 + 108 + process.stdin.on("data", (buf) => { 109 + clearTimeout(timer); 110 + 111 + let { events, pending } = input.scan(new Uint8Array(buf)); 112 + 113 + for (let event of events) { 114 + dispatch(event); 115 + } 116 + 117 + // if a lone ESC is pending, wait and re-scan to flush it 118 + if (pending) { 119 + timer = setTimeout(() => { 120 + let flush = input.scan(); 121 + for (let event of flush.events) { 122 + dispatch(event); 123 + } 124 + }, pending.delay); 125 + } 126 + }); 61 127 ``` 62 128 63 129 ## Development
+3 -1
deno.json
··· 25 25 "exclude": ["clay", "build"] 26 26 }, 27 27 "lint": { 28 - "exclude": ["clay", "build"] 28 + "rules": { "exclude": ["prefer-const"] }, 29 + "exclude": ["clay", "build"], 30 + "plugins": ["lint/prefer-let.ts"] 29 31 } 30 32 }
+141
input-native.ts
··· 1 + export const EVENT_KEY = 1; 2 + export const EVENT_MOUSE = 2; 3 + export const EVENT_RESIZE = 3; 4 + 5 + export const MOD_ALT = 1; 6 + export const MOD_CTRL = 2; 7 + export const MOD_SHIFT = 4; 8 + export const MOD_MOTION = 8; 9 + export const MOD_RELEASE = 16; 10 + 11 + export const KEY_F1 = 0xFFFF; 12 + export const KEY_F2 = 0xFFFE; 13 + export const KEY_F3 = 0xFFFD; 14 + export const KEY_F4 = 0xFFFC; 15 + export const KEY_F5 = 0xFFFB; 16 + export const KEY_F6 = 0xFFFA; 17 + export const KEY_F7 = 0xFFF9; 18 + export const KEY_F8 = 0xFFF8; 19 + export const KEY_F9 = 0xFFF7; 20 + export const KEY_F10 = 0xFFF6; 21 + export const KEY_F11 = 0xFFF5; 22 + export const KEY_F12 = 0xFFF4; 23 + export const KEY_ARROW_UP = 0xFFF3; 24 + export const KEY_ARROW_DOWN = 0xFFF2; 25 + export const KEY_ARROW_LEFT = 0xFFF1; 26 + export const KEY_ARROW_RIGHT = 0xFFF0; 27 + export const KEY_HOME = 0xFFEF; 28 + export const KEY_END = 0xFFEE; 29 + export const KEY_INSERT = 0xFFED; 30 + export const KEY_DELETE = 0xFFEC; 31 + export const KEY_PGUP = 0xFFEB; 32 + export const KEY_PGDN = 0xFFEA; 33 + export const KEY_BACKTAB = 0xFFE9; 34 + export const KEY_MOUSE_LEFT = 0xFFE8; 35 + export const KEY_MOUSE_RIGHT = 0xFFE7; 36 + export const KEY_MOUSE_MIDDLE = 0xFFE6; 37 + export const KEY_MOUSE_RELEASE = 0xFFE5; 38 + export const KEY_MOUSE_WHEEL_UP = 0xFFE4; 39 + export const KEY_MOUSE_WHEEL_DOWN = 0xFFE3; 40 + export const KEY_ESC = 0x1B; 41 + export const KEY_ENTER = 0x0D; 42 + export const KEY_TAB = 0x09; 43 + export const KEY_BACKSPACE = 0x7F; 44 + export const KEY_SPACE = 0x20; 45 + 46 + export const OFF_TYPE = 0; 47 + export const OFF_MOD = 1; 48 + export const OFF_KEY = 2; 49 + export const OFF_CH = 4; 50 + export const OFF_X = 8; 51 + export const OFF_Y = 12; 52 + export const OFF_W = 16; 53 + export const OFF_H = 20; 54 + 55 + export interface NativeInputEvent { 56 + type: number; 57 + mod: number; 58 + key: number; 59 + ch: number; 60 + x: number; 61 + y: number; 62 + w: number; 63 + h: number; 64 + } 65 + 66 + export function readEvent(view: DataView, ptr: number): NativeInputEvent { 67 + return { 68 + type: view.getUint8(ptr + OFF_TYPE), 69 + mod: view.getUint8(ptr + OFF_MOD), 70 + key: view.getUint16(ptr + OFF_KEY, true), 71 + ch: view.getUint32(ptr + OFF_CH, true), 72 + x: view.getInt32(ptr + OFF_X, true), 73 + y: view.getInt32(ptr + OFF_Y, true), 74 + w: view.getInt32(ptr + OFF_W, true), 75 + h: view.getInt32(ptr + OFF_H, true), 76 + }; 77 + } 78 + 79 + export interface InputNative { 80 + memory: WebAssembly.Memory; 81 + state: number; 82 + buffer: number; 83 + scan(st: number, buf: number, len: number, now: number): number; 84 + count(st: number): number; 85 + event(st: number, index: number): number; 86 + delay(st: number): number; 87 + } 88 + 89 + import { compiled } from "./wasm.ts"; 90 + 91 + export async function createInputNative( 92 + escLatency: number, 93 + ): Promise<InputNative> { 94 + let memory = new WebAssembly.Memory({ initial: 4 }); 95 + 96 + let instance = await WebAssembly.instantiate(compiled, { 97 + env: { memory }, 98 + clay: { 99 + measureTextFunction() {}, 100 + queryScrollOffsetFunction(ret: number) { 101 + let v = new DataView(memory.buffer); 102 + v.setFloat32(ret, 0, true); 103 + v.setFloat32(ret + 4, 0, true); 104 + }, 105 + }, 106 + }); 107 + 108 + let exports = instance.exports as unknown as { 109 + __heap_base: WebAssembly.Global; 110 + input_size(): number; 111 + input_init(mem: number, escLatency: number): number; 112 + input_scan(st: number, buf: number, len: number, now: number): number; 113 + input_count(st: number): number; 114 + input_event(st: number, index: number): number; 115 + input_delay(st: number): number; 116 + }; 117 + 118 + let heap = exports.__heap_base.value as number; 119 + let size = exports.input_size(); 120 + let state = exports.input_init(heap, escLatency); 121 + let buffer = (heap + size + 7) & ~7; 122 + 123 + return { 124 + memory, 125 + state, 126 + buffer, 127 + scan: exports.input_scan, 128 + count: exports.input_count, 129 + event: exports.input_event, 130 + delay: exports.input_delay, 131 + }; 132 + } 133 + 134 + // Compiled terminfo entries are limited to 4096 bytes (legacy) or 32768 135 + // bytes (extended ncurses format). We use the extended limit as our upper 136 + // bound. See https://man7.org/linux/man-pages/man5/term.5.html 137 + export const MAX_TERMINFO = 32768; 138 + 139 + // Must match SCAN_BUFFER_SIZE in input.c — the maximum bytes input_scan() 140 + // can accept in a single call. 141 + export const SCAN_BUFFER_SIZE = 4096;
+447
input.ts
··· 1 + /** 2 + * Terminal input parser. 3 + * 4 + * Thin wrapper around WASM `input_*` functions that decode raw VT/ANSI 5 + * escape sequences into structured events. All parsing logic, state 6 + * management, and buffering lives in WASM. 7 + */ 8 + 9 + import { 10 + createInputNative, 11 + EVENT_KEY, 12 + EVENT_MOUSE, 13 + EVENT_RESIZE, 14 + KEY_ARROW_DOWN, 15 + KEY_ARROW_LEFT, 16 + KEY_ARROW_RIGHT, 17 + KEY_ARROW_UP, 18 + KEY_BACKSPACE, 19 + KEY_BACKTAB, 20 + KEY_DELETE, 21 + KEY_END, 22 + KEY_ENTER, 23 + KEY_ESC, 24 + KEY_F1, 25 + KEY_F10, 26 + KEY_F11, 27 + KEY_F12, 28 + KEY_F2, 29 + KEY_F3, 30 + KEY_F4, 31 + KEY_F5, 32 + KEY_F6, 33 + KEY_F7, 34 + KEY_F8, 35 + KEY_F9, 36 + KEY_HOME, 37 + KEY_INSERT, 38 + KEY_MOUSE_LEFT, 39 + KEY_MOUSE_MIDDLE, 40 + KEY_MOUSE_RELEASE, 41 + KEY_MOUSE_RIGHT, 42 + KEY_MOUSE_WHEEL_DOWN, 43 + KEY_MOUSE_WHEEL_UP, 44 + KEY_PGDN, 45 + KEY_PGUP, 46 + KEY_TAB, 47 + MAX_TERMINFO, 48 + MOD_ALT, 49 + MOD_CTRL, 50 + MOD_MOTION, 51 + MOD_RELEASE, 52 + MOD_SHIFT, 53 + type NativeInputEvent, 54 + readEvent, 55 + SCAN_BUFFER_SIZE, 56 + } from "./input-native.ts"; 57 + 58 + /** 59 + * Modifier keys held during a key or mouse event. For example, Ctrl+C will be seen as 60 + * ```ts 61 + * { type: "char"; key: "c"; ctrl: true} 62 + * ``` 63 + */ 64 + export interface KeyModifiers { 65 + alt?: true; 66 + ctrl?: true; 67 + shift?: true; 68 + } 69 + 70 + /** 71 + * A printable character (Unicode codepoint) was pressed. There may 72 + */ 73 + export interface CharEvent extends KeyModifiers { 74 + type: "char"; 75 + key: string; 76 + } 77 + 78 + /** 79 + * Non printable keys such as "Escape" 80 + */ 81 + export interface KeyEvent extends KeyModifiers { 82 + type: "key"; 83 + key: 84 + | "F1" 85 + | "F2" 86 + | "F3" 87 + | "F4" 88 + | "F5" 89 + | "F6" 90 + | "F7" 91 + | "F8" 92 + | "F9" 93 + | "F10" 94 + | "F11" 95 + | "F12" 96 + | "ArrowUp" 97 + | "ArrowDown" 98 + | "ArrowLeft" 99 + | "ArrowRight" 100 + | "Home" 101 + | "End" 102 + | "Insert" 103 + | "Delete" 104 + | "PageUp" 105 + | "PageDown" 106 + | "Backtab" 107 + | "Backspace" 108 + | "Tab" 109 + | "Enter" 110 + | "Escape"; 111 + } 112 + 113 + /** 114 + * A mouse button was pressed or released 115 + */ 116 + export interface MouseEvent extends KeyModifiers { 117 + type: "mouse"; 118 + /** 119 + * Which mouse button triggered this event. 120 + * 121 + * Note: "release" is not technically a button, but is used by 122 + * `VT200` and `urxvt` protocols where the terminal reports a button 123 + * release without indicating which button. 124 + */ 125 + button: "left" | "right" | "middle" | "release"; 126 + 127 + /** 128 + * `x` coordinate of this event 129 + */ 130 + x: number; 131 + 132 + /** 133 + * `y` coordinate of this event 134 + */ 135 + y: number; 136 + 137 + /** 138 + * True if the event represents a button being released. 139 + */ 140 + release?: true; 141 + } 142 + 143 + /** 144 + * Mouse movement while a button is held. 145 + */ 146 + export interface DragEvent extends KeyModifiers { 147 + type: "drag"; 148 + /** 149 + * Which mouse button is being held during the drag. 150 + */ 151 + button: "left" | "right" | "middle"; 152 + /** 153 + * Cursor column (0-based). 154 + */ 155 + x: number; 156 + /** 157 + * Cursor row (0-based). 158 + */ 159 + y: number; 160 + } 161 + 162 + /** 163 + * A scroll wheel tick. 164 + */ 165 + export interface WheelEvent extends KeyModifiers { 166 + type: "wheel"; 167 + /** 168 + * Did the wheel move up or down 169 + */ 170 + direction: "up" | "down"; 171 + 172 + /** 173 + * Cursor column at the time of the scroll (0-based). 174 + */ 175 + x: number; 176 + 177 + /** 178 + * Cursor row at the time of the scroll (0-based). 179 + */ 180 + y: number; 181 + } 182 + 183 + /** 184 + * Terminal resize notification. 185 + */ 186 + export interface ResizeEvent { 187 + type: "resize"; 188 + 189 + /** 190 + * New terminal width in columns. 191 + */ 192 + width: number; 193 + 194 + /** 195 + * New terminal height in rows. 196 + */ 197 + height: number; 198 + } 199 + 200 + export type InputEvent = 201 + | CharEvent 202 + | KeyEvent 203 + | MouseEvent 204 + | DragEvent 205 + | WheelEvent 206 + | ResizeEvent; 207 + 208 + /** 209 + * Result of a single scan() call. 210 + * 211 + * When `pending` is present, a lone ESC is buffered and the caller should 212 + * re-call scan() with an empty buffer after `pending.delay` milliseconds. 213 + */ 214 + export interface ScanResult { 215 + events: InputEvent[]; 216 + pending?: { delay: number }; 217 + } 218 + 219 + export interface Input { 220 + /** 221 + * Feed raw bytes from stdin into the parser and return any events 222 + * produced. Call with no arguments to flush a pending ESC after the 223 + * latency period has elapsed. 224 + * 225 + * @example 226 + * ```ts 227 + * let { events, pending } = input.scan(bytes); 228 + * for (let event of events) { 229 + * dispatch(event); 230 + * } 231 + * if (pending) { 232 + * // there is a pending ESC event. wait for the delay 233 + * await sleep(pending.delay); 234 + * 235 + * // re-scan 236 + * let flush = input.scan(); 237 + * 238 + * //dispatch the flushed ESC 239 + * for (let event of flush.events) { 240 + * dispatch(event) 241 + * } 242 + * } 243 + * ``` 244 + */ 245 + scan(bytes?: Uint8Array): ScanResult; 246 + } 247 + 248 + export interface InputOptions { 249 + /** 250 + * Milliseconds to wait before resolving a lone ESC byte as the Escape 251 + * key rather than the start of an escape sequence. Lower values feel 252 + * snappier but risk misinterpreting sequences on slow connections. 253 + * 254 + * For reference, Vim's `ttimeoutlen` defaults to 100ms and ncurses 255 + * `ESCDELAY` defaults to 1000ms. The default of 25ms is tuned for 256 + * local terminals where escape sequences arrive within microseconds. 257 + * 258 + * @default 25 259 + */ 260 + escLatency?: number; 261 + 262 + /** 263 + * Compiled terminfo binary to load terminal-specific escape sequences. 264 + * 265 + * This is the format used by files like /usr/lib/terminfo/78/xterm-256color 266 + * and they can be directly loaded from disk into this option. 267 + * 268 + * If no terminfo is provided it will use xterm capabilities as the default 269 + */ 270 + terminfo?: Uint8Array; 271 + } 272 + 273 + export async function createInput(options: InputOptions = {}): Promise<Input> { 274 + let { escLatency = 25, terminfo } = options; 275 + 276 + if (terminfo && terminfo.byteLength > MAX_TERMINFO) { 277 + throw new RangeError( 278 + `terminfo exceeds ${MAX_TERMINFO} byte limit (got ${terminfo.byteLength})`, 279 + ); 280 + } 281 + 282 + let native = await createInputNative(escLatency); 283 + 284 + return { 285 + scan(bytes: Uint8Array = new Uint8Array(0)): ScanResult { 286 + let now = Date.now(); 287 + let events: InputEvent[] = []; 288 + let offset = 0; 289 + 290 + while (offset < bytes.length || (offset === 0 && bytes.length === 0)) { 291 + let chunk = bytes.subarray(offset, offset + SCAN_BUFFER_SIZE); 292 + if (chunk.length > 0) { 293 + new Uint8Array(native.memory.buffer).set(chunk, native.buffer); 294 + } 295 + 296 + let accepted = native.scan( 297 + native.state, 298 + native.buffer, 299 + chunk.length, 300 + now, 301 + ); 302 + 303 + let count = native.count(native.state); 304 + let view = new DataView(native.memory.buffer); 305 + for (let i = 0; i < count; i++) { 306 + let ptr = native.event(native.state, i); 307 + if (ptr !== 0) { 308 + events.push(mapEvent(readEvent(view, ptr))); 309 + } 310 + } 311 + 312 + offset += accepted; 313 + 314 + if (accepted < chunk.length) { 315 + break; 316 + } 317 + if (bytes.length === 0) { 318 + break; 319 + } 320 + } 321 + 322 + let delay = native.delay(native.state); 323 + if (delay > 0) { 324 + return { events, pending: { delay } }; 325 + } 326 + return { events }; 327 + }, 328 + }; 329 + } 330 + 331 + const KEY_NAMES = new Map<number, KeyEvent["key"]>([ 332 + [KEY_F1, "F1"], 333 + [KEY_F2, "F2"], 334 + [KEY_F3, "F3"], 335 + [KEY_F4, "F4"], 336 + [KEY_F5, "F5"], 337 + [KEY_F6, "F6"], 338 + [KEY_F7, "F7"], 339 + [KEY_F8, "F8"], 340 + [KEY_F9, "F9"], 341 + [KEY_F10, "F10"], 342 + [KEY_F11, "F11"], 343 + [KEY_F12, "F12"], 344 + [KEY_ARROW_UP, "ArrowUp"], 345 + [KEY_ARROW_DOWN, "ArrowDown"], 346 + [KEY_ARROW_LEFT, "ArrowLeft"], 347 + [KEY_ARROW_RIGHT, "ArrowRight"], 348 + [KEY_HOME, "Home"], 349 + [KEY_END, "End"], 350 + [KEY_INSERT, "Insert"], 351 + [KEY_DELETE, "Delete"], 352 + [KEY_PGUP, "PageUp"], 353 + [KEY_PGDN, "PageDown"], 354 + [KEY_BACKTAB, "Backtab"], 355 + [KEY_BACKSPACE, "Backspace"], 356 + [KEY_TAB, "Tab"], 357 + [KEY_ENTER, "Enter"], 358 + [KEY_ESC, "Escape"], 359 + ]); 360 + 361 + const BUTTON_NAMES = new Map<number, MouseEvent["button"]>([ 362 + [KEY_MOUSE_LEFT, "left"], 363 + [KEY_MOUSE_RIGHT, "right"], 364 + [KEY_MOUSE_MIDDLE, "middle"], 365 + [KEY_MOUSE_RELEASE, "release"], 366 + ]); 367 + 368 + function mods(native: NativeInputEvent): KeyModifiers { 369 + let m: KeyModifiers = {}; 370 + if (native.mod & MOD_ALT) m.alt = true; 371 + if (native.mod & MOD_CTRL) m.ctrl = true; 372 + if (native.mod & MOD_SHIFT) m.shift = true; 373 + return m; 374 + } 375 + 376 + function mapEvent(native: NativeInputEvent): InputEvent { 377 + switch (native.type) { 378 + case EVENT_KEY: { 379 + let name = KEY_NAMES.get(native.key); 380 + if (name) { 381 + return { type: "key", key: name, ...mods(native) }; 382 + } else if (native.key === 0 && native.ch > 0) { 383 + return { 384 + type: "char", 385 + key: String.fromCodePoint(native.ch), 386 + ...mods(native), 387 + }; 388 + } else if (native.key > 0 && native.key < 0x20) { 389 + return { 390 + type: "char", 391 + key: String.fromCharCode(native.key + 0x60), 392 + ...mods(native), 393 + }; 394 + } else { 395 + return { 396 + type: "char", 397 + key: String.fromCodePoint(native.ch || native.key), 398 + ...mods(native), 399 + }; 400 + } 401 + } 402 + case EVENT_MOUSE: { 403 + if ( 404 + native.key === KEY_MOUSE_WHEEL_UP || native.key === KEY_MOUSE_WHEEL_DOWN 405 + ) { 406 + return { 407 + type: "wheel", 408 + direction: native.key === KEY_MOUSE_WHEEL_UP ? "up" : "down", 409 + x: native.x, 410 + y: native.y, 411 + ...mods(native), 412 + }; 413 + } 414 + if (native.mod & MOD_MOTION) { 415 + let button = BUTTON_NAMES.get(native.key) ?? "left"; 416 + return { 417 + type: "drag" as const, 418 + button: button === "release" ? "left" as const : button, 419 + x: native.x, 420 + y: native.y, 421 + ...mods(native), 422 + }; 423 + } 424 + let button = BUTTON_NAMES.get(native.key) ?? "left"; 425 + let m = mods(native); 426 + let ev: MouseEvent = { 427 + type: "mouse", 428 + button, 429 + x: native.x, 430 + y: native.y, 431 + ...m, 432 + }; 433 + if (native.mod & MOD_RELEASE) ev.release = true; 434 + return ev; 435 + } 436 + case EVENT_RESIZE: { 437 + return { type: "resize", width: native.w, height: native.h }; 438 + } 439 + default: { 440 + return { 441 + type: "char", 442 + key: String.fromCodePoint(native.ch || native.key), 443 + ...mods(native), 444 + }; 445 + } 446 + } 447 + }
+57
lint/prefer-let.ts
··· 1 + type VariableDeclaration = Deno.lint.VariableDeclaration; 2 + 3 + const plugin: Deno.lint.Plugin = { 4 + name: "clayterm", 5 + rules: { 6 + "prefer-let": { 7 + create(context) { 8 + function isTopLevelScope(node: VariableDeclaration): boolean { 9 + // deno-lint-ignore no-explicit-any 10 + let current: any = node.parent; 11 + 12 + while (current) { 13 + switch (current.type) { 14 + case "FunctionDeclaration": 15 + case "FunctionExpression": 16 + case "ArrowFunctionExpression": 17 + case "BlockStatement": 18 + if ( 19 + current.type === "BlockStatement" && 20 + current.parent?.type === "Program" 21 + ) { 22 + current = current.parent; 23 + continue; 24 + } 25 + return false; 26 + 27 + case "Program": 28 + return true; 29 + default: 30 + current = current.parent; 31 + } 32 + } 33 + 34 + return false; 35 + } 36 + 37 + return { 38 + VariableDeclaration(node) { 39 + if (node.kind === "var") { 40 + context.report({ 41 + message: "prefer `let` over `var` to declare value bindings", 42 + node, 43 + }); 44 + } else if (node.kind === "const" && !isTopLevelScope(node)) { 45 + context.report({ 46 + message: "`const` declaration outside top-level scope", 47 + node, 48 + }); 49 + } 50 + }, 51 + }; 52 + }, 53 + }, 54 + }, 55 + }; 56 + 57 + export default plugin;
+11 -16
native.ts term-native.ts
··· 7 7 length(ct: number): number; 8 8 } 9 9 10 - import { readFile } from "node:fs/promises"; 11 - 12 - const wasm = new Uint8Array( 13 - await readFile(new URL("./clayterm.wasm", import.meta.url)), 14 - ); 15 - const compiled = await WebAssembly.compile(wasm); 10 + import { compiled } from "./wasm.ts"; 16 11 17 - export async function load(w: number, h: number): Promise<Native> { 18 - const memory = new WebAssembly.Memory({ initial: 256 }); 19 - const exports: Record<string, CallableFunction> = {}; 12 + export async function createTermNative(w: number, h: number): Promise<Native> { 13 + let memory = new WebAssembly.Memory({ initial: 256 }); 14 + let exports: Record<string, CallableFunction> = {}; 20 15 21 - const instance = await WebAssembly.instantiate(compiled, { 16 + let instance = await WebAssembly.instantiate(compiled, { 22 17 env: { memory }, 23 18 clay: { 24 19 measureTextFunction( ··· 34 29 _elementId: number, 35 30 _userData: number, 36 31 ) { 37 - const view = new DataView(memory.buffer); 32 + let view = new DataView(memory.buffer); 38 33 view.setFloat32(ret, 0, true); 39 34 view.setFloat32(ret + 4, 0, true); 40 35 }, ··· 43 38 44 39 Object.assign(exports, instance.exports); 45 40 46 - const ct = exports as unknown as { 41 + let ct = exports as unknown as { 47 42 __heap_base: WebAssembly.Global; 48 43 clayterm_size(w: number, h: number): number; 49 44 init(mem: number, w: number, h: number): number; ··· 52 47 length(ct: number): number; 53 48 }; 54 49 55 - const heap = ct.__heap_base.value as number; 56 - const size = ct.clayterm_size(w, h); 57 - const statePtr = ct.init(heap, w, h); 58 - const opsBuf = (heap + size + 3) & ~3; 50 + let heap = ct.__heap_base.value as number; 51 + let size = ct.clayterm_size(w, h); 52 + let statePtr = ct.init(heap, w, h); 53 + let opsBuf = (heap + size + 3) & ~3; 59 54 60 55 return { 61 56 memory,
+9 -9
ops.ts
··· 63 63 } 64 64 65 65 export function pack(ops: Op[], mem: ArrayBufferLike, offset: number): number { 66 - const view = new DataView(mem); 66 + let view = new DataView(mem); 67 67 let o = offset; 68 68 69 - for (const op of ops) { 69 + for (let op of ops) { 70 70 switch (op.id) { 71 71 case OP_CLOSE_ELEMENT: 72 72 view.setUint32(o, op.id, true); ··· 77 77 view.setUint32(o, OP_OPEN_ELEMENT, true); 78 78 o += 4; 79 79 80 - const id = encoder.encode(op.name); 80 + let id = encoder.encode(op.name); 81 81 o = packString(view, id, o); 82 82 83 83 let mask = 0; ··· 91 91 o += 4; 92 92 93 93 if (op.layout) { 94 - const l = op.layout; 94 + let l = op.layout; 95 95 o = packAxis(view, o, l.width ?? { type: "fit" }); 96 96 o = packAxis(view, o, l.height ?? { type: "fit" }); 97 97 98 - const p = l.padding ?? {}; 98 + let p = l.padding ?? {}; 99 99 view.setUint32( 100 100 o, 101 101 (p.left ?? 0) | ((p.right ?? 0) << 8) | ((p.top ?? 0) << 16) | ··· 121 121 } 122 122 123 123 if (op.cornerRadius) { 124 - const cr = op.cornerRadius; 124 + let cr = op.cornerRadius; 125 125 view.setUint32( 126 126 o, 127 127 (cr.tl ?? 0) | ((cr.tr ?? 0) << 8) | ((cr.bl ?? 0) << 16) | ··· 132 132 } 133 133 134 134 if (op.border) { 135 - const b = op.border; 135 + let b = op.border; 136 136 view.setUint32(o, b.color, true); 137 137 o += 4; 138 138 view.setUint32( ··· 154 154 } 155 155 156 156 if (op.floating) { 157 - const f = op.floating; 157 + let f = op.floating; 158 158 view.setFloat32(o, f.x ?? 0, true); 159 159 o += 4; 160 160 view.setFloat32(o, f.y ?? 0, true); ··· 187 187 ); 188 188 o += 4; 189 189 190 - const str = encoder.encode(op.content); 190 + let str = encoder.encode(op.content); 191 191 o = packString(view, str, o); 192 192 break; 193 193 }
-7
src/buffer.c
··· 4 4 #include "mem.h" 5 5 #include "utf8.h" 6 6 7 - static size_t strlen(const char *s) { 8 - size_t n = 0; 9 - while (s[n]) 10 - n++; 11 - return n; 12 - } 13 - 14 7 void buf_put(Buffer *b, const char *s, int n) { 15 8 if (b->length + n > b->capacity) 16 9 return;
-2
src/clayterm.c
··· 335 335 336 336 /* ── Public API ───────────────────────────────────────────────────── */ 337 337 338 - static int align8(int n) { return (n + 7) & ~7; } 339 - 340 338 static int align64(int n) { return (n + 63) & ~63; } 341 339 342 340 int clayterm_size(int w, int h) {
+639
src/input.c
··· 1 + /* input.c — VT/ANSI escape sequence parser 2 + * 3 + * Decodes raw terminal input bytes into structured InputEvent records. 4 + * All state lives in an arena-allocated InputState; no libc required. 5 + * 6 + * Public API (exported to WASM): 7 + * input_size — compute arena size 8 + * input_init — initialize parser state in provided memory 9 + * input_scan — feed bytes, produce events 10 + * input_count — number of events from last scan 11 + * input_event — pointer to event by index 12 + * input_pending — deadline for pending ESC (0 = none) 13 + */ 14 + 15 + #include "input.h" 16 + #include "trie.h" 17 + #include "mem.h" 18 + #include "utf8.h" 19 + 20 + #define SCAN_BUFFER_SIZE 4096 21 + #define MAX_EVENTS 128 22 + 23 + /* ── State ────────────────────────────────────────────────────────── */ 24 + 25 + struct InputState { 26 + int esc_latency_ms; 27 + char buf[SCAN_BUFFER_SIZE]; 28 + int len; 29 + double esc_time; 30 + struct InputEvent events[MAX_EVENTS]; 31 + int count; 32 + int trie_len; 33 + Trie trie; 34 + }; 35 + 36 + /* ── Helpers ──────────────────────────────────────────────────────── */ 37 + 38 + static void shift(struct InputState *st, int n) { 39 + st->len -= n; 40 + if (st->len > 0) { 41 + /* move remaining bytes to front */ 42 + for (int i = 0; i < st->len; i++) 43 + st->buf[i] = st->buf[i + n]; 44 + } 45 + } 46 + 47 + static struct InputEvent *emit(struct InputState *st) { 48 + if (st->count >= MAX_EVENTS) 49 + return &st->events[MAX_EVENTS - 1]; /* saturate */ 50 + struct InputEvent *ev = &st->events[st->count++]; 51 + memset(ev, 0, sizeof(*ev)); 52 + return ev; 53 + } 54 + 55 + /* ── Mouse parsing ────────────────────────────────────────────────── */ 56 + 57 + #define MOUSE_BUTTON_MASK 3 58 + #define MOUSE_BUTTON_LEFT 0 59 + #define MOUSE_BUTTON_MIDDLE 1 60 + #define MOUSE_BUTTON_RIGHT 2 61 + #define MOUSE_BUTTON_RELEASE 3 62 + #define MOUSE_WHEEL_BIT 64 63 + 64 + static uint16_t mouse_button(int b) { 65 + switch (b & MOUSE_BUTTON_MASK) { 66 + case MOUSE_BUTTON_LEFT: 67 + if (b & MOUSE_WHEEL_BIT) { 68 + return KEY_MOUSE_WHEEL_UP; 69 + } else { 70 + return KEY_MOUSE_LEFT; 71 + } 72 + case MOUSE_BUTTON_MIDDLE: 73 + if (b & MOUSE_WHEEL_BIT) { 74 + return KEY_MOUSE_WHEEL_DOWN; 75 + } else { 76 + return KEY_MOUSE_MIDDLE; 77 + } 78 + case MOUSE_BUTTON_RIGHT: 79 + return KEY_MOUSE_RIGHT; 80 + case MOUSE_BUTTON_RELEASE: 81 + return KEY_MOUSE_RELEASE; 82 + default: 83 + return KEY_MOUSE_LEFT; 84 + } 85 + } 86 + 87 + static int parse_mouse_vt200(struct InputState *st, struct InputEvent *ev) { 88 + /* \x1b[M + 3 bytes = 6 total */ 89 + if (st->len < 6) 90 + return PARSE_NEED_MORE; 91 + if (st->buf[0] != '\x1b' || st->buf[1] != '[' || st->buf[2] != 'M') 92 + return PARSE_ERR; 93 + 94 + int b = st->buf[3] - 0x20; 95 + ev->type = EVENT_MOUSE; 96 + ev->key = mouse_button(b); 97 + ev->mod = (b & 32) ? MOD_MOTION : 0; 98 + if (ev->key == KEY_MOUSE_RELEASE) 99 + ev->mod |= MOD_RELEASE; 100 + ev->x = ((uint8_t)st->buf[4]) - 0x21; 101 + ev->y = ((uint8_t)st->buf[5]) - 0x21; 102 + 103 + shift(st, 6); 104 + return PARSE_OK; 105 + } 106 + 107 + static int parse_mouse_sgr(struct InputState *st, struct InputEvent *ev) { 108 + /* \x1b[< btn ; x ; y {M|m} */ 109 + if (st->len < 3) 110 + return PARSE_NEED_MORE; 111 + if (st->buf[0] != '\x1b' || st->buf[1] != '[' || st->buf[2] != '<') 112 + return PARSE_ERR; 113 + 114 + int num[3] = {-1, -1, -1}; 115 + int ni = 0; 116 + int cur = -1; 117 + char trail = ' '; 118 + int i = 3; 119 + 120 + while (i < st->len && ni < 3) { 121 + char c = st->buf[i]; 122 + if (c >= '0' && c <= '9') { 123 + if (cur == -1) 124 + cur = 0; 125 + cur = cur * 10 + (c - '0'); 126 + } else if (cur != -1 && 127 + ((ni < 2 && c == ';') || (ni == 2 && (c == 'm' || c == 'M')))) { 128 + num[ni++] = cur; 129 + cur = -1; 130 + trail = c; 131 + } else { 132 + return PARSE_ERR; 133 + } 134 + i++; 135 + } 136 + 137 + if (num[2] == -1) 138 + return PARSE_NEED_MORE; 139 + 140 + ev->type = EVENT_MOUSE; 141 + ev->key = mouse_button(num[0]); 142 + ev->mod = (num[0] & 32) ? MOD_MOTION : 0; 143 + if (trail == 'm') 144 + ev->mod |= MOD_RELEASE; 145 + ev->x = (num[1] - 1 < 0) ? 0 : num[1] - 1; 146 + ev->y = (num[2] - 1 < 0) ? 0 : num[2] - 1; 147 + 148 + shift(st, i); 149 + return PARSE_OK; 150 + } 151 + 152 + static int parse_mouse_urxvt(struct InputState *st, struct InputEvent *ev) { 153 + /* \x1b[ btn ; x ; y M (no '<' prefix, digits start at buf[2]) */ 154 + if (st->len < 3) 155 + return PARSE_NEED_MORE; 156 + if (st->buf[0] != '\x1b' || st->buf[1] != '[') 157 + return PARSE_ERR; 158 + /* must start with a digit at buf[2] */ 159 + if (st->buf[2] < '0' || st->buf[2] > '9') 160 + return PARSE_ERR; 161 + 162 + int num[3] = {-1, -1, -1}; 163 + int ni = 0; 164 + int cur = -1; 165 + int i = 2; 166 + 167 + while (i < st->len && ni < 3) { 168 + char c = st->buf[i]; 169 + if (c >= '0' && c <= '9') { 170 + if (cur == -1) 171 + cur = 0; 172 + cur = cur * 10 + (c - '0'); 173 + } else if (cur != -1 && ((ni < 2 && c == ';') || (ni == 2 && c == 'M'))) { 174 + num[ni++] = cur; 175 + cur = -1; 176 + } else { 177 + return PARSE_ERR; 178 + } 179 + i++; 180 + } 181 + 182 + if (num[2] == -1) 183 + return PARSE_NEED_MORE; 184 + 185 + int b = num[0] - 0x20; 186 + ev->type = EVENT_MOUSE; 187 + ev->key = mouse_button(b); 188 + ev->mod = (b & 32) ? MOD_MOTION : 0; 189 + if (ev->key == KEY_MOUSE_RELEASE) 190 + ev->mod |= MOD_RELEASE; 191 + ev->x = (num[1] - 1 < 0) ? 0 : num[1] - 1; 192 + ev->y = (num[2] - 1 < 0) ? 0 : num[2] - 1; 193 + 194 + shift(st, i); 195 + return PARSE_OK; 196 + } 197 + 198 + static int parse_mouse(struct InputState *st, struct InputEvent *ev) { 199 + if (st->len < 2) 200 + return PARSE_NEED_MORE; 201 + if (st->buf[0] != '\x1b' || st->buf[1] != '[') 202 + return PARSE_ERR; 203 + if (st->len < 3) 204 + return PARSE_NEED_MORE; 205 + 206 + int rv; 207 + if (st->buf[2] == 'M') { 208 + rv = parse_mouse_vt200(st, ev); 209 + } else if (st->buf[2] == '<') { 210 + rv = parse_mouse_sgr(st, ev); 211 + } else if (st->buf[2] >= '0' && st->buf[2] <= '9') { 212 + rv = parse_mouse_urxvt(st, ev); 213 + } else { 214 + rv = PARSE_ERR; 215 + } 216 + return rv; 217 + } 218 + 219 + /* ── Cap table (xterm defaults) ───────────────────────────────────── */ 220 + 221 + struct CapEntry { 222 + const char *seq; 223 + uint16_t key; 224 + uint8_t mod; 225 + }; 226 + 227 + static const struct CapEntry base_caps[] = { 228 + /* xterm base keys (no modifiers) */ 229 + {"\x1bOP", KEY_F1, 0}, 230 + {"\x1bOQ", KEY_F2, 0}, 231 + {"\x1bOR", KEY_F3, 0}, 232 + {"\x1bOS", KEY_F4, 0}, 233 + {"\x1b[15~", KEY_F5, 0}, 234 + {"\x1b[17~", KEY_F6, 0}, 235 + {"\x1b[18~", KEY_F7, 0}, 236 + {"\x1b[19~", KEY_F8, 0}, 237 + {"\x1b[20~", KEY_F9, 0}, 238 + {"\x1b[21~", KEY_F10, 0}, 239 + {"\x1b[23~", KEY_F11, 0}, 240 + {"\x1b[24~", KEY_F12, 0}, 241 + {"\x1b[2~", KEY_INSERT, 0}, 242 + {"\x1b[3~", KEY_DELETE, 0}, 243 + {"\x1bOH", KEY_HOME, 0}, 244 + {"\x1bOF", KEY_END, 0}, 245 + {"\x1b[5~", KEY_PGUP, 0}, 246 + {"\x1b[6~", KEY_PGDN, 0}, 247 + {"\x1bOA", KEY_ARROW_UP, 0}, 248 + {"\x1bOB", KEY_ARROW_DOWN, 0}, 249 + {"\x1bOD", KEY_ARROW_LEFT, 0}, 250 + {"\x1bOC", KEY_ARROW_RIGHT, 0}, 251 + {"\x1b[Z", KEY_BACKTAB, 0}, 252 + /* alternate arrow sequences (CSI, used by many terminals) */ 253 + {"\x1b[A", KEY_ARROW_UP, 0}, 254 + {"\x1b[B", KEY_ARROW_DOWN, 0}, 255 + {"\x1b[D", KEY_ARROW_LEFT, 0}, 256 + {"\x1b[C", KEY_ARROW_RIGHT, 0}, 257 + /* alternate home/end (CSI ~ style) */ 258 + {"\x1b[1~", KEY_HOME, 0}, 259 + {"\x1b[4~", KEY_END, 0}, 260 + {"\x1b[H", KEY_HOME, 0}, 261 + {"\x1b[F", KEY_END, 0}, 262 + {0, 0, 0}, 263 + }; 264 + 265 + /* xterm modifier combos — modifier code: 2=Shift 3=Alt 4=Alt+Shift 266 + * 5=Ctrl 6=Ctrl+Shift 7=Ctrl+Alt 8=Ctrl+Alt+Shift */ 267 + static const struct CapEntry mod_caps[] = { 268 + /* arrows */ 269 + {"\x1b[1;2A", KEY_ARROW_UP, MOD_SHIFT}, 270 + {"\x1b[1;3A", KEY_ARROW_UP, MOD_ALT}, 271 + {"\x1b[1;4A", KEY_ARROW_UP, MOD_ALT | MOD_SHIFT}, 272 + {"\x1b[1;5A", KEY_ARROW_UP, MOD_CTRL}, 273 + {"\x1b[1;6A", KEY_ARROW_UP, MOD_CTRL | MOD_SHIFT}, 274 + {"\x1b[1;7A", KEY_ARROW_UP, MOD_CTRL | MOD_ALT}, 275 + {"\x1b[1;8A", KEY_ARROW_UP, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 276 + 277 + {"\x1b[1;2B", KEY_ARROW_DOWN, MOD_SHIFT}, 278 + {"\x1b[1;3B", KEY_ARROW_DOWN, MOD_ALT}, 279 + {"\x1b[1;4B", KEY_ARROW_DOWN, MOD_ALT | MOD_SHIFT}, 280 + {"\x1b[1;5B", KEY_ARROW_DOWN, MOD_CTRL}, 281 + {"\x1b[1;6B", KEY_ARROW_DOWN, MOD_CTRL | MOD_SHIFT}, 282 + {"\x1b[1;7B", KEY_ARROW_DOWN, MOD_CTRL | MOD_ALT}, 283 + {"\x1b[1;8B", KEY_ARROW_DOWN, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 284 + 285 + {"\x1b[1;2C", KEY_ARROW_RIGHT, MOD_SHIFT}, 286 + {"\x1b[1;3C", KEY_ARROW_RIGHT, MOD_ALT}, 287 + {"\x1b[1;4C", KEY_ARROW_RIGHT, MOD_ALT | MOD_SHIFT}, 288 + {"\x1b[1;5C", KEY_ARROW_RIGHT, MOD_CTRL}, 289 + {"\x1b[1;6C", KEY_ARROW_RIGHT, MOD_CTRL | MOD_SHIFT}, 290 + {"\x1b[1;7C", KEY_ARROW_RIGHT, MOD_CTRL | MOD_ALT}, 291 + {"\x1b[1;8C", KEY_ARROW_RIGHT, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 292 + 293 + {"\x1b[1;2D", KEY_ARROW_LEFT, MOD_SHIFT}, 294 + {"\x1b[1;3D", KEY_ARROW_LEFT, MOD_ALT}, 295 + {"\x1b[1;4D", KEY_ARROW_LEFT, MOD_ALT | MOD_SHIFT}, 296 + {"\x1b[1;5D", KEY_ARROW_LEFT, MOD_CTRL}, 297 + {"\x1b[1;6D", KEY_ARROW_LEFT, MOD_CTRL | MOD_SHIFT}, 298 + {"\x1b[1;7D", KEY_ARROW_LEFT, MOD_CTRL | MOD_ALT}, 299 + {"\x1b[1;8D", KEY_ARROW_LEFT, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 300 + 301 + /* home/end */ 302 + {"\x1b[1;2H", KEY_HOME, MOD_SHIFT}, 303 + {"\x1b[1;3H", KEY_HOME, MOD_ALT}, 304 + {"\x1b[1;4H", KEY_HOME, MOD_ALT | MOD_SHIFT}, 305 + {"\x1b[1;5H", KEY_HOME, MOD_CTRL}, 306 + {"\x1b[1;6H", KEY_HOME, MOD_CTRL | MOD_SHIFT}, 307 + {"\x1b[1;7H", KEY_HOME, MOD_CTRL | MOD_ALT}, 308 + {"\x1b[1;8H", KEY_HOME, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 309 + 310 + {"\x1b[1;2F", KEY_END, MOD_SHIFT}, 311 + {"\x1b[1;3F", KEY_END, MOD_ALT}, 312 + {"\x1b[1;4F", KEY_END, MOD_ALT | MOD_SHIFT}, 313 + {"\x1b[1;5F", KEY_END, MOD_CTRL}, 314 + {"\x1b[1;6F", KEY_END, MOD_CTRL | MOD_SHIFT}, 315 + {"\x1b[1;7F", KEY_END, MOD_CTRL | MOD_ALT}, 316 + {"\x1b[1;8F", KEY_END, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 317 + 318 + /* insert */ 319 + {"\x1b[2;2~", KEY_INSERT, MOD_SHIFT}, 320 + {"\x1b[2;3~", KEY_INSERT, MOD_ALT}, 321 + {"\x1b[2;4~", KEY_INSERT, MOD_ALT | MOD_SHIFT}, 322 + {"\x1b[2;5~", KEY_INSERT, MOD_CTRL}, 323 + {"\x1b[2;6~", KEY_INSERT, MOD_CTRL | MOD_SHIFT}, 324 + {"\x1b[2;7~", KEY_INSERT, MOD_CTRL | MOD_ALT}, 325 + {"\x1b[2;8~", KEY_INSERT, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 326 + 327 + /* delete */ 328 + {"\x1b[3;2~", KEY_DELETE, MOD_SHIFT}, 329 + {"\x1b[3;3~", KEY_DELETE, MOD_ALT}, 330 + {"\x1b[3;4~", KEY_DELETE, MOD_ALT | MOD_SHIFT}, 331 + {"\x1b[3;5~", KEY_DELETE, MOD_CTRL}, 332 + {"\x1b[3;6~", KEY_DELETE, MOD_CTRL | MOD_SHIFT}, 333 + {"\x1b[3;7~", KEY_DELETE, MOD_CTRL | MOD_ALT}, 334 + {"\x1b[3;8~", KEY_DELETE, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 335 + 336 + /* pgup */ 337 + {"\x1b[5;2~", KEY_PGUP, MOD_SHIFT}, 338 + {"\x1b[5;3~", KEY_PGUP, MOD_ALT}, 339 + {"\x1b[5;4~", KEY_PGUP, MOD_ALT | MOD_SHIFT}, 340 + {"\x1b[5;5~", KEY_PGUP, MOD_CTRL}, 341 + {"\x1b[5;6~", KEY_PGUP, MOD_CTRL | MOD_SHIFT}, 342 + {"\x1b[5;7~", KEY_PGUP, MOD_CTRL | MOD_ALT}, 343 + {"\x1b[5;8~", KEY_PGUP, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 344 + 345 + /* pgdn */ 346 + {"\x1b[6;2~", KEY_PGDN, MOD_SHIFT}, 347 + {"\x1b[6;3~", KEY_PGDN, MOD_ALT}, 348 + {"\x1b[6;4~", KEY_PGDN, MOD_ALT | MOD_SHIFT}, 349 + {"\x1b[6;5~", KEY_PGDN, MOD_CTRL}, 350 + {"\x1b[6;6~", KEY_PGDN, MOD_CTRL | MOD_SHIFT}, 351 + {"\x1b[6;7~", KEY_PGDN, MOD_CTRL | MOD_ALT}, 352 + {"\x1b[6;8~", KEY_PGDN, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 353 + 354 + /* F1–F4 */ 355 + {"\x1b[1;2P", KEY_F1, MOD_SHIFT}, 356 + {"\x1b[1;3P", KEY_F1, MOD_ALT}, 357 + {"\x1b[1;4P", KEY_F1, MOD_ALT | MOD_SHIFT}, 358 + {"\x1b[1;5P", KEY_F1, MOD_CTRL}, 359 + {"\x1b[1;6P", KEY_F1, MOD_CTRL | MOD_SHIFT}, 360 + {"\x1b[1;7P", KEY_F1, MOD_CTRL | MOD_ALT}, 361 + {"\x1b[1;8P", KEY_F1, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 362 + 363 + {"\x1b[1;2Q", KEY_F2, MOD_SHIFT}, 364 + {"\x1b[1;3Q", KEY_F2, MOD_ALT}, 365 + {"\x1b[1;4Q", KEY_F2, MOD_ALT | MOD_SHIFT}, 366 + {"\x1b[1;5Q", KEY_F2, MOD_CTRL}, 367 + {"\x1b[1;6Q", KEY_F2, MOD_CTRL | MOD_SHIFT}, 368 + {"\x1b[1;7Q", KEY_F2, MOD_CTRL | MOD_ALT}, 369 + {"\x1b[1;8Q", KEY_F2, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 370 + 371 + {"\x1b[1;2R", KEY_F3, MOD_SHIFT}, 372 + {"\x1b[1;3R", KEY_F3, MOD_ALT}, 373 + {"\x1b[1;4R", KEY_F3, MOD_ALT | MOD_SHIFT}, 374 + {"\x1b[1;5R", KEY_F3, MOD_CTRL}, 375 + {"\x1b[1;6R", KEY_F3, MOD_CTRL | MOD_SHIFT}, 376 + {"\x1b[1;7R", KEY_F3, MOD_CTRL | MOD_ALT}, 377 + {"\x1b[1;8R", KEY_F3, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 378 + 379 + {"\x1b[1;2S", KEY_F4, MOD_SHIFT}, 380 + {"\x1b[1;3S", KEY_F4, MOD_ALT}, 381 + {"\x1b[1;4S", KEY_F4, MOD_ALT | MOD_SHIFT}, 382 + {"\x1b[1;5S", KEY_F4, MOD_CTRL}, 383 + {"\x1b[1;6S", KEY_F4, MOD_CTRL | MOD_SHIFT}, 384 + {"\x1b[1;7S", KEY_F4, MOD_CTRL | MOD_ALT}, 385 + {"\x1b[1;8S", KEY_F4, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 386 + 387 + /* F5–F12 */ 388 + {"\x1b[15;2~", KEY_F5, MOD_SHIFT}, 389 + {"\x1b[15;3~", KEY_F5, MOD_ALT}, 390 + {"\x1b[15;4~", KEY_F5, MOD_ALT | MOD_SHIFT}, 391 + {"\x1b[15;5~", KEY_F5, MOD_CTRL}, 392 + {"\x1b[15;6~", KEY_F5, MOD_CTRL | MOD_SHIFT}, 393 + {"\x1b[15;7~", KEY_F5, MOD_CTRL | MOD_ALT}, 394 + {"\x1b[15;8~", KEY_F5, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 395 + 396 + {"\x1b[17;2~", KEY_F6, MOD_SHIFT}, 397 + {"\x1b[17;3~", KEY_F6, MOD_ALT}, 398 + {"\x1b[17;4~", KEY_F6, MOD_ALT | MOD_SHIFT}, 399 + {"\x1b[17;5~", KEY_F6, MOD_CTRL}, 400 + {"\x1b[17;6~", KEY_F6, MOD_CTRL | MOD_SHIFT}, 401 + {"\x1b[17;7~", KEY_F6, MOD_CTRL | MOD_ALT}, 402 + {"\x1b[17;8~", KEY_F6, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 403 + 404 + {"\x1b[18;2~", KEY_F7, MOD_SHIFT}, 405 + {"\x1b[18;3~", KEY_F7, MOD_ALT}, 406 + {"\x1b[18;4~", KEY_F7, MOD_ALT | MOD_SHIFT}, 407 + {"\x1b[18;5~", KEY_F7, MOD_CTRL}, 408 + {"\x1b[18;6~", KEY_F7, MOD_CTRL | MOD_SHIFT}, 409 + {"\x1b[18;7~", KEY_F7, MOD_CTRL | MOD_ALT}, 410 + {"\x1b[18;8~", KEY_F7, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 411 + 412 + {"\x1b[19;2~", KEY_F8, MOD_SHIFT}, 413 + {"\x1b[19;3~", KEY_F8, MOD_ALT}, 414 + {"\x1b[19;4~", KEY_F8, MOD_ALT | MOD_SHIFT}, 415 + {"\x1b[19;5~", KEY_F8, MOD_CTRL}, 416 + {"\x1b[19;6~", KEY_F8, MOD_CTRL | MOD_SHIFT}, 417 + {"\x1b[19;7~", KEY_F8, MOD_CTRL | MOD_ALT}, 418 + {"\x1b[19;8~", KEY_F8, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 419 + 420 + {"\x1b[20;2~", KEY_F9, MOD_SHIFT}, 421 + {"\x1b[20;3~", KEY_F9, MOD_ALT}, 422 + {"\x1b[20;4~", KEY_F9, MOD_ALT | MOD_SHIFT}, 423 + {"\x1b[20;5~", KEY_F9, MOD_CTRL}, 424 + {"\x1b[20;6~", KEY_F9, MOD_CTRL | MOD_SHIFT}, 425 + {"\x1b[20;7~", KEY_F9, MOD_CTRL | MOD_ALT}, 426 + {"\x1b[20;8~", KEY_F9, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 427 + 428 + {"\x1b[21;2~", KEY_F10, MOD_SHIFT}, 429 + {"\x1b[21;3~", KEY_F10, MOD_ALT}, 430 + {"\x1b[21;4~", KEY_F10, MOD_ALT | MOD_SHIFT}, 431 + {"\x1b[21;5~", KEY_F10, MOD_CTRL}, 432 + {"\x1b[21;6~", KEY_F10, MOD_CTRL | MOD_SHIFT}, 433 + {"\x1b[21;7~", KEY_F10, MOD_CTRL | MOD_ALT}, 434 + {"\x1b[21;8~", KEY_F10, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 435 + 436 + {"\x1b[23;2~", KEY_F11, MOD_SHIFT}, 437 + {"\x1b[23;3~", KEY_F11, MOD_ALT}, 438 + {"\x1b[23;4~", KEY_F11, MOD_ALT | MOD_SHIFT}, 439 + {"\x1b[23;5~", KEY_F11, MOD_CTRL}, 440 + {"\x1b[23;6~", KEY_F11, MOD_CTRL | MOD_SHIFT}, 441 + {"\x1b[23;7~", KEY_F11, MOD_CTRL | MOD_ALT}, 442 + {"\x1b[23;8~", KEY_F11, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 443 + 444 + {"\x1b[24;2~", KEY_F12, MOD_SHIFT}, 445 + {"\x1b[24;3~", KEY_F12, MOD_ALT}, 446 + {"\x1b[24;4~", KEY_F12, MOD_ALT | MOD_SHIFT}, 447 + {"\x1b[24;5~", KEY_F12, MOD_CTRL}, 448 + {"\x1b[24;6~", KEY_F12, MOD_CTRL | MOD_SHIFT}, 449 + {"\x1b[24;7~", KEY_F12, MOD_CTRL | MOD_ALT}, 450 + {"\x1b[24;8~", KEY_F12, MOD_CTRL | MOD_ALT | MOD_SHIFT}, 451 + 452 + {0, 0, 0}, 453 + }; 454 + 455 + /* ── Public API ───────────────────────────────────────────────────── */ 456 + 457 + int input_size(void) { return align8((int)sizeof(struct InputState)); } 458 + 459 + struct InputState *input_init(void *mem, int esc_latency_ms) { 460 + struct InputState *st = (struct InputState *)mem; 461 + memset(st, 0, sizeof(struct InputState)); 462 + st->esc_latency_ms = esc_latency_ms; 463 + 464 + /* build escape sequence trie from cap tables */ 465 + trie_init(st->trie, &st->trie_len); 466 + for (int i = 0; base_caps[i].seq; i++) 467 + trie_add(st->trie, &st->trie_len, base_caps[i].seq, 468 + strlen(base_caps[i].seq), base_caps[i].key, base_caps[i].mod); 469 + for (int i = 0; mod_caps[i].seq; i++) 470 + trie_add(st->trie, &st->trie_len, mod_caps[i].seq, strlen(mod_caps[i].seq), 471 + mod_caps[i].key, mod_caps[i].mod); 472 + 473 + return st; 474 + } 475 + 476 + int input_scan(struct InputState *st, const char *buf, int len, double now) { 477 + 478 + /* append incoming bytes (may be partial if buffer is full) */ 479 + int space = SCAN_BUFFER_SIZE - st->len; 480 + int accepted = len < space ? len : space; 481 + if (accepted > 0) { 482 + memcpy(st->buf + st->len, buf, accepted); 483 + st->len += accepted; 484 + } 485 + 486 + st->count = 0; 487 + 488 + while (st->len > 0 && st->count < MAX_EVENTS) { 489 + /* ── ESC handling ───────────────────────────────────────────── */ 490 + if ((uint8_t)st->buf[0] == 0x1b) { 491 + /* lone ESC? */ 492 + if (st->len == 1) { 493 + if (st->esc_time == 0) 494 + st->esc_time = now; 495 + if (now - st->esc_time >= (double)st->esc_latency_ms) { 496 + struct InputEvent *ev = emit(st); 497 + ev->type = EVENT_KEY; 498 + ev->key = KEY_ESC; 499 + shift(st, 1); 500 + st->esc_time = 0; 501 + continue; 502 + } 503 + /* pending — caller should retry after timeout */ 504 + return accepted; 505 + } 506 + 507 + /* try trie match */ 508 + { 509 + int consumed = 0; 510 + uint16_t key = 0; 511 + uint8_t mod = 0; 512 + int rv = trie_match(st->trie, st->buf, st->len, &consumed, &key, &mod); 513 + if (rv == PARSE_OK) { 514 + struct InputEvent *ev = emit(st); 515 + ev->type = EVENT_KEY; 516 + ev->key = key; 517 + ev->mod = mod; 518 + shift(st, consumed); 519 + st->esc_time = 0; 520 + continue; 521 + } 522 + if (rv == PARSE_NEED_MORE) { 523 + return accepted; 524 + } 525 + } 526 + 527 + /* try mouse */ 528 + { 529 + struct InputEvent mev; 530 + memset(&mev, 0, sizeof(mev)); 531 + int rv = parse_mouse(st, &mev); 532 + if (rv == PARSE_OK) { 533 + struct InputEvent *ev = emit(st); 534 + *ev = mev; 535 + st->esc_time = 0; 536 + continue; 537 + } 538 + if (rv == PARSE_NEED_MORE) { 539 + return accepted; 540 + } 541 + } 542 + 543 + /* unrecognized ESC sequence: treat as Alt + next byte */ 544 + shift(st, 1); 545 + st->esc_time = 0; 546 + 547 + if (st->len > 0) { 548 + uint8_t b = (uint8_t)st->buf[0]; 549 + struct InputEvent *ev = emit(st); 550 + ev->type = EVENT_KEY; 551 + ev->mod = MOD_ALT; 552 + 553 + if (b < 0x20) { 554 + ev->key = b; 555 + ev->mod |= MOD_CTRL; 556 + shift(st, 1); 557 + } else if (b == 0x7f) { 558 + ev->key = KEY_BACKSPACE; 559 + shift(st, 1); 560 + } else if (b >= 0x20 && b < 0x80) { 561 + ev->ch = b; 562 + shift(st, 1); 563 + } else { 564 + /* Alt + UTF-8 char */ 565 + int need = utf8_len(b); 566 + if (need > st->len) { 567 + return accepted; /* need more */ 568 + } 569 + uint32_t cp; 570 + utf8_decode(&cp, st->buf); 571 + ev->ch = cp; 572 + shift(st, need); 573 + } 574 + } 575 + continue; 576 + } 577 + 578 + /* ── Control characters ─────────────────────────────────────── */ 579 + uint8_t b = (uint8_t)st->buf[0]; 580 + 581 + if (b < 0x20) { 582 + struct InputEvent *ev = emit(st); 583 + ev->type = EVENT_KEY; 584 + ev->key = b; 585 + if (b != KEY_TAB && b != KEY_ENTER) { 586 + ev->mod = MOD_CTRL; 587 + } 588 + shift(st, 1); 589 + st->esc_time = 0; 590 + continue; 591 + } 592 + 593 + /* ── Backspace (0x7f) ───────────────────────────────────────── */ 594 + if (b == 0x7f) { 595 + struct InputEvent *ev = emit(st); 596 + ev->type = EVENT_KEY; 597 + ev->key = KEY_BACKSPACE; 598 + shift(st, 1); 599 + st->esc_time = 0; 600 + continue; 601 + } 602 + 603 + /* ── Printable / UTF-8 ──────────────────────────────────────── */ 604 + { 605 + int need = utf8_len(b); 606 + if (need > st->len) { 607 + return accepted; /* partial UTF-8 */ 608 + } 609 + uint32_t cp; 610 + int n = utf8_decode(&cp, st->buf); 611 + if (n <= 0) { 612 + n = 1; 613 + cp = 0xfffd; 614 + } 615 + struct InputEvent *ev = emit(st); 616 + ev->type = EVENT_KEY; 617 + ev->ch = cp; 618 + shift(st, n); 619 + st->esc_time = 0; 620 + } 621 + } 622 + 623 + return accepted; 624 + } 625 + 626 + int input_count(struct InputState *st) { return st->count; } 627 + 628 + struct InputEvent *input_event(struct InputState *st, int index) { 629 + if (index < 0 || index >= st->count) 630 + return 0; 631 + return &st->events[index]; 632 + } 633 + 634 + int input_delay(struct InputState *st) { 635 + if (st->esc_time > 0) { 636 + return st->esc_latency_ms; 637 + } 638 + return 0; 639 + }
+202
src/input.h
··· 1 + /* input.h — VT/ANSI escape sequence parser for terminal input 2 + * 3 + * Decodes raw terminal bytes into structured InputEvent records. Handles 4 + * escape sequences (arrow keys, function keys, etc.), mouse protocols 5 + * (VT200, SGR, urxvt), UTF-8 multibyte characters, and control keys. 6 + * 7 + * 8 + * Usage: 9 + * 1. input does not allocate any memory itself, so start by allocating 10 + * input_size() bytes of memory. 11 + * 2. Call input_init(mem, esc_latency_ms) to get an InputState. 12 + * 3. When bytes arrive from stdin, call input_scan(st, buf, len, now). 13 + * 4. Read events with input_count(st) and input_event(st, i). 14 + * 5. Check input_delay(st): if non-zero, re-call input_scan() with 15 + * an empty buffer (len=0) after that many milliseconds to resolve 16 + * a pending lone ESC. 17 + * 18 + * Example: 19 + * 20 + * void *mem = malloc(input_size()); 21 + * struct InputState *st = input_init(mem, 50); 22 + * 23 + * // in your event loop: 24 + * int accepted = input_scan(st, buf, nread, now_ms); 25 + * for (int i = 0; i < input_count(st); i++) { 26 + * struct InputEvent *ev = input_event(st, i); 27 + * // handle ev->type, ev->key, ev->ch, ev->mod ... 28 + * } 29 + * int delay = input_delay(st); 30 + * if (delay > 0) { 31 + * // schedule another input_scan(st, NULL, 0, now_ms) after delay ms 32 + * } 33 + * 34 + * Gotchas: 35 + * 36 + * - input_scan() may accept fewer bytes than provided if the internal 37 + * buffer (SCAN_BUFFER_SIZE) is full. The caller must re-feed the remainder. 38 + * - Event pointers from input_event() are invalidated by the next 39 + * input_scan() call. Copy any data you need before scanning again. 40 + * - At most 128 events are produced per scan. If saturated, the last 41 + * slot is overwritten. In practice this is unreachable. 42 + * - A lone ESC byte is ambiguous: it could be the Escape key or the 43 + * start of a sequence. The parser holds it until either more bytes 44 + * arrive or the ESC latency expires. You must honour input_delay() and 45 + * re-call input_scan() with len=0 to flush it. 46 + * - The `now` parameter must be monotonically increasing milliseconds 47 + * (e.g. Date.now()). The parser uses it solely for ESC latency 48 + * disambiguation. 49 + */ 50 + 51 + #ifndef INPUT_H 52 + #define INPUT_H 53 + 54 + #include <stdint.h> 55 + 56 + /* ── Event types ──────────────────────────────────────────────────── */ 57 + 58 + #define EVENT_KEY 1 59 + #define EVENT_MOUSE 2 60 + #define EVENT_RESIZE 3 61 + 62 + /* ── Modifier flags (bitwise) ─────────────────────────────────────── */ 63 + 64 + #define MOD_ALT 1 65 + #define MOD_CTRL 2 66 + #define MOD_SHIFT 4 67 + #define MOD_MOTION 8 68 + #define MOD_RELEASE 16 69 + 70 + /* ── Key codes ────────────────────────────────────────────────────── */ 71 + 72 + /* Function keys */ 73 + #define KEY_F1 0xFFFF 74 + #define KEY_F2 0xFFFE 75 + #define KEY_F3 0xFFFD 76 + #define KEY_F4 0xFFFC 77 + #define KEY_F5 0xFFFB 78 + #define KEY_F6 0xFFFA 79 + #define KEY_F7 0xFFF9 80 + #define KEY_F8 0xFFF8 81 + #define KEY_F9 0xFFF7 82 + #define KEY_F10 0xFFF6 83 + #define KEY_F11 0xFFF5 84 + #define KEY_F12 0xFFF4 85 + 86 + /* Navigation */ 87 + #define KEY_ARROW_UP 0xFFF3 88 + #define KEY_ARROW_DOWN 0xFFF2 89 + #define KEY_ARROW_LEFT 0xFFF1 90 + #define KEY_ARROW_RIGHT 0xFFF0 91 + #define KEY_HOME 0xFFEF 92 + #define KEY_END 0xFFEE 93 + #define KEY_INSERT 0xFFED 94 + #define KEY_DELETE 0xFFEC 95 + #define KEY_PGUP 0xFFEB 96 + #define KEY_PGDN 0xFFEA 97 + #define KEY_BACKTAB 0xFFE9 98 + 99 + /* Mouse */ 100 + #define KEY_MOUSE_LEFT 0xFFE8 101 + #define KEY_MOUSE_RIGHT 0xFFE7 102 + #define KEY_MOUSE_MIDDLE 0xFFE6 103 + #define KEY_MOUSE_RELEASE 0xFFE5 104 + #define KEY_MOUSE_WHEEL_UP 0xFFE4 105 + #define KEY_MOUSE_WHEEL_DOWN 0xFFE3 106 + 107 + /* Special keys (ASCII range) */ 108 + #define KEY_ESC 0x1B 109 + #define KEY_ENTER 0x0D 110 + #define KEY_TAB 0x09 111 + #define KEY_BACKSPACE 0x7F 112 + #define KEY_SPACE 0x20 113 + 114 + /** 115 + * Parsed terminal input event. All fields are represented as numbers for 116 + * efficient transport via linear memory. A semantic layer is added on top in 117 + * input.ts 118 + * 119 + * @field type Event kind: EVENT_KEY, EVENT_MOUSE, or EVENT_RESIZE. 120 + * @field mod Bitwise combination of MOD_ALT, MOD_CTRL, MOD_SHIFT, MOD_MOTION. 121 + * @field key KEY_* constant for special keys, raw byte for control chars, 122 + * or 0 when the event is a printable character (see ch). 123 + * @field ch Unicode codepoint for printable characters, 0 otherwise. 124 + * @field x Mouse column (0-based). Only valid for EVENT_MOUSE. 125 + * @field y Mouse row (0-based). Only valid for EVENT_MOUSE. 126 + * @field w Terminal width. Only valid for EVENT_RESIZE. 127 + * @field h Terminal height. Only valid for EVENT_RESIZE. 128 + */ 129 + struct InputEvent { 130 + uint8_t type; 131 + uint8_t mod; 132 + uint16_t key; 133 + uint32_t ch; 134 + int32_t x; 135 + int32_t y; 136 + int32_t w; 137 + int32_t h; 138 + }; 139 + 140 + /** 141 + * A struct in which the entire input state can be stored. It is deliberately 142 + * opaque since it comprises the private state of the input scanner. 143 + */ 144 + struct InputState; 145 + 146 + /** 147 + * Return the number of bytes needed to allocate an InputState. 148 + * 149 + * @return Required arena size in bytes (8-byte aligned). 150 + */ 151 + int input_size(void); 152 + 153 + /** 154 + * Initialize an input parser in the provided memory. 155 + * 156 + * @param mem Pointer to at least input_size() bytes. 157 + * @param esc_latency_ms ESC disambiguation latency in milliseconds. 158 + * @return Initialized parser state. 159 + */ 160 + struct InputState *input_init(void *mem, int esc_latency_ms); 161 + 162 + /** 163 + * Feed raw bytes into the parser and produce events. 164 + * 165 + * @param st Parser state from input_init(). 166 + * @param buf Input bytes to parse. 167 + * @param len Number of bytes in buf. 168 + * @param now Current timestamp in milliseconds (e.g. Date.now()). 169 + * Used for ESC latency disambiguation. 170 + * @return Number of bytes accepted. May be less than len if the 171 + * internal buffer is full (caller should re-feed the rest). 172 + */ 173 + int input_scan(struct InputState *st, const char *buf, int len, double now); 174 + 175 + /** 176 + * Return the number of events produced by the last input_scan() call. 177 + * 178 + * @param st Parser state. 179 + * @return Event count (0 to 128). 180 + */ 181 + int input_count(struct InputState *st); 182 + 183 + /** 184 + * Return a pointer to the event at the given index. 185 + * 186 + * @param st Parser state. 187 + * @param index Event index (0-based). 188 + * @return Pointer to the event, or NULL if index is out of range. 189 + */ 190 + struct InputEvent *input_event(struct InputState *st, int index); 191 + 192 + /** 193 + * Return the ESC latency delay if an ambiguous ESC is pending. 194 + * 195 + * @param st Parser state. 196 + * @return esc_latency_ms if a lone ESC is buffered, 0 otherwise. 197 + * When non-zero, the caller should re-call input_scan() 198 + * with an empty buffer after this many milliseconds. 199 + */ 200 + int input_delay(struct InputState *st); 201 + 202 + #endif
+9
src/mem.c
··· 16 16 *d++ = (unsigned char)c; 17 17 return dst; 18 18 } 19 + 20 + size_t strlen(const char *s) { 21 + size_t n = 0; 22 + while (s[n]) 23 + n++; 24 + return n; 25 + } 26 + 27 + int align8(int n) { return (n + 7) & ~7; }
+2
src/mem.h
··· 7 7 8 8 void *memcpy(void *dst, const void *src, size_t n); 9 9 void *memset(void *dst, int c, size_t n); 10 + size_t strlen(const char *s); 11 + int align8(int n); 10 12 11 13 #endif
+2
src/module.c
··· 8 8 #include "utf8.c" 9 9 #include "wcwidth.c" 10 10 #include "clayterm.c" 11 + #include "trie.c" 12 + #include "input.c"
+115
src/trie.c
··· 1 + #include "trie.h" 2 + #include "mem.h" 3 + 4 + void trie_init(Trie trie, int *len) { 5 + memset(&trie[0], 0, sizeof(struct TrieNode)); 6 + *len = 1; 7 + } 8 + 9 + int trie_add(Trie trie, int *len, const char *seq, int slen, uint16_t key, 10 + uint8_t mod) { 11 + uint16_t cur = 0; 12 + 13 + for (int i = 0; i < slen; i++) { 14 + uint8_t b = (uint8_t)seq[i]; 15 + uint16_t child = trie[cur].child; 16 + uint16_t prev = 0; 17 + int found = 0; 18 + 19 + while (child != 0) { 20 + if (trie[child].byte == b) { 21 + cur = child; 22 + found = 1; 23 + break; 24 + } 25 + prev = child; 26 + child = trie[child].sibling; 27 + } 28 + 29 + if (!found) { 30 + if (*len >= MAX_TRIE_NODES) 31 + return -1; 32 + uint16_t idx = (uint16_t)(*len); 33 + (*len)++; 34 + memset(&trie[idx], 0, sizeof(struct TrieNode)); 35 + trie[idx].byte = b; 36 + 37 + if (prev != 0) { 38 + trie[prev].sibling = idx; 39 + } else { 40 + trie[cur].child = idx; 41 + } 42 + cur = idx; 43 + } 44 + } 45 + 46 + if (!trie[cur].is_leaf) { 47 + trie[cur].is_leaf = 1; 48 + trie[cur].key = key; 49 + trie[cur].mod = mod; 50 + } 51 + return 0; 52 + } 53 + 54 + int trie_match(const Trie trie, const char *buf, int buflen, int *consumed, 55 + uint16_t *key, uint8_t *mod) { 56 + uint16_t cur = 0; 57 + int last_leaf_depth = -1; 58 + uint16_t last_leaf_key = 0; 59 + uint8_t last_leaf_mod = 0; 60 + int has_children = 0; 61 + 62 + for (int i = 0; i < buflen; i++) { 63 + uint8_t b = (uint8_t)buf[i]; 64 + uint16_t child = trie[cur].child; 65 + int found = 0; 66 + 67 + while (child != 0) { 68 + if (trie[child].byte == b) { 69 + cur = child; 70 + found = 1; 71 + break; 72 + } 73 + child = trie[child].sibling; 74 + } 75 + 76 + if (!found) { 77 + if (last_leaf_depth >= 0) { 78 + *consumed = last_leaf_depth; 79 + *key = last_leaf_key; 80 + *mod = last_leaf_mod; 81 + return PARSE_OK; 82 + } 83 + return PARSE_ERR; 84 + } 85 + 86 + if (trie[cur].is_leaf) { 87 + last_leaf_depth = i + 1; 88 + last_leaf_key = trie[cur].key; 89 + last_leaf_mod = trie[cur].mod; 90 + } 91 + has_children = (trie[cur].child != 0); 92 + } 93 + 94 + if (last_leaf_depth >= 0 && !has_children) { 95 + *consumed = last_leaf_depth; 96 + *key = last_leaf_key; 97 + *mod = last_leaf_mod; 98 + return PARSE_OK; 99 + } 100 + if (has_children) { 101 + if (last_leaf_depth >= 0) { 102 + *consumed = last_leaf_depth; 103 + *key = last_leaf_key; 104 + *mod = last_leaf_mod; 105 + } 106 + return PARSE_NEED_MORE; 107 + } 108 + if (last_leaf_depth >= 0) { 109 + *consumed = last_leaf_depth; 110 + *key = last_leaf_key; 111 + *mod = last_leaf_mod; 112 + return PARSE_OK; 113 + } 114 + return PARSE_ERR; 115 + }
+32
src/trie.h
··· 1 + #ifndef TRIE_H 2 + #define TRIE_H 3 + 4 + #include <stdint.h> 5 + 6 + #define PARSE_OK 0 7 + #define PARSE_ERR 1 8 + #define PARSE_NEED_MORE 2 9 + 10 + #define MAX_TRIE_NODES 1024 11 + 12 + struct TrieNode { 13 + uint8_t byte; 14 + uint8_t is_leaf; 15 + uint16_t key; 16 + uint8_t mod; 17 + uint8_t pad; 18 + uint16_t child; 19 + uint16_t sibling; 20 + }; 21 + 22 + typedef struct TrieNode Trie[MAX_TRIE_NODES]; 23 + 24 + void trie_init(Trie trie, int *len); 25 + 26 + int trie_add(Trie trie, int *len, const char *seq, int slen, uint16_t key, 27 + uint8_t mod); 28 + 29 + int trie_match(const Trie trie, const char *buf, int buflen, int *consumed, 30 + uint16_t *key, uint8_t *mod); 31 + 32 + #endif
+1 -1
src/utf8.c
··· 19 19 20 20 static const unsigned char utf8_mask[6] = {0x7f, 0x1f, 0x0f, 0x07, 0x03, 0x01}; 21 21 22 - static int utf8_len(char c) { return utf8_length[(unsigned char)c]; } 22 + int utf8_len(char c) { return utf8_length[(unsigned char)c]; } 23 23 24 24 int utf8_decode(uint32_t *out, const char *c) { 25 25 if (*c == '\0')
+24
src/utf8.h
··· 5 5 6 6 #include <stdint.h> 7 7 8 + /** 9 + * Return the byte length of a UTF-8 sequence given its leading byte. 10 + * 11 + * @param c Leading byte of a UTF-8 sequence. 12 + * @return Number of bytes in the sequence (1–6). 13 + */ 14 + int utf8_len(char c); 15 + 16 + /** 17 + * Decode a UTF-8 sequence into a Unicode codepoint. 18 + * 19 + * @param out Output codepoint. 20 + * @param c Pointer to the start of a UTF-8 sequence. 21 + * @return Number of bytes consumed (1–6), or negative on error. 22 + * Returns 0 if c points to a null terminator. 23 + */ 8 24 int utf8_decode(uint32_t *out, const char *c); 25 + 26 + /** 27 + * Encode a Unicode codepoint as UTF-8. 28 + * 29 + * @param out Output buffer (must hold at least 7 bytes including null). 30 + * @param c Unicode codepoint. 31 + * @return Number of bytes written (1–6), excluding null terminator. 32 + */ 9 33 int utf8_encode(char *out, uint32_t c); 10 34 11 35 #endif
+8 -7
term.ts
··· 1 1 import { type Op, pack } from "./ops.ts"; 2 - import { load } from "./native.ts"; 2 + import { createTermNative } from "./term-native.ts"; 3 3 4 4 export interface TermOptions { 5 5 height: number; ··· 11 11 } 12 12 13 13 export async function createTerm(options: TermOptions): Promise<Term> { 14 - const { width, height } = options; 15 - const { memory, statePtr, opsBuf, reduce, output, length } = await load( 16 - width, 17 - height, 18 - ); 14 + let { width, height } = options; 15 + let { memory, statePtr, opsBuf, reduce, output, length } = 16 + await createTermNative( 17 + width, 18 + height, 19 + ); 19 20 20 21 return { 21 22 render(ops: Op[]): Uint8Array { 22 - const len = pack(ops, memory.buffer, opsBuf); 23 + let len = pack(ops, memory.buffer, opsBuf); 23 24 reduce(statePtr, opsBuf, len); 24 25 return new Uint8Array( 25 26 memory.buffer,
+367
test/input.test.ts
··· 1 + import { beforeEach, describe, expect, it } from "./suite.ts"; 2 + import { createInput, type Input } from "../input.ts"; 3 + 4 + function bytes(...values: number[]): Uint8Array { 5 + return new Uint8Array(values); 6 + } 7 + 8 + function str(s: string): Uint8Array { 9 + return new TextEncoder().encode(s); 10 + } 11 + 12 + describe("input", () => { 13 + let input: Input; 14 + 15 + beforeEach(async () => { 16 + input = await createInput({ escLatency: 25 }); 17 + }); 18 + 19 + describe("printable ASCII", () => { 20 + it("parses a single character", () => { 21 + let result = input.scan(bytes(0x61)); // 'a' 22 + expect(result.events.length).toBe(1); 23 + expect(result.events[0]).toMatchObject({ type: "char", key: "a" }); 24 + }); 25 + 26 + it("parses multiple characters", () => { 27 + let result = input.scan(str("hello")); 28 + expect(result.events.length).toBe(5); 29 + expect(result.events[0]).toMatchObject({ type: "char", key: "h" }); 30 + expect(result.events[4]).toMatchObject({ type: "char", key: "o" }); 31 + }); 32 + }); 33 + 34 + describe("control characters", () => { 35 + it("parses Ctrl+C", () => { 36 + let result = input.scan(bytes(0x03)); 37 + expect(result.events.length).toBe(1); 38 + expect(result.events[0]).toMatchObject({ 39 + type: "char", 40 + key: "c", 41 + ctrl: true, 42 + }); 43 + }); 44 + 45 + it("parses Tab without ctrl modifier", () => { 46 + let result = input.scan(bytes(0x09)); 47 + expect(result.events.length).toBe(1); 48 + expect(result.events[0]).toMatchObject({ type: "key", key: "Tab" }); 49 + }); 50 + 51 + it("parses Enter without ctrl modifier", () => { 52 + let result = input.scan(bytes(0x0d)); 53 + expect(result.events.length).toBe(1); 54 + expect(result.events[0]).toMatchObject({ type: "key", key: "Enter" }); 55 + }); 56 + }); 57 + 58 + describe("backspace", () => { 59 + it("parses 0x7f as backspace", () => { 60 + let result = input.scan(bytes(0x7f)); 61 + expect(result.events.length).toBe(1); 62 + expect(result.events[0]).toMatchObject({ type: "key", key: "Backspace" }); 63 + }); 64 + }); 65 + 66 + describe("arrow keys", () => { 67 + it("parses arrow up (CSI)", () => { 68 + let result = input.scan(bytes(0x1b, 0x5b, 0x41)); 69 + expect(result.events.length).toBe(1); 70 + expect(result.events[0]).toMatchObject({ type: "key", key: "ArrowUp" }); 71 + }); 72 + 73 + it("parses arrow down (SS3)", () => { 74 + let result = input.scan(bytes(0x1b, 0x4f, 0x42)); 75 + expect(result.events.length).toBe(1); 76 + expect(result.events[0]).toMatchObject({ type: "key", key: "ArrowDown" }); 77 + }); 78 + }); 79 + 80 + describe("modifier combos", () => { 81 + it("parses Ctrl+Arrow Up", () => { 82 + let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x35, 0x41)); 83 + expect(result.events.length).toBe(1); 84 + expect(result.events[0]).toMatchObject({ 85 + type: "key", 86 + key: "ArrowUp", 87 + ctrl: true, 88 + }); 89 + }); 90 + 91 + it("parses Shift+Arrow Up", () => { 92 + let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x32, 0x41)); 93 + expect(result.events.length).toBe(1); 94 + expect(result.events[0]).toMatchObject({ 95 + type: "key", 96 + key: "ArrowUp", 97 + shift: true, 98 + }); 99 + }); 100 + 101 + it("parses Ctrl+Alt+Shift combo", () => { 102 + let result = input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x38, 0x41)); 103 + expect(result.events.length).toBe(1); 104 + expect(result.events[0]).toMatchObject({ 105 + type: "key", 106 + key: "ArrowUp", 107 + ctrl: true, 108 + alt: true, 109 + shift: true, 110 + }); 111 + }); 112 + }); 113 + 114 + describe("function keys", () => { 115 + it("parses F1 (SS3)", () => { 116 + let result = input.scan(bytes(0x1b, 0x4f, 0x50)); 117 + expect(result.events.length).toBe(1); 118 + expect(result.events[0]).toMatchObject({ type: "key", key: "F1" }); 119 + }); 120 + 121 + it("parses F5 (CSI ~)", () => { 122 + let result = input.scan(str("\x1b[15~")); 123 + expect(result.events.length).toBe(1); 124 + expect(result.events[0]).toMatchObject({ type: "key", key: "F5" }); 125 + }); 126 + }); 127 + 128 + describe("navigation keys", () => { 129 + it("parses Home", () => { 130 + let result = input.scan(bytes(0x1b, 0x4f, 0x48)); 131 + expect(result.events.length).toBe(1); 132 + expect(result.events[0]).toMatchObject({ type: "key", key: "Home" }); 133 + }); 134 + 135 + it("parses End", () => { 136 + let result = input.scan(bytes(0x1b, 0x4f, 0x46)); 137 + expect(result.events.length).toBe(1); 138 + expect(result.events[0]).toMatchObject({ type: "key", key: "End" }); 139 + }); 140 + 141 + it("parses Delete", () => { 142 + let result = input.scan(str("\x1b[3~")); 143 + expect(result.events.length).toBe(1); 144 + expect(result.events[0]).toMatchObject({ type: "key", key: "Delete" }); 145 + }); 146 + 147 + it("parses PgDn with Ctrl", () => { 148 + let result = input.scan(str("\x1b[6;5~")); 149 + expect(result.events.length).toBe(1); 150 + expect(result.events[0]).toMatchObject({ 151 + type: "key", 152 + key: "PageDown", 153 + ctrl: true, 154 + }); 155 + }); 156 + }); 157 + 158 + describe("mouse", () => { 159 + it("parses SGR mouse press", () => { 160 + let result = input.scan(str("\x1b[<0;35;12M")); 161 + expect(result.events.length).toBe(1); 162 + expect(result.events[0]).toMatchObject({ 163 + type: "mouse", 164 + button: "left", 165 + x: 34, 166 + y: 11, 167 + }); 168 + }); 169 + 170 + it("parses SGR left release", () => { 171 + let result = input.scan(str("\x1b[<0;10;5m")); 172 + expect(result.events.length).toBe(1); 173 + expect(result.events[0]).toMatchObject({ 174 + type: "mouse", 175 + button: "left", 176 + x: 9, 177 + y: 4, 178 + release: true, 179 + }); 180 + }); 181 + 182 + it("parses SGR right release", () => { 183 + let result = input.scan(str("\x1b[<2;10;5m")); 184 + expect(result.events.length).toBe(1); 185 + expect(result.events[0]).toMatchObject({ 186 + type: "mouse", 187 + button: "right", 188 + x: 9, 189 + y: 4, 190 + release: true, 191 + }); 192 + }); 193 + 194 + it("parses SGR middle release", () => { 195 + let result = input.scan(str("\x1b[<1;10;5m")); 196 + expect(result.events.length).toBe(1); 197 + expect(result.events[0]).toMatchObject({ 198 + type: "mouse", 199 + button: "middle", 200 + x: 9, 201 + y: 4, 202 + release: true, 203 + }); 204 + }); 205 + 206 + it("parses VT200 release", () => { 207 + // button 3 = release (0x23 = 3 + 0x20), x=10 (0x2b), y=5 (0x26) 208 + let result = input.scan(bytes(0x1b, 0x5b, 0x4d, 0x23, 0x2b, 0x26)); 209 + expect(result.events.length).toBe(1); 210 + expect(result.events[0]).toMatchObject({ 211 + type: "mouse", 212 + button: "release", 213 + x: 10, 214 + y: 5, 215 + release: true, 216 + }); 217 + }); 218 + 219 + it("parses SGR wheel up", () => { 220 + let result = input.scan(str("\x1b[<64;15;20M")); 221 + expect(result.events.length).toBe(1); 222 + expect(result.events[0]).toMatchObject({ 223 + type: "wheel", 224 + direction: "up", 225 + x: 14, 226 + y: 19, 227 + }); 228 + }); 229 + 230 + it("parses SGR wheel down", () => { 231 + let result = input.scan(str("\x1b[<65;15;20M")); 232 + expect(result.events.length).toBe(1); 233 + expect(result.events[0]).toMatchObject({ 234 + type: "wheel", 235 + direction: "down", 236 + x: 14, 237 + y: 19, 238 + }); 239 + }); 240 + 241 + it("parses VT200 mouse", () => { 242 + // \x1b[M + button(0+0x20=0x20) + x(10+0x21=0x2b) + y(5+0x21=0x26) 243 + let result = input.scan(bytes(0x1b, 0x5b, 0x4d, 0x20, 0x2b, 0x26)); 244 + expect(result.events.length).toBe(1); 245 + expect(result.events[0]).toMatchObject({ 246 + type: "mouse", 247 + button: "left", 248 + x: 10, 249 + y: 5, 250 + }); 251 + }); 252 + }); 253 + 254 + describe("ESC timeout", () => { 255 + it("returns pending for lone ESC", () => { 256 + let result = input.scan(bytes(0x1b)); 257 + expect(result.events.length).toBe(0); 258 + expect(result.pending).toBeDefined(); 259 + expect(result.pending!.delay).toBe(25); 260 + }); 261 + 262 + it("resolves ESC after timeout", async () => { 263 + input.scan(bytes(0x1b)); 264 + await new Promise((r) => setTimeout(r, 30)); 265 + let result = input.scan(new Uint8Array(0)); 266 + expect(result.events.length).toBe(1); 267 + expect(result.events[0]).toMatchObject({ type: "key", key: "Escape" }); 268 + expect(result.pending).toBeUndefined(); 269 + }); 270 + 271 + it("resolves as escape sequence when more bytes arrive", () => { 272 + let r1 = input.scan(bytes(0x1b)); 273 + expect(r1.events.length).toBe(0); 274 + 275 + let r2 = input.scan(bytes(0x5b, 0x41)); 276 + expect(r2.events.length).toBe(1); 277 + expect(r2.events[0]).toMatchObject({ type: "key", key: "ArrowUp" }); 278 + expect(r2.pending).toBeUndefined(); 279 + }); 280 + 281 + it("resolves pending ESC as Alt modifier when plain byte arrives", () => { 282 + let r1 = input.scan(bytes(0x1b)); 283 + expect(r1.events.length).toBe(0); 284 + expect(r1.pending).toBeDefined(); 285 + 286 + // "x" arrives — ESC + x = Alt+x 287 + let r2 = input.scan(bytes(0x78)); // 'x' 288 + expect(r2.events.length).toBe(1); 289 + expect(r2.events[0]).toMatchObject({ type: "char", key: "x", alt: true }); 290 + expect(r2.pending).toBeUndefined(); 291 + }); 292 + 293 + it("completes pending ESC sequence and parses trailing bytes", () => { 294 + let r1 = input.scan(bytes(0x1b)); 295 + expect(r1.events.length).toBe(0); 296 + expect(r1.pending).toBeDefined(); 297 + 298 + // rest of ArrowUp + "hi" 299 + let r2 = input.scan(bytes(0x5b, 0x41, 0x68, 0x69)); 300 + expect(r2.events.length).toBe(3); 301 + expect(r2.events[0]).toMatchObject({ type: "key", key: "ArrowUp" }); 302 + expect(r2.events[1]).toMatchObject({ type: "char", key: "h" }); 303 + expect(r2.events[2]).toMatchObject({ type: "char", key: "i" }); 304 + expect(r2.pending).toBeUndefined(); 305 + }); 306 + }); 307 + 308 + describe("Alt combinations", () => { 309 + it("parses Alt+a as unrecognized ESC sequence", () => { 310 + let result = input.scan(bytes(0x1b, 0x61)); 311 + expect(result.events.length).toBe(1); 312 + expect(result.events[0]).toMatchObject({ 313 + type: "char", 314 + key: "a", 315 + alt: true, 316 + }); 317 + }); 318 + }); 319 + 320 + describe("multi-event burst", () => { 321 + it("parses multiple escape sequences in one call", () => { 322 + // Arrow Up + Arrow Down + "hi" 323 + let result = input.scan( 324 + bytes(0x1b, 0x5b, 0x41, 0x1b, 0x5b, 0x42, 0x68, 0x69), 325 + ); 326 + expect(result.events.length).toBe(4); 327 + expect(result.events[0]).toMatchObject({ type: "key", key: "ArrowUp" }); 328 + expect(result.events[1]).toMatchObject({ type: "key", key: "ArrowDown" }); 329 + expect(result.events[2]).toMatchObject({ type: "char", key: "h" }); 330 + expect(result.events[3]).toMatchObject({ type: "char", key: "i" }); 331 + }); 332 + }); 333 + 334 + describe("partial sequences", () => { 335 + it("buffers partial escape sequence", () => { 336 + let r1 = input.scan(bytes(0x1b, 0x5b)); 337 + expect(r1.events.length).toBe(0); 338 + 339 + let r2 = input.scan(bytes(0x41)); 340 + expect(r2.events.length).toBe(1); 341 + expect(r2.events[0]).toMatchObject({ type: "key", key: "ArrowUp" }); 342 + }); 343 + }); 344 + 345 + describe("UTF-8", () => { 346 + it("parses 2-byte UTF-8 (é)", () => { 347 + let result = input.scan(bytes(0xc3, 0xa9)); 348 + expect(result.events.length).toBe(1); 349 + expect(result.events[0]).toMatchObject({ type: "char", key: "\u00e9" }); 350 + }); 351 + 352 + it("parses 3-byte UTF-8 (中)", () => { 353 + let result = input.scan(bytes(0xe4, 0xb8, 0xad)); 354 + expect(result.events.length).toBe(1); 355 + expect(result.events[0]).toMatchObject({ type: "char", key: "\u4e2d" }); 356 + }); 357 + 358 + it("parses 4-byte UTF-8 (emoji 🎉)", () => { 359 + let result = input.scan(bytes(0xf0, 0x9f, 0x8e, 0x89)); 360 + expect(result.events.length).toBe(1); 361 + expect(result.events[0]).toMatchObject({ 362 + type: "char", 363 + key: "\u{1f389}", 364 + }); 365 + }); 366 + }); 367 + });
+5 -5
test/print.ts
··· 4 4 * Strips all SGR (color/style) sequences. 5 5 */ 6 6 export function print(ansi: string, w: number, h: number): string { 7 - const grid: string[][] = []; 7 + let grid: string[][] = []; 8 8 for (let y = 0; y < h; y++) { 9 9 grid[y] = []; 10 10 for (let x = 0; x < w; x++) { ··· 27 27 ) { 28 28 params += ansi[i++]; 29 29 } 30 - const cmd = ansi[i++]; 30 + let cmd = ansi[i++]; 31 31 32 32 if (cmd === "H") { 33 33 // cursor position: row;col (1-indexed) 34 - const parts = params.split(";"); 34 + let parts = params.split(";"); 35 35 y = (parseInt(parts[0]) || 1) - 1; 36 36 x = (parseInt(parts[1]) || 1) - 1; 37 37 } else if (cmd === "m") { ··· 40 40 // ignore all other CSI sequences (?25l, ?25h, etc.) 41 41 } else { 42 42 // regular character — could be multi-byte UTF-8 43 - const cp = ansi.codePointAt(i)!; 44 - const ch = String.fromCodePoint(cp); 43 + let cp = ansi.codePointAt(i)!; 44 + let ch = String.fromCodePoint(cp); 45 45 if (x >= 0 && x < w && y >= 0 && y < h) { 46 46 grid[y][x] = ch; 47 47 }
+2 -2
test/term.test.ts
··· 13 13 }); 14 14 15 15 it("renders hello world", () => { 16 - const out = print( 16 + let out = print( 17 17 decode(term.render([ 18 18 open("root", { 19 19 layout: { width: grow(), height: grow(), direction: "ttb" }, ··· 29 29 }); 30 30 31 31 it("renders borders and padding", () => { 32 - const out = print( 32 + let out = print( 33 33 decode(term.render([ 34 34 open("box", { 35 35 layout: {
+2 -2
test/validate.test.ts
··· 1 1 import { beforeEach, describe, expect, it } from "./suite.ts"; 2 2 import { createTerm, type Term } from "../term.ts"; 3 - import { close, grow, open, rgba, text } from "../ops.ts"; 3 + import { close, grow, open, text } from "../ops.ts"; 4 4 import { assert, validate, validated } from "../validate.ts"; 5 5 import { print } from "./print.ts"; 6 6 ··· 88 88 }); 89 89 90 90 it("renders valid ops normally", () => { 91 - const out = print( 91 + let out = print( 92 92 decode(term.render([ 93 93 open("root", { 94 94 layout: { width: grow(), height: grow(), direction: "ttb" },
+2 -2
validate.ts
··· 122 122 123 123 export function assert(ops: unknown): asserts ops is Op[] { 124 124 if (!compiled.Check(ops)) { 125 - const errors = [...compiled.Errors(ops)]; 126 - const msg = errors 125 + let errors = [...compiled.Errors(ops)]; 126 + let msg = errors 127 127 .slice(0, 5) 128 128 .map((e) => `${e.path}: ${e.message}`) 129 129 .join("\n");
+7
wasm.ts
··· 1 + import { readFile } from "node:fs/promises"; 2 + 3 + const wasm = new Uint8Array( 4 + await readFile(new URL("./clayterm.wasm", import.meta.url)), 5 + ); 6 + 7 + export const compiled = await WebAssembly.compile(wasm);