|
1 | 1 | /** |
2 | 2 | * Turning terminal bytes into the events the tree already understands. |
3 | 3 | * |
4 | | - * The tty scanner does the decoding; this decides which decoded events the |
| 4 | + * Framed paste is text, including bytes that otherwise name keyboard commands. |
| 5 | + * Its payload waits for the closing marker so UTF-8 and line separators can |
| 6 | + * cross reads without becoming partial text or submission keys. |
| 7 | + * |
| 8 | + * The tty scanner decodes ordinary input; this decides which decoded events the |
5 | 9 | * composition layer has a meaning for and hands them over in its own closed |
6 | 10 | * shape. Nothing here knows an action name: a normalized event says what |
7 | 11 | * happened and where, and what it means is decided by whichever mounted node |
@@ -103,9 +107,9 @@ const ALT_KEYS: ReadonlyMap<string, ReplKey> = new Map([ |
103 | 107 | ]); |
104 | 108 |
|
105 | 109 | /** |
106 | | - * The physical key a pasted newline arrives as. |
| 110 | + * The physical key an unframed LF arrives as. |
107 | 111 | * |
108 | | - * A terminal sends `LF` inside a paste, and the decoder reports `LF` as |
| 112 | + * An unframed newline arrives as `LF`, and the decoder reports `LF` as |
109 | 113 | * Control-J with no text of its own. Pasting a multi-line document is the whole |
110 | 114 | * point of an XMD draft, so this one chord is text — and it is the only chord |
111 | 115 | * that is. |
@@ -278,27 +282,96 @@ export interface ReplDecoder { |
278 | 282 | export function useReplDecoder( |
279 | 283 | options: { readonly escLatency?: number } = {}, |
280 | 284 | ): Operation<ReplDecoder> { |
281 | | - const settings = options.escLatency === undefined ? {} : { escLatency: options.escLatency }; |
| 285 | + const settings = { escLatency: options.escLatency ?? 25 }; |
282 | 286 | return resource<ReplDecoder>(function* (provide) { |
283 | 287 | let input: Input = yield* until(createInput(settings)); |
| 288 | + let prefix: number[] = []; |
| 289 | + let paste: number[] | undefined; |
| 290 | + const opening = new TextEncoder().encode("\x1b[200~"); |
| 291 | + const closing = new TextEncoder().encode("\x1b[201~"); |
| 292 | + |
| 293 | + function* keys(bytes?: Uint8Array): Operation<ReplInputScan> { |
| 294 | + const drained = drain(input, bytes); |
| 295 | + const settling = |
| 296 | + bytes === undefined && drained.pendingFor !== undefined && drained.ordered.length === 0; |
| 297 | + if (!settling) { |
| 298 | + return drained; |
| 299 | + } |
| 300 | + // The spent ESC otherwise joins the next keystroke as an Alt modifier. |
| 301 | + input = yield* until(createInput(settings)); |
| 302 | + return Object.freeze({ |
| 303 | + ordered: Object.freeze([{ kind: "input", event: ESCAPE } satisfies ReplPhysicalInput]), |
| 304 | + events: Object.freeze([ESCAPE]), |
| 305 | + pointers: Object.freeze([]), |
| 306 | + resized: drained.resized, |
| 307 | + pendingFor: undefined, |
| 308 | + }); |
| 309 | + } |
| 310 | + |
284 | 311 | yield* provide({ |
285 | 312 | *scan(bytes?: Uint8Array): Operation<ReplInputScan> { |
286 | | - const drained = drain(input, bytes); |
287 | | - const settling = |
288 | | - bytes === undefined && drained.pendingFor !== undefined && drained.ordered.length === 0; |
289 | | - if (!settling) { |
290 | | - return drained; |
| 313 | + const passes: ReplInputScan[] = []; |
| 314 | + let ordinary: number[] = []; |
| 315 | + if (bytes === undefined) { |
| 316 | + if (paste === undefined && prefix.length > 0) { |
| 317 | + passes.push(yield* keys(Uint8Array.from(prefix))); |
| 318 | + prefix = []; |
| 319 | + } |
| 320 | + passes.push(yield* keys()); |
| 321 | + } else { |
| 322 | + for (const byte of bytes) { |
| 323 | + if (paste !== undefined) { |
| 324 | + paste.push(byte); |
| 325 | + if ( |
| 326 | + paste.length >= closing.length && |
| 327 | + closing.every( |
| 328 | + (one, index) => paste?.[paste.length - closing.length + index] === one, |
| 329 | + ) |
| 330 | + ) { |
| 331 | + const text = new TextDecoder() |
| 332 | + .decode(Uint8Array.from(paste.slice(0, -closing.length))) |
| 333 | + .replace(/\r\n|\r/g, "\n"); |
| 334 | + const event: ReplInputEvent = Object.freeze({ kind: "text", text }); |
| 335 | + passes.push({ |
| 336 | + ordered: [{ kind: "input", event }], |
| 337 | + events: [event], |
| 338 | + pointers: [], |
| 339 | + resized: false, |
| 340 | + pendingFor: undefined, |
| 341 | + }); |
| 342 | + paste = undefined; |
| 343 | + } |
| 344 | + continue; |
| 345 | + } |
| 346 | + if (prefix.length > 0 && byte !== opening[prefix.length]) { |
| 347 | + ordinary.push(...prefix); |
| 348 | + prefix = []; |
| 349 | + } |
| 350 | + if (byte === opening[prefix.length]) { |
| 351 | + prefix.push(byte); |
| 352 | + if (prefix.length === opening.length) { |
| 353 | + passes.push(yield* keys(Uint8Array.from(ordinary))); |
| 354 | + ordinary = []; |
| 355 | + prefix = []; |
| 356 | + paste = []; |
| 357 | + } |
| 358 | + } else { |
| 359 | + ordinary.push(byte); |
| 360 | + } |
| 361 | + } |
| 362 | + passes.push(yield* keys(Uint8Array.from(ordinary))); |
291 | 363 | } |
292 | | - // Nothing followed the held ESC, so it was the key. A fresh scanner, |
293 | | - // because the spent byte is still in this one's buffer and would |
294 | | - // otherwise become the first byte of whatever is typed next. |
295 | | - input = yield* until(createInput(settings)); |
296 | 364 | return Object.freeze({ |
297 | | - ordered: Object.freeze([{ kind: "input", event: ESCAPE } satisfies ReplPhysicalInput]), |
298 | | - events: Object.freeze([ESCAPE]), |
299 | | - pointers: Object.freeze([]), |
300 | | - resized: drained.resized, |
301 | | - pendingFor: undefined, |
| 365 | + ordered: Object.freeze(passes.flatMap((pass) => pass.ordered)), |
| 366 | + events: Object.freeze(passes.flatMap((pass) => pass.events)), |
| 367 | + pointers: Object.freeze(passes.flatMap((pass) => pass.pointers)), |
| 368 | + resized: passes.some((pass) => pass.resized), |
| 369 | + pendingFor: |
| 370 | + prefix.length === 1 |
| 371 | + ? settings.escLatency |
| 372 | + : prefix.length > 1 || paste !== undefined |
| 373 | + ? undefined |
| 374 | + : passes.at(-1)?.pendingFor, |
302 | 375 | }); |
303 | 376 | }, |
304 | 377 | }); |
|
0 commit comments