Skip to content

Commit d36ffef

Browse files
committed
✨ 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
1 parent ae32caf commit d36ffef

25 files changed

Lines changed: 2168 additions & 57 deletions

README.md

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# clayterm
22

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
59

610
With every frame, the entire UI tree is packed into a flat byte array and sent
711
to WASM in a single call. On the C side, Clay runs layout, render commands are
@@ -30,8 +34,39 @@ WebAssembly does: Deno, Node, Bun, browsers, or any other runtime.
3034
+---------------+ +---------------------------+
3135
```
3236

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+
3366
## Usage
3467

68+
### Output
69+
3570
```typescript
3671
import { close, createTerm, grow, open, rgba, text } from "clayterm";
3772

@@ -57,7 +92,38 @@ const ansi = term.render([
5792
close(),
5893
]);
5994

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+
});
61127
```
62128

63129
## Development

deno.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"exclude": ["clay", "build"]
2626
},
2727
"lint": {
28-
"exclude": ["clay", "build"]
28+
"rules": { "exclude": ["prefer-const"] },
29+
"exclude": ["clay", "build"],
30+
"plugins": ["lint/prefer-let.ts"]
2931
}
3032
}

input-native.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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;

0 commit comments

Comments
 (0)