wongtty 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,866 @@
1
+ // wongtty — terminal emulator engine (Rust → WebAssembly).
2
+ // Works in browsers, bundlers (Vite/webpack resolve the .wasm URL) and Node ≥ 18.
3
+
4
+ const ABI = 15;
5
+ const encoder = new TextEncoder();
6
+ const decoder = new TextDecoder();
7
+ // Input buffer: smallest allocation, and the largest one kept between writes.
8
+ const IN_MIN = 4096;
9
+ const IN_KEEP = 256 * 1024;
10
+
11
+ /** @type {any} */
12
+ let wasm = null;
13
+ let initPromise = null;
14
+ // The compiled module, kept to rebuild the instance after a trap.
15
+ let wasmModule = null;
16
+ // Bumped each time the instance is rebuilt; terminals from an older one start over.
17
+ let generation = 0;
18
+
19
+ /**
20
+ * Loads the WebAssembly module once. Call before creating terminals.
21
+ * @param {URL | string | ArrayBuffer | ArrayBufferView | WebAssembly.Module | Response} [source]
22
+ */
23
+ export function init(source) {
24
+ initPromise ??= load(source).catch((err) => {
25
+ initPromise = null;
26
+ throw err;
27
+ });
28
+ return initPromise;
29
+ }
30
+
31
+ async function load(source) {
32
+ source ??= new URL('./wongtty.wasm', import.meta.url);
33
+ let module;
34
+ let instance;
35
+ if (source instanceof WebAssembly.Module) {
36
+ module = source;
37
+ instance = await WebAssembly.instantiate(source, {});
38
+ } else if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) {
39
+ ({ module, instance } = await WebAssembly.instantiate(source, {}));
40
+ } else {
41
+ const response = typeof Response !== 'undefined' && source instanceof Response
42
+ ? source
43
+ : await fetchWasm(source);
44
+ if (response instanceof Uint8Array) {
45
+ ({ module, instance } = await WebAssembly.instantiate(response, {}));
46
+ } else if (WebAssembly.instantiateStreaming &&
47
+ response.headers.get('content-type')?.startsWith('application/wasm')) {
48
+ ({ module, instance } = await WebAssembly.instantiateStreaming(response, {}));
49
+ } else {
50
+ ({ module, instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {}));
51
+ }
52
+ }
53
+ const abi = instance.exports.wongtty_abi();
54
+ if (abi !== ABI) throw new Error(`wongtty: wasm ABI ${abi} does not match JS wrapper ABI ${ABI}`);
55
+ wasmModule = module;
56
+ wasm = guard(instance.exports);
57
+ }
58
+
59
+ // A Rust panic aborts the whole instance (every terminal in it) with a
60
+ // WebAssembly.RuntimeError. Each export is wrapped so that a trap rebuilds the
61
+ // instance from the same module before the error reaches the caller; terminals
62
+ // notice the new generation on their next call and start over empty.
63
+ function guard(exports) {
64
+ const guarded = { memory: exports.memory };
65
+ for (const [name, fn] of Object.entries(exports)) {
66
+ if (typeof fn !== 'function') continue;
67
+ guarded[name] = (a, b, c, d) => {
68
+ try {
69
+ return fn(a, b, c, d);
70
+ } catch (err) {
71
+ if (err instanceof WebAssembly.RuntimeError) rebuild();
72
+ throw err;
73
+ }
74
+ };
75
+ }
76
+ return guarded;
77
+ }
78
+
79
+ function rebuild() {
80
+ wasm = guard(new WebAssembly.Instance(wasmModule, {}).exports);
81
+ generation++;
82
+ }
83
+
84
+ async function fetchWasm(source) {
85
+ const base = globalThis.location?.href ?? import.meta.url;
86
+ const url = source instanceof URL ? source : new URL(String(source), base);
87
+ if (url.protocol === 'file:') {
88
+ // Keep the name in a variable so browser bundlers do not follow the node: module.
89
+ const fsModule = 'node:fs/promises';
90
+ const { readFile } = await import(/* @vite-ignore */ /* webpackIgnore: true */ fsModule);
91
+ return new Uint8Array(await readFile(url));
92
+ }
93
+ const response = await fetch(url);
94
+ if (!response.ok) throw new Error(`wongtty: failed to fetch ${url} (${response.status})`);
95
+ return response;
96
+ }
97
+
98
+ function requireWasm() {
99
+ if (!wasm) throw new Error('wongtty: call `await init()` before creating a Terminal');
100
+ return wasm;
101
+ }
102
+
103
+ const bytesOut = (len) => new Uint8Array(wasm.memory.buffer, wasm.wongtty_out_ptr(), len).slice();
104
+ const textOut = (len) => (len <= 0 ? '' : decoder.decode(new Uint8Array(wasm.memory.buffer, wasm.wongtty_out_ptr(), len)));
105
+ const wordsOut = (n) => (n <= 0 ? new Uint32Array(0) : new Uint32Array(wasm.memory.buffer, wasm.wongtty_out32_ptr(), n).slice());
106
+
107
+ /** Rows `textRows` asks the engine for at a time, so the text it holds in its memory stays small. */
108
+ const TEXT_BLOCK = 1024;
109
+ /** Matches `searchRows` asks the engine for at a time (16 bytes each in its memory), so a common query does not grow it. */
110
+ const MATCH_PAGE = 4096;
111
+
112
+ /** `@xterm/addon-search` `NON_WORD_CHARACTERS`: what ends a word for `wholeWord`. */
113
+ const NON_WORD = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?';
114
+ const CELL_WIDE = 0x80000000;
115
+
116
+ const isWholeWord = (text, at, len) =>
117
+ (at === 0 || NON_WORD.includes(text[at - 1])) && (at + len === text.length || NON_WORD.includes(text[at + len]));
118
+
119
+ // The first regex match in `text` at or after unit `from`, as [index, length]; empty matches are skipped.
120
+ function regexNext(re, text, from, wholeWord) {
121
+ for (let at = from; at <= text.length;) {
122
+ re.lastIndex = at;
123
+ const m = re.exec(text);
124
+ if (!m) return null;
125
+ if (m[0].length && (!wholeWord || isWholeWord(text, m.index, m[0].length))) return [m.index, m[0].length];
126
+ at = m.index + 1;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ // The last regex match in `text` that ends at or before unit `before`.
132
+ function regexPrevious(re, text, before, wholeWord) {
133
+ let last = null;
134
+ for (let at = 0; at < before;) {
135
+ const found = regexNext(re, text, at, wholeWord);
136
+ if (!found || found[0] + found[1] > before) {
137
+ if (!found || found[0] >= before) break;
138
+ at = found[0] + 1;
139
+ continue;
140
+ }
141
+ last = found;
142
+ at = found[0] + 1;
143
+ }
144
+ return last;
145
+ }
146
+
147
+ // [index, length] in a search line's units to a match in cells.
148
+ function lineMatch(line, [at, len], cols) {
149
+ const start = line.cells[at] & ~CELL_WIDE;
150
+ const lastCell = line.cells[at + len - 1];
151
+ const end = (lastCell & ~CELL_WIDE) + (lastCell & CELL_WIDE ? 2 : 1) - 1;
152
+ return { startLine: line.first + Math.floor(start / cols), startCol: start % cols, endLine: line.first + Math.floor(end / cols), endCol: end % cols + 1 };
153
+ }
154
+
155
+ const registry = typeof FinalizationRegistry === 'undefined'
156
+ ? null
157
+ : new FinalizationRegistry(({ h, gen }) => { if (gen === generation) wasm?.wongtty_drop(h); });
158
+
159
+ /** Cell attribute bits (`rowCells` word 3). */
160
+ export const Attr = Object.freeze({
161
+ BOLD: 1 << 0, DIM: 1 << 1, ITALIC: 1 << 2, UNDERLINE: 1 << 3,
162
+ DOUBLE_UNDERLINE: 1 << 4, CURLY_UNDERLINE: 1 << 5, DOTTED_UNDERLINE: 1 << 6, DASHED_UNDERLINE: 1 << 7,
163
+ INVERSE: 1 << 8, HIDDEN: 1 << 9, STRIKEOUT: 1 << 10, BLINK: 1 << 11,
164
+ WIDE: 1 << 12, WIDE_SPACER: 1 << 13, COMBINING: 1 << 14, UNDERLINE_COLOR: 1 << 15, HYPERLINK: 1 << 16, LEADING_WIDE_SPACER: 1 << 17,
165
+ TYPED_SPACE: 1 << 18,
166
+ IMAGE: 1 << 19,
167
+ });
168
+
169
+ /** Terminal mode bits (`modes()`). */
170
+ export const Mode = Object.freeze({
171
+ SHOW_CURSOR: 1 << 0, APP_CURSOR: 1 << 1, APP_KEYPAD: 1 << 2, BRACKETED_PASTE: 1 << 3,
172
+ MOUSE_CLICK: 1 << 4, MOUSE_DRAG: 1 << 5, MOUSE_MOTION: 1 << 6, SGR_MOUSE: 1 << 7,
173
+ FOCUS_EVENTS: 1 << 8, ALT_SCREEN: 1 << 9, ALTERNATE_SCROLL: 1 << 10, LINE_WRAP: 1 << 11,
174
+ INSERT: 1 << 12, ORIGIN: 1 << 13, KITTY_KEYBOARD: 1 << 14, MOUSE_X10: 1 << 15, SGR_PIXELS_MOUSE: 1 << 16,
175
+ });
176
+
177
+ const CURSOR_SHAPES = ['block', 'underline', 'bar'];
178
+ const HOOK_KINDS = ['csi', 'osc', 'dcs'];
179
+
180
+ /** Packed colour kinds (`rowCells` words 1 and 2). */
181
+ export const ColorKind = Object.freeze({ NAMED: 1, INDEXED: 2, RGB: 3 });
182
+
183
+ /** Entries `setColors` takes: palette 0–255, then foreground, background, cursor. */
184
+ export const COLOR_SLOTS = 259;
185
+
186
+ /** @internal Forces a trap in the instance, to test crash recovery. */
187
+ export function __trapForTest() {
188
+ // A feed from far outside linear memory traps on the first read.
189
+ wasm.wongtty_feed(wasm.wongtty_new(2, 1, 0), 0xfffffff0, 64);
190
+ }
191
+
192
+ /**
193
+ * @param {number} packed
194
+ * @returns {{ kind: 'named' | 'indexed' | 'rgb', value: number }}
195
+ */
196
+ export function decodeColor(packed) {
197
+ const kind = packed >>> 24;
198
+ const value = packed & 0xffffff;
199
+ return { kind: kind === ColorKind.RGB ? 'rgb' : kind === ColorKind.INDEXED ? 'indexed' : 'named', value };
200
+ }
201
+
202
+ export class Terminal {
203
+ #h;
204
+ // Renderers read it per cell, so keep the size here to avoid calling wasm (it changes only on resize)
205
+ #cols = 0;
206
+ #rows = 0;
207
+ #inPtr = 0;
208
+ #inCap = 0;
209
+ #syncTimer = null;
210
+ #syncTimeoutMs;
211
+ #onSyncFlush;
212
+ #onCrash;
213
+ #gen;
214
+ #scrollback;
215
+ // Host settings re-applied when the engine starts over after a crash.
216
+ #clipboard = { read: false, write: false };
217
+ #kittyKeyboard = false;
218
+ #focus = null;
219
+ #colors = null;
220
+ #hooks = new Set();
221
+ #windowsPty = null;
222
+ #cursorDefault = null;
223
+ // Bytes of a write that stopped at a hook, fed by finishHook.
224
+ #rest = null;
225
+
226
+ /**
227
+ * @param {{ cols?: number, rows?: number, scrollback?: number, syncTimeoutMs?: number, onSyncFlush?: () => void, onCrash?: (error: unknown) => void }} [options]
228
+ */
229
+ constructor({ cols = 80, rows = 24, scrollback = 1000, syncTimeoutMs = 1000, onSyncFlush, onCrash } = {}) {
230
+ const w = requireWasm();
231
+ this.#scrollback = scrollback;
232
+ this.#syncTimeoutMs = syncTimeoutMs;
233
+ this.#onSyncFlush = onSyncFlush;
234
+ this.#onCrash = onCrash;
235
+ this.#open(w, cols, rows);
236
+ }
237
+
238
+ #open(w, cols, rows) {
239
+ this.#gen = generation;
240
+ this.#h = w.wongtty_new(cols, rows, this.#scrollback);
241
+ this.#readSize();
242
+ registry?.register(this, { h: this.#h, gen: this.#gen }, this);
243
+ }
244
+
245
+ #handle() {
246
+ if (!this.#h) throw new Error('wongtty: terminal is disposed');
247
+ if (this.#gen !== generation) this.#restart();
248
+ return this.#h;
249
+ }
250
+
251
+ // The instance was rebuilt after a trap: this terminal's state is gone. Start
252
+ // over at the same size with the host's settings, and tell the host.
253
+ #restart() {
254
+ registry?.unregister(this);
255
+ if (this.#syncTimer) clearTimeout(this.#syncTimer);
256
+ this.#syncTimer = null;
257
+ this.#inPtr = 0;
258
+ this.#inCap = 0;
259
+ this.#open(wasm, this.#cols, this.#rows);
260
+ wasm.wongtty_set_clipboard_access(this.#h, this.#clipboard.read ? 1 : 0, this.#clipboard.write ? 1 : 0);
261
+ if (this.#kittyKeyboard) wasm.wongtty_set_kitty_keyboard(this.#h, 1);
262
+ if (this.#focus !== null) wasm.wongtty_set_focus(this.#h, this.#focus ? 1 : 0);
263
+ if (this.#colors) this.setColors(this.#colors);
264
+ this.#rest = null;
265
+ if (this.#windowsPty) this.setWindowsPty(this.#windowsPty);
266
+ if (this.#cursorDefault) this.setCursorDefault(...this.#cursorDefault);
267
+ for (const key of this.#hooks) {
268
+ const [kind, id] = key.split(':');
269
+ wasm.wongtty_set_hook(this.#h, HOOK_KINDS.indexOf(kind), Number(id), 1);
270
+ }
271
+ this.#onCrash?.(new Error('wongtty: the engine crashed and this terminal was reset'));
272
+ }
273
+
274
+ /** Feeds program output. Strings are encoded as UTF-8. */
275
+ write(data) {
276
+ const h = this.#handle();
277
+ if (typeof data === 'string') {
278
+ if (!data.length) return;
279
+ // UTF-8 is at most 3 bytes per UTF-16 unit: encode straight into linear memory.
280
+ const ptr = this.#reserve(data.length * 3);
281
+ const { written } = encoder.encodeInto(data, new Uint8Array(wasm.memory.buffer, ptr, data.length * 3));
282
+ wasm.wongtty_feed(h, ptr, written);
283
+ } else {
284
+ if (!data.length) return;
285
+ wasm.wongtty_feed(h, this.#stage(data), data.length);
286
+ }
287
+ this.#shrinkInput();
288
+ this.#armSync();
289
+ }
290
+
291
+ /**
292
+ * Stops parsing at sequences of `kind` with `id`, or no longer does (`on` false): for CSI and DCS the id is the
293
+ * intermediates (a private marker such as `?` first) and final byte, one byte each from the most significant;
294
+ * for OSC the number before the first `;`.
295
+ * @param {'csi' | 'osc' | 'dcs'} kind
296
+ */
297
+ setHook(kind, id, on) {
298
+ const k = HOOK_KINDS.indexOf(kind);
299
+ if (k < 0) throw new RangeError(`wongtty: unknown hook kind ${kind}`);
300
+ const h = this.#handle();
301
+ if (on) this.#hooks.add(`${kind}:${id}`);
302
+ else this.#hooks.delete(`${kind}:${id}`);
303
+ wasm.wongtty_set_hook(h, k, id, on ? 1 : 0);
304
+ }
305
+
306
+ /**
307
+ * Like `write`, but stops right after a hooked sequence, before its default action. Returns true when all of
308
+ * `data` was parsed; false when stopped: read `pendingHook()`, run or skip the default with `finishHook()`, then
309
+ * `resume()`. Throws until then.
310
+ */
311
+ writeUntilHook(data) {
312
+ const h = this.#handle();
313
+ if (this.#rest || wasm.wongtty_pending_hook(h) >= 0) throw new Error('wongtty: a hook is pending; call finishHook() first');
314
+ let ptr;
315
+ let len;
316
+ if (typeof data === 'string') {
317
+ ptr = this.#reserve(data.length * 3);
318
+ len = encoder.encodeInto(data, new Uint8Array(wasm.memory.buffer, ptr, data.length * 3)).written;
319
+ } else {
320
+ ptr = this.#stage(data);
321
+ len = data.length;
322
+ }
323
+ return this.#feedUntilHook(h, ptr, len);
324
+ }
325
+
326
+ /** @returns {{ kind: 'csi' | 'osc' | 'dcs', id: number, params: number[][], data: Uint8Array } | null} */
327
+ pendingHook() {
328
+ const kind = wasm.wongtty_pending_hook(this.#handle());
329
+ if (kind < 0) return null;
330
+ const out = new Uint32Array(wasm.memory.buffer, wasm.wongtty_out32_ptr());
331
+ const [id, count] = out;
332
+ const words = out.subarray(2);
333
+ const params = [];
334
+ for (let i = 0, at = 0; i < count; i++) {
335
+ const n = words[at];
336
+ params.push(Array.from(words.subarray(at + 1, at + 1 + n), (v) => v | 0));
337
+ at += n + 1;
338
+ }
339
+ return { kind: HOOK_KINDS[kind], id, params, data: bytesOut(wasm.wongtty_out_len()) };
340
+ }
341
+
342
+ /** Runs the pending hook's default action (`runDefault`) or skips it; `resume()` parses on. */
343
+ finishHook(runDefault) {
344
+ wasm.wongtty_finish_hook(this.#handle(), runDefault ? 1 : 0);
345
+ }
346
+
347
+ /** Parses the rest of the stopped write (or flush) after `finishHook`; false when it stopped at the next hook. */
348
+ resume() {
349
+ const h = this.#handle();
350
+ if (wasm.wongtty_pending_hook(h) >= 0) return false;
351
+ const rest = this.#rest ?? new Uint8Array(0);
352
+ this.#rest = null;
353
+ return this.#feedUntilHook(h, this.#stage(rest), rest.length);
354
+ }
355
+
356
+ #feedUntilHook(h, ptr, len) {
357
+ // Called with nothing too: the engine first goes on with what it stopped in.
358
+ const n = wasm.wongtty_feed_until_hook(h, ptr, len);
359
+ if (n < len) this.#rest = new Uint8Array(wasm.memory.buffer, ptr + n, len - n).slice();
360
+ this.#shrinkInput();
361
+ this.#armSync();
362
+ return n === len && wasm.wongtty_pending_hook(h) < 0;
363
+ }
364
+
365
+ // The reusable input buffer in linear memory, grown to at least `len` bytes.
366
+ #reserve(len) {
367
+ if (len > this.#inCap) {
368
+ if (this.#inPtr) wasm.wongtty_free(this.#inPtr, this.#inCap);
369
+ this.#inCap = Math.max(len, IN_MIN);
370
+ this.#inPtr = wasm.wongtty_alloc(this.#inCap);
371
+ }
372
+ return this.#inPtr;
373
+ }
374
+
375
+ // Copies bytes into the input buffer.
376
+ #stage(bytes) {
377
+ const ptr = this.#reserve(bytes.length);
378
+ new Uint8Array(wasm.memory.buffer, ptr, bytes.length).set(bytes);
379
+ return ptr;
380
+ }
381
+
382
+ // A buffer grown for one large write goes back, so the next small write
383
+ // does not keep it allocated.
384
+ #shrinkInput() {
385
+ if (this.#inCap > IN_KEEP) {
386
+ wasm.wongtty_free(this.#inPtr, this.#inCap);
387
+ this.#inPtr = 0;
388
+ this.#inCap = 0;
389
+ }
390
+ }
391
+
392
+ // Synchronized output (CSI ? 2026 h) holds the renderer's frames until the
393
+ // program ends it; if it never does, end it after a timeout like other terminals.
394
+ #armSync() {
395
+ const pending = wasm.wongtty_sync_pending(this.#h) === 1;
396
+ if (pending && !this.#syncTimer) {
397
+ this.#syncTimer = setTimeout(() => {
398
+ this.#syncTimer = null;
399
+ if (this.#h && wasm.wongtty_sync_pending(this.#h) === 1) {
400
+ wasm.wongtty_flush_sync(this.#h);
401
+ this.#onSyncFlush?.();
402
+ }
403
+ }, this.#syncTimeoutMs);
404
+ } else if (!pending && this.#syncTimer) {
405
+ clearTimeout(this.#syncTimer);
406
+ this.#syncTimer = null;
407
+ }
408
+ }
409
+
410
+ #readSize() {
411
+ this.#cols = wasm.wongtty_cols(this.#h);
412
+ this.#rows = wasm.wongtty_rows(this.#h);
413
+ }
414
+
415
+ resize(cols, rows) {
416
+ wasm.wongtty_resize(this.#handle(), cols, rows);
417
+ this.#readSize();
418
+ }
419
+
420
+ get cols() { this.#handle(); return this.#cols; }
421
+ get rows() { this.#handle(); return this.#rows; }
422
+ /** Scrollback lines; address them as rows -1 (newest) … -historyLength (oldest). */
423
+ get historyLength() { return wasm.wongtty_history_len(this.#handle()); }
424
+
425
+ /** Lines that have left the top of the scrollback (full history, ED3, RIS); history row -k is line trimmedLines + historyLength - k. */
426
+ get trimmedLines() { return wasm.wongtty_trimmed_lines(this.#handle()); }
427
+
428
+ /** @returns {string | null} */
429
+ rowText(line) {
430
+ const n = wasm.wongtty_row_text(this.#handle(), line);
431
+ return n < 0 ? null : textOut(n);
432
+ }
433
+
434
+ /**
435
+ * `translateToString(trimRight)` of lines `first`…`last` (clamped to the buffer), each followed by `\n`.
436
+ * @returns {string}
437
+ */
438
+ textRows(first = -this.historyLength, last = this.rows - 1, trimRight = true) {
439
+ const h = this.#handle();
440
+ first = Math.max(first, -this.historyLength);
441
+ last = Math.min(last, this.rows - 1);
442
+ let text = '';
443
+ for (let from = first; from <= last; from += TEXT_BLOCK) {
444
+ text += textOut(wasm.wongtty_text_rows(h, from, Math.min(from + TEXT_BLOCK - 1, last), trimRight ? 1 : 0));
445
+ }
446
+ wasm.wongtty_out_release();
447
+ return text;
448
+ }
449
+
450
+ /** `[codepoint, fg, bg, attrs]` per cell. @returns {Uint32Array | null} */
451
+ rowCells(line) {
452
+ const n = wasm.wongtty_row_cells(this.#handle(), line);
453
+ return n < 0 ? null : wordsOut(n * 4);
454
+ }
455
+
456
+ /**
457
+ * Like `rowCells`, but a view into WebAssembly memory instead of a copy:
458
+ * valid only until the next call into any terminal. For renderers that read
459
+ * every visible row each frame. @returns {Uint32Array | null}
460
+ */
461
+ rowCellsView(line) {
462
+ const n = wasm.wongtty_row_cells(this.#handle(), line);
463
+ return n < 0 ? null : new Uint32Array(wasm.memory.buffer, wasm.wongtty_out32_ptr(), n * 4);
464
+ }
465
+
466
+ rowWrapped(line) {
467
+ return wasm.wongtty_row_wrapped(this.#handle(), line) === 1;
468
+ }
469
+
470
+ /** Cell text including combining marks. @returns {string | null} */
471
+ cellText(line, col) {
472
+ const n = wasm.wongtty_cell_text(this.#handle(), line, col);
473
+ return n < 0 ? null : textOut(n);
474
+ }
475
+
476
+ /** Cells with combining marks, an underline colour or a hyperlink. */
477
+ rowExtras(line) {
478
+ const n = wasm.wongtty_row_extras(this.#handle(), line);
479
+ if (n < 0) return null;
480
+ if (n === 0) return [];
481
+ const words = wordsOut(n * 4);
482
+ const bytes = new Uint8Array(wasm.memory.buffer, wasm.wongtty_out_ptr(), wasm.wongtty_out_len()).slice();
483
+ let at = 0;
484
+ const texts = [];
485
+ for (let i = 0; i < n; i++) {
486
+ const len = words[i * 4 + 2];
487
+ texts.push(len ? decoder.decode(bytes.subarray(at, at + len)) : undefined);
488
+ at += len;
489
+ }
490
+ // After the text come this line's links, in `id \0 uri \0` order.
491
+ const parts = decoder.decode(bytes.subarray(at)).split('\0');
492
+ const links = [];
493
+ for (let i = 0; i + 1 < parts.length; i += 2) links.push(Object.freeze({ id: parts[i], uri: parts[i + 1] }));
494
+ const extras = [];
495
+ for (let i = 0; i < n; i++) {
496
+ const e = { col: words[i * 4] };
497
+ if (texts[i] !== undefined) e.text = texts[i];
498
+ if (words[i * 4 + 1]) e.underlineColor = words[i * 4 + 1];
499
+ if (words[i * 4 + 3]) e.hyperlink = links[words[i * 4 + 3] - 1];
500
+ extras.push(e);
501
+ }
502
+ return extras;
503
+ }
504
+
505
+ cursor() {
506
+ const h = this.#handle();
507
+ const c = wasm.wongtty_cursor(h);
508
+ const style = wasm.wongtty_cursor_style(h);
509
+ return {
510
+ line: (c >>> 16) & 0x7fff,
511
+ col: c & 0xffff,
512
+ visible: c >>> 31 === 1,
513
+ shape: CURSOR_SHAPES[style & 0xff] ?? 'block',
514
+ blink: ((style >>> 8) & 1) === 1,
515
+ styled: ((style >>> 9) & 1) === 1,
516
+ };
517
+ }
518
+
519
+ /** Rows the view is scrolled back into history (0 = following output). */
520
+ get displayOffset() { return wasm.wongtty_display_offset(this.#handle()); }
521
+
522
+ /** Positive `delta` scrolls back into history; clamped. Damage becomes full when the view moves. */
523
+ scrollDisplay(delta) {
524
+ wasm.wongtty_scroll_display(this.#handle(), delta);
525
+ }
526
+
527
+ scrollToBottom() {
528
+ wasm.wongtty_scroll_to_bottom(this.#handle());
529
+ }
530
+
531
+ modes() {
532
+ return wasm.wongtty_modes(this.#handle());
533
+ }
534
+
535
+ /** Lets the program use the kitty keyboard protocol (off by default); switching drops its pushed flags. */
536
+ setKittyKeyboard(enabled) {
537
+ this.#kittyKeyboard = !!enabled;
538
+ wasm.wongtty_set_kitty_keyboard(this.#handle(), enabled ? 1 : 0);
539
+ }
540
+
541
+ /** Drops the kitty keyboard flags the program pushed, keeping the protocol on or off. */
542
+ resetKittyKeyboard() {
543
+ wasm.wongtty_reset_kitty_keyboard(this.#handle());
544
+ }
545
+
546
+ /** Active kitty keyboard flags: 1 disambiguate, 2 event types, 4 alternate keys, 8 all keys as escapes, 16 associated text. */
547
+ kittyFlags() {
548
+ return wasm.wongtty_kitty_flags(this.#handle());
549
+ }
550
+
551
+ /** The host's focus, for the report sent when the program turns on focus events (`CSI ? 1004 h`); `null`, the default, sends none. */
552
+ setFocus(focused) {
553
+ this.#focus = focused == null ? null : !!focused;
554
+ wasm.wongtty_set_focus(this.#handle(), this.#focus === null ? -1 : this.#focus ? 1 : 0);
555
+ }
556
+
557
+ /** The host's cursor style (xterm.js `cursorStyle`, `cursorBlink`): `cursor()` reports it until the program sets one, and DECRQSS reports it. */
558
+ setCursorDefault(shape, blink) {
559
+ this.#cursorDefault = [shape, !!blink];
560
+ wasm.wongtty_set_cursor_default(this.#handle(), Math.max(0, CURSOR_SHAPES.indexOf(shape)), blink ? 1 : 0);
561
+ }
562
+
563
+ /** Viewport changes since the last call: move drawn rows up by `scroll`, then redraw `lines`. */
564
+ takeDamage() {
565
+ const n = wasm.wongtty_take_damage(this.#handle());
566
+ if (n < 0) return { full: true, scroll: 0, lines: [] };
567
+ const words = wordsOut(n + 1);
568
+ return { full: false, scroll: words[0], lines: Array.from(words.subarray(1)) };
569
+ }
570
+
571
+ /** Bytes to write back to the PTY (status reports, …). */
572
+ takeReplies() {
573
+ const n = wasm.wongtty_take_replies(this.#handle());
574
+ return n === 0 ? new Uint8Array(0) : bytesOut(n);
575
+ }
576
+
577
+ /** The same replies one by one, in order (xterm.js fires one `onData` per reply). */
578
+ takeReplyList() {
579
+ const n = wasm.wongtty_take_reply_list(this.#handle());
580
+ if (n === 0) return [];
581
+ const lens = wordsOut(n);
582
+ const bytes = bytesOut(wasm.wongtty_out_len());
583
+ const replies = [];
584
+ let at = 0;
585
+ for (const len of lens) {
586
+ replies.push(bytes.subarray(at, at + len));
587
+ at += len;
588
+ }
589
+ return replies;
590
+ }
591
+
592
+ /** @returns {string | null} new title, or null if unchanged */
593
+ takeTitle() {
594
+ const n = wasm.wongtty_take_title(this.#handle());
595
+ return n < 0 ? null : textOut(n);
596
+ }
597
+
598
+ takeBells() {
599
+ return wasm.wongtty_take_bells(this.#handle());
600
+ }
601
+
602
+ /** Full resets (RIS) the program sent since the last call. */
603
+ takeResets() {
604
+ return wasm.wongtty_take_resets(this.#handle());
605
+ }
606
+
607
+ /** Lets the program read and/or write the host clipboards with OSC 52 (both off by default). */
608
+ setClipboardAccess({ read = false, write = false } = {}) {
609
+ this.#clipboard = { read, write };
610
+ wasm.wongtty_set_clipboard_access(this.#handle(), read ? 1 : 0, write ? 1 : 0);
611
+ }
612
+
613
+ /** @returns {{ selection: 'c' | 'p', text: string } | null} oldest text the program asked to copy */
614
+ takeClipboardWrite() {
615
+ const n = wasm.wongtty_take_clipboard_write(this.#handle());
616
+ if (n < 0) return null;
617
+ const out = bytesOut(n);
618
+ return { selection: String.fromCharCode(out[0]), text: decoder.decode(out.subarray(1)) };
619
+ }
620
+
621
+ /** @returns {'c' | 'p' | null} oldest clipboard the program asked to read; answer with replyClipboard */
622
+ takeClipboardRead() {
623
+ const c = wasm.wongtty_take_clipboard_read(this.#handle());
624
+ return c < 0 ? null : String.fromCharCode(c);
625
+ }
626
+
627
+ /** Answers the oldest read of `selection`; the reply joins takeReplies(). */
628
+ replyClipboard(selection, text) {
629
+ const h = this.#handle();
630
+ const bytes = encoder.encode(text);
631
+ return wasm.wongtty_reply_clipboard(h, selection.charCodeAt(0), this.#stage(bytes), bytes.length) === 1;
632
+ }
633
+
634
+ /** Colours (0xRRGGBB, COLOR_SLOTS of them) that answer OSC 4/10/11/12 queries; null stops answering. */
635
+ setColors(colors) {
636
+ const h = this.#handle();
637
+ if (!colors) {
638
+ this.#colors = null;
639
+ return wasm.wongtty_set_colors(h, 0, 0);
640
+ }
641
+ if (colors.length !== COLOR_SLOTS) throw new RangeError(`wongtty: setColors takes ${COLOR_SLOTS} colours`);
642
+ const words = Uint32Array.from(colors);
643
+ this.#colors = words;
644
+ wasm.wongtty_set_colors(h, this.#stage(new Uint8Array(words.buffer)), words.byteLength);
645
+ }
646
+
647
+ syncPending() {
648
+ return wasm.wongtty_sync_pending(this.#handle()) === 1;
649
+ }
650
+
651
+ /** Ends a synchronized update, as its timeout does. Returns false while a hook is pending (see `writeUntilHook`). */
652
+ flushSync() {
653
+ const h = this.#handle();
654
+ wasm.wongtty_flush_sync(h);
655
+ this.#armSync();
656
+ return wasm.wongtty_pending_hook(h) < 0;
657
+ }
658
+
659
+ /**
660
+ * Behaves as xterm.js does with `windowsPty` (none by default): new rows blank at the bottom, no reflow before
661
+ * conpty build 21376, and its wrapping heuristic for conpty before 21376.
662
+ * @param {{ backend?: 'conpty' | 'winpty', buildNumber?: number } | null | undefined} pty
663
+ */
664
+ setWindowsPty(pty) {
665
+ const h = this.#handle();
666
+ this.#windowsPty = pty ? { backend: pty.backend, buildNumber: pty.buildNumber } : null;
667
+ const backend = pty?.backend === 'conpty' ? 1 : pty?.backend === 'winpty' ? 2 : 0;
668
+ wasm.wongtty_set_windows_pty(h, backend, pty?.buildNumber ?? NaN);
669
+ }
670
+
671
+ // Search runs here over the engine's logical lines (`searchLine`), with a RegExp for every query: a plain one is
672
+ // escaped, and the `i` flag folds case without changing lengths. Only `searchRows` of a plain ASCII query is
673
+ // matched in the engine (`searchRowsView`), whose ASCII case folding needs no Unicode tables in the wasm.
674
+ #matcher(query, { caseSensitive = false, regex = false } = {}) {
675
+ return new RegExp(regex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSensitive ? 'g' : 'gi');
676
+ }
677
+
678
+ /** First match starting at or after cell (`line`, `col`), down to the bottom of the screen. @returns {object | null} */
679
+ searchNext(query, options = {}, line = -this.historyLength, col = 0) {
680
+ if (!query) return null;
681
+ const re = this.#matcher(query, options);
682
+ const cols = this.cols;
683
+ line += Math.floor(col / cols);
684
+ col %= cols;
685
+ if (line < -this.historyLength) [line, col] = [-this.historyLength, 0];
686
+ let found = this.searchLine(line);
687
+ let target = found ? (line - found.first) * cols + col : 0;
688
+ while (found) {
689
+ let from = 0;
690
+ while (from < found.text.length && (found.cells[from] & ~CELL_WIDE) < target) from++;
691
+ const at = regexNext(re, found.text, from, options.wholeWord);
692
+ if (at) return lineMatch(found, at, cols);
693
+ found = this.searchLine(found.first + found.rows);
694
+ target = 0;
695
+ }
696
+ return null;
697
+ }
698
+
699
+ /** Last match ending at or before cell (`line`, `col`), up to the top of the history; (`rows - 1`, `cols`) is the end. */
700
+ searchPrevious(query, options = {}, line = this.rows - 1, col = this.cols) {
701
+ if (!query) return null;
702
+ const re = this.#matcher(query, options);
703
+ const cols = this.cols;
704
+ const top = -this.historyLength;
705
+ line += Math.floor(col / cols);
706
+ col %= cols;
707
+ if (col === 0 && line > top) [line, col] = [line - 1, cols];
708
+ if (line > this.rows - 1) [line, col] = [this.rows - 1, cols];
709
+ let found = this.searchLine(line);
710
+ let before = found ? (line - found.first) * cols + col : 0;
711
+ while (found) {
712
+ let bound = 0;
713
+ while (bound < found.text.length && (found.cells[bound] & ~CELL_WIDE) + (found.cells[bound] & CELL_WIDE ? 2 : 1) <= before) bound++;
714
+ const at = regexPrevious(re, found.text, bound, options.wholeWord);
715
+ if (at) return lineMatch(found, at, cols);
716
+ if (found.first <= top) return null;
717
+ found = this.searchLine(found.first - 1);
718
+ before = Infinity;
719
+ }
720
+ return null;
721
+ }
722
+
723
+ /** Every match in the logical lines rows `first`…`last` belong to (each from the end of the previous one), keeping those that touch the rows. */
724
+ searchRows(query, options = {}, first, last) {
725
+ const out = [];
726
+ if (!query) return out;
727
+ // Page by page, each from just after the last match
728
+ for (let line = -0x80000000, col = 0; !options.regex; ) {
729
+ const m = this.#searchRowsAt(query, options, first, last, MATCH_PAGE, line, col);
730
+ if (!m) break;
731
+ for (let i = 0; i < m.length; i += 4) out.push({ startLine: m[i], startCol: m[i + 1], endLine: m[i + 2], endCol: m[i + 3] });
732
+ if (m.length < MATCH_PAGE * 4) {
733
+ wasm.wongtty_out_release();
734
+ return out;
735
+ }
736
+ [line, col] = [m[m.length - 4], m[m.length - 3] + 1];
737
+ }
738
+ out.length = 0;
739
+ const re = this.#matcher(query, options);
740
+ last = Math.min(last, this.rows - 1);
741
+ let found = this.searchLine(Math.max(first, -this.historyLength));
742
+ while (found && found.first <= last) {
743
+ for (let at = 0, m; (m = regexNext(re, found.text, at, options.wholeWord)); at = m[0] + m[1]) {
744
+ const match = lineMatch(found, m, this.cols);
745
+ if (match.endLine >= first && match.startLine <= last) out.push(match);
746
+ }
747
+ found = this.searchLine(found.first + found.rows);
748
+ }
749
+ return out;
750
+ }
751
+
752
+ /**
753
+ * The engine's matching of a plain `query` over rows `first`…`last` (`searchRows` without `regex`): at most
754
+ * `limit` matches as `[startLine, startCol, endLine, endCol]` each, a view into WebAssembly memory valid only until
755
+ * the next call into any terminal. Null for a query that is not ASCII and, with `keepLength`, when a line with a
756
+ * match holds U+0130 (İ): ignoring case it lowercases to two characters, where a host that lowercases whole lines
757
+ * (`@xterm/addon-search`) places matches differently. @returns {Int32Array | null}
758
+ */
759
+ searchRowsView(query, options = {}, first, last, limit = 0xffffffff) {
760
+ return this.#searchRowsAt(query, options, first, last, limit, -0x80000000, 0);
761
+ }
762
+
763
+ // `searchRowsView` of the matches starting at or after cell (`line`, `col`).
764
+ #searchRowsAt(query, { caseSensitive = false, wholeWord = false, keepLength = false }, first, last, limit, line, col) {
765
+ const h = this.#handle();
766
+ // Bounds past the buffer (±Infinity too) are its ends, as the RegExp path reads them; NaN finds nothing
767
+ first = Math.max(first, -this.historyLength);
768
+ last = Math.min(last, this.rows - 1);
769
+ if (!(first <= last)) return new Int32Array(0);
770
+ const bytes = encoder.encode(query);
771
+ const ptr = this.#reserve(20 + bytes.length);
772
+ const head = new DataView(wasm.memory.buffer, ptr, 20);
773
+ head.setInt32(0, first, true);
774
+ head.setInt32(4, last, true);
775
+ head.setUint32(8, Math.min(limit, 0xffffffff), true);
776
+ head.setInt32(12, line, true);
777
+ head.setUint32(16, col, true);
778
+ new Uint8Array(wasm.memory.buffer, ptr + 20, bytes.length).set(bytes);
779
+ const flags = (caseSensitive ? 1 : 0) | (wholeWord ? 2 : 0) | (keepLength ? 4 : 0);
780
+ const n = wasm.wongtty_search_rows(h, ptr, 20 + bytes.length, flags);
781
+ this.#shrinkInput();
782
+ return n < 0 ? null : new Int32Array(wasm.memory.buffer, wasm.wongtty_out32_ptr(), n * 4);
783
+ }
784
+
785
+ /**
786
+ * The logical line row `line` belongs to as search reads it, or null outside the buffer. `cells[i]` is the cell of
787
+ * the UTF-16 unit `text[i]`: `row * cols + col` from `first`, bit 31 set for a wide character.
788
+ */
789
+ searchLine(line) {
790
+ const n = wasm.wongtty_search_line(this.#handle(), line);
791
+ if (n < 0) return null;
792
+ const text = textOut(wasm.wongtty_out_len());
793
+ const words = new Uint32Array(wasm.memory.buffer, wasm.wongtty_out32_ptr(), n + 2);
794
+ const cells = new Uint32Array(text.length);
795
+ let unit = 0;
796
+ let k = 2;
797
+ for (const ch of text) {
798
+ cells[unit++] = words[k];
799
+ if (ch.length === 2) cells[unit++] = words[k];
800
+ k++;
801
+ }
802
+ return { first: words[0] | 0, rows: words[1], text, cells };
803
+ }
804
+
805
+ /** Puts a marker on buffer line `line` of the primary screen (0 is the oldest history line); its id, or 0 outside the buffer or on the alternate screen. */
806
+ addMarker(line) {
807
+ return wasm.wongtty_add_marker(this.#handle(), line);
808
+ }
809
+
810
+ /** The marker's buffer line on the primary screen, or -1 once it is gone. */
811
+ markerLine(id) {
812
+ return wasm.wongtty_marker_line(this.#handle(), id);
813
+ }
814
+
815
+ removeMarker(id) {
816
+ wasm.wongtty_remove_marker(this.#handle(), id);
817
+ }
818
+
819
+ /** Markers the engine dropped since the last call (trimmed, cleared, reset). @returns {number[]} */
820
+ takeDisposedMarkers() {
821
+ const n = wasm.wongtty_take_disposed_markers(this.#handle());
822
+ return n > 0 ? Array.from(wordsOut(n)) : [];
823
+ }
824
+
825
+ /** Covers `cols` × `rows` cells with the tiles of image `id`. @returns {{ tiles: number, covered: number[] }} */
826
+ placeImage(id, cols, rows, { scrolling = true, cursor = 'vt340' } = {}) {
827
+ const size = (Math.min(rows, 0xffff) << 16 | Math.min(cols, 0xffff)) >>> 0;
828
+ const n = wasm.wongtty_place_image(this.#handle(), id, size, (scrolling ? 1 : 0) | (cursor === 'iip' ? 2 : 0));
829
+ const words = wordsOut(n + 1);
830
+ return { tiles: words[0] ?? 0, covered: Array.from(words.subarray(1)) };
831
+ }
832
+
833
+ /** `[col, id, tile]` per cell an image tile covers, or null if out of range. @returns {Uint32Array | null} */
834
+ rowImages(line) {
835
+ const n = wasm.wongtty_row_images(this.#handle(), line);
836
+ return n < 0 ? null : wordsOut(n * 3);
837
+ }
838
+
839
+ /** `[fg, bg, attrs]` that text written now gets, packed like `rowCells`. @returns {Uint32Array} */
840
+ pen() {
841
+ wasm.wongtty_pen(this.#handle());
842
+ return wordsOut(3);
843
+ }
844
+
845
+ /** Bytes that rebuild this screen in a fresh xterm.js, or why they cannot. `options` as @xterm/addon-serialize's `scrollback` and `excludeModes`. */
846
+ ansiSnapshot({ scrollback, excludeModes = false } = {}) {
847
+ const n = wasm.wongtty_ansi_snapshot(this.#handle(), scrollback ?? -1, excludeModes ? 1 : 0);
848
+ const result = n > 0 ? { ok: true, payload: bytesOut(n) } : { ok: false, reason: textOut(-n) };
849
+ // The payload is copied out; free it in the engine's memory rather than at the next call
850
+ wasm.wongtty_out_release();
851
+ return result;
852
+ }
853
+
854
+ dispose() {
855
+ if (!this.#h) return;
856
+ if (this.#syncTimer) clearTimeout(this.#syncTimer);
857
+ if (this.#gen === generation) {
858
+ if (this.#inPtr) wasm.wongtty_free(this.#inPtr, this.#inCap);
859
+ wasm.wongtty_drop(this.#h);
860
+ }
861
+ registry?.unregister(this);
862
+ this.#h = 0;
863
+ this.#inPtr = 0;
864
+ this.#inCap = 0;
865
+ }
866
+ }