[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.

examples use `node:` api (#48)

* examples use `node:` api

* move main to top

* direct to examples folder

* fmt

* fix const ordering

* fmt again

authored by

Jacob Bolda and committed by
GitHub
(May 31, 2026, 2:02 PM -0500) 07ea00b9 41392126

+338 -224
+2 -1
README.md
··· 23 23 24 24 ### Examples 25 25 26 - The application in this demo uses Clayterm for all layout and input parsing 26 + See this keyboard example and more in the [examples folder](examples/README.md). 27 + This demo uses Clayterm for all layout and input parsing. 27 28 28 29 #### Keyboard Events 29 30
+22 -2
examples/README.md
··· 6 6 information that could be pertinent to reproducing the issue. 7 7 8 8 > [!NOTE] 9 - > Run the commands in this document from the repository root. 9 + > Run the commands in this document from the repository root. These examples use 10 + > `node:` terminal APIs so the same files can be run with either Deno or Node. 10 11 11 12 ## Prerequisites 12 13 ··· 24 25 25 26 ```sh 26 27 deno run examples/keyboard/index.ts 28 + # or 29 + node examples/keyboard/index.ts 27 30 ``` 28 31 29 32 What it shows: ··· 40 43 events 41 44 - `examples/keyboard/use-stdin.ts` adapts stdin into a byte stream for the demo 42 45 46 + #### Keyboard Events 47 + 48 + The input parser decodes raw terminal bytes into structured events. Here you can 49 + see each key event as the string "hello world" is typed. 50 + 51 + ![Keyboard events demo](keyboard/keyboard-key-events.gif) 52 + 53 + #### Pointer Events 54 + 55 + Here we see hover styles applied to UI elements in response to the pointer 56 + state. Clay drives the hit testing; no manual coordinate math required. 57 + 58 + ![Pointer events demo](keyboard/keyboard-pointer-events.gif) 59 + 43 60 ## Inline Regions 44 61 45 62 Path: `examples/inline-regions/index.ts` ··· 48 65 49 66 ```sh 50 67 deno run examples/inline-regions/index.ts 68 + # or 69 + node examples/inline-regions/index.ts 51 70 ``` 52 71 53 72 What it shows: 54 73 55 74 - rendering animated regions into normal terminal scrollback 56 - - querying cursor position with DSR to place later frames correctly 75 + - querying cursor position with Device Status Report (DSR) to place later frames 76 + correctly 57 77 - updating a previously allocated region without taking over the whole screen 58 78 - small animated demos including a spinner, a progress bar, and a nyan-cat-style 59 79 sequence
+156 -109
examples/inline-regions/index.ts
··· 3 3 * 4 4 * Shows the region lifecycle: 5 5 * 1. Allocate space with raw newlines 6 - * 2. DSR — queries cursor position to compute `top` 6 + * 2. Device Status Report (DSR) — queries cursor position to compute `top` 7 7 * 3. CUP mode (all frames) — renders at `top` 8 8 * 4. Commit — restore cursor past region, advance with \n 9 9 */ 10 10 11 - import { main, type Operation, sleep, until } from "effection"; 11 + import { Buffer } from "node:buffer"; 12 + import { readSync } from "node:fs"; 13 + import process from "node:process"; 14 + import { ensure, main, type Operation, sleep, until } from "effection"; 12 15 import { 13 16 close, 14 17 createInput, ··· 29 32 import { validated } from "../../validate.ts"; 30 33 31 34 const encode = (s: string) => new TextEncoder().encode(s); 32 - const write = (b: Uint8Array) => Deno.stdout.writeSync(b); 35 + const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b)); 33 36 34 37 const GREEN = rgba(80, 250, 123); 35 38 const GRAY = rgba(100, 100, 100); ··· 45 48 46 49 const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 47 50 48 - function* queryCursor(): Operation<CursorEvent> { 49 - let parser = yield* until(createInput({ escLatency: 100 })); 50 - write(DSR()); 51 - 52 - let buf = new Uint8Array(32); 53 - while (true) { 54 - let n = Deno.stdin.readSync(buf); 55 - if (n === null) continue; 56 - let result = parser.scan(buf.subarray(0, n)); 57 - for (let ev of result.events) { 58 - if (ev.type === "cursor") { 59 - return ev; 60 - } 61 - } 62 - } 63 - } 64 - 65 - function waitKey() { 66 - let buf = new Uint8Array(32); 67 - while (true) { 68 - let n = Deno.stdin.readSync(buf); 69 - if (n === null) continue; 70 - for (let i = 0; i < n; i++) { 71 - if (buf[i] === 0x03) { 72 - Deno.stdin.setRaw(false); 73 - write(SHOWCURSOR()); 74 - Deno.exit(0); 75 - } 76 - } 77 - return; 78 - } 79 - } 80 - 81 - function box(msg: string, fg: number, border: number): Op[] { 82 - return [ 83 - open("root", { 84 - layout: { width: grow(), height: grow(), direction: "ttb" }, 85 - }), 86 - open("box", { 87 - layout: { 88 - width: grow(), 89 - height: grow(), 90 - direction: "ttb", 91 - padding: { left: 1 }, 92 - alignY: 2, 93 - }, 94 - border: { 95 - color: border, 96 - left: 1, 97 - right: 1, 98 - top: 1, 99 - bottom: 1, 100 - }, 101 - cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, 102 - }), 103 - text(msg, { color: fg }), 104 - close(), 105 - close(), 106 - ]; 107 - } 108 - 109 - function* transaction( 110 - height: number, 111 - renderFrame: (frame: number) => Op[], 112 - frames: number, 113 - interval: number, 114 - ): Operation<void> { 115 - let { columns } = Deno.consoleSize(); 116 - 117 - write(encode("\n".repeat(height))); 118 - 119 - let pos = yield* queryCursor(); 120 - /** 1-based terminal row where the region starts */ 121 - let row = pos.row - height + 1; 122 - 123 - write(ESC("7")); 51 + await main(function* () { 52 + let { columns } = terminalSize(); 53 + setRawMode(true); 124 54 let tty = settings(cursor(false)); 125 55 write(tty.apply); 126 56 127 - let term = validated( 128 - yield* until(createTerm({ width: columns, height })), 129 - ); 130 - for (let i = 0; i < frames; i++) { 131 - let result = term.render(renderFrame(i), { row }); 132 - write(new Uint8Array(result.output)); 133 - yield* sleep(interval); 134 - } 135 - 136 - write(tty.revert); 137 - write(ESC("8")); 138 - write(encode("\n")); 139 - } 140 - 141 - function say(msg: string) { 142 - write(encode(msg + "\n")); 143 - } 144 - 145 - function pause() { 146 - waitKey(); 147 - write(encode("\n")); 148 - } 149 - 150 - await main(function* () { 151 - let { columns } = Deno.consoleSize(); 152 - Deno.stdin.setRaw(true); 153 - let tty = settings(cursor(false)); 154 - write(tty.apply); 57 + yield* ensure(() => { 58 + // SGR reset sequence 59 + setRawMode(false); 60 + write(CSI("0m")); 61 + write(tty.revert); 62 + }); 155 63 156 64 // Introduction 157 65 say("Clayterm can render entire scenes, but it can also render"); ··· 338 246 339 247 write(CSI("0m")); 340 248 write(encode("\n")); 249 + }); 250 + 251 + function terminalSize(): { columns: number; rows: number } { 252 + return process.stdout.isTTY 253 + ? { 254 + columns: process.stdout.columns ?? 80, 255 + rows: process.stdout.rows ?? 24, 256 + } 257 + : { columns: 80, rows: 24 }; 258 + } 259 + 260 + function setRawMode(enabled: boolean): void { 261 + if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { 262 + process.stdin.setRawMode(enabled); 263 + } 264 + } 265 + 266 + function* queryCursor(): Operation<CursorEvent> { 267 + let parser = yield* until(createInput({ escLatency: 100 })); 268 + write(DSR()); 269 + 270 + let buf = Buffer.allocUnsafe(32); 271 + while (true) { 272 + let n: number; 273 + try { 274 + n = readSync(process.stdin.fd, buf, 0, buf.length, null); 275 + } catch (error) { 276 + if ( 277 + error && typeof error === "object" && 278 + ("code" in error && (error.code === "EAGAIN" || error.code === "EINTR")) 279 + ) { 280 + continue; 281 + } 282 + throw error; 283 + } 284 + 285 + if (n === 0) continue; 286 + let result = parser.scan(buf.subarray(0, n)); 287 + for (let ev of result.events) { 288 + if (ev.type === "cursor") { 289 + return ev; 290 + } 291 + } 292 + } 293 + } 294 + 295 + function waitKey(): void { 296 + let buf = Buffer.allocUnsafe(32); 297 + while (true) { 298 + let n: number; 299 + try { 300 + n = readSync(process.stdin.fd, buf, 0, buf.length, null); 301 + } catch (error) { 302 + if ( 303 + error && typeof error === "object" && 304 + ("code" in error && (error.code === "EAGAIN" || error.code === "EINTR")) 305 + ) { 306 + continue; 307 + } 308 + throw error; 309 + } 310 + 311 + if (n === 0) continue; 312 + for (let i = 0; i < n; i++) { 313 + if (buf[i] === 0x03) { 314 + setRawMode(false); 315 + write(SHOWCURSOR()); 316 + process.exit(0); 317 + } 318 + } 319 + return; 320 + } 321 + } 322 + 323 + function box(msg: string, fg: number, border: number): Op[] { 324 + return [ 325 + open("root", { 326 + layout: { width: grow(), height: grow(), direction: "ttb" }, 327 + }), 328 + open("box", { 329 + layout: { 330 + width: grow(), 331 + height: grow(), 332 + direction: "ttb", 333 + padding: { left: 1 }, 334 + alignY: 2, 335 + }, 336 + border: { 337 + color: border, 338 + left: 1, 339 + right: 1, 340 + top: 1, 341 + bottom: 1, 342 + }, 343 + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, 344 + }), 345 + text(msg, { color: fg }), 346 + close(), 347 + close(), 348 + ]; 349 + } 350 + 351 + function* transaction( 352 + height: number, 353 + renderFrame: (frame: number) => Op[], 354 + frames: number, 355 + interval: number, 356 + ): Operation<void> { 357 + let { columns } = terminalSize(); 358 + 359 + write(encode("\n".repeat(height))); 360 + 361 + let pos = yield* queryCursor(); 362 + /** 1-based terminal row where the region starts */ 363 + let row = pos.row - height + 1; 364 + 365 + write(ESC("7")); 366 + let tty = settings(cursor(false)); 367 + write(tty.apply); 368 + 369 + let term = validated( 370 + yield* until(createTerm({ width: columns, height })), 371 + ); 372 + for (let i = 0; i < frames; i++) { 373 + let result = term.render(renderFrame(i), { row }); 374 + write(new Uint8Array(result.output)); 375 + yield* sleep(interval); 376 + } 377 + 341 378 write(tty.revert); 342 - Deno.stdin.setRaw(false); 343 - }); 379 + write(ESC("8")); 380 + write(encode("\n")); 381 + } 382 + 383 + function say(msg: string) { 384 + write(encode(msg + "\n")); 385 + } 386 + 387 + function pause(): void { 388 + waitKey(); 389 + write(encode("\n")); 390 + }
+156 -110
examples/keyboard/index.ts
··· 1 1 // deno-lint-ignore-file no-fallthrough 2 + import { Buffer } from "node:buffer"; 3 + import process from "node:process"; 2 4 import { 3 5 createChannel, 4 6 each, ··· 42 44 43 45 const KEY_W = 5; 44 46 const GAP = 1; 47 + const hovered = rgba(80, 80, 100); 48 + 49 + const flagNames: 50 + (keyof Omit<AppContext, "mode" | "event" | "logged" | "log" | "entered">)[] = 51 + [ 52 + "Disambiguate escape codes", 53 + "Report event types", 54 + "Report alternate keys", 55 + "Report all keys as escapes", 56 + "Report associated text", 57 + ]; 58 + 59 + const logEntries: { key: string; name: keyof EventFilter }[] = [ 60 + { key: "a", name: "keydown" }, 61 + { key: "b", name: "keyup" }, 62 + { key: "c", name: "keyrepeat" }, 63 + { key: "d", name: "mousedown" }, 64 + { key: "e", name: "mouseup" }, 65 + { key: "f", name: "mousemove" }, 66 + { key: "g", name: "wheel" }, 67 + { key: "h", name: "resize" }, 68 + { key: "i", name: "pointerenter" }, 69 + { key: "j", name: "pointerleave" }, 70 + { key: "k", name: "pointerclick" }, 71 + ]; 72 + 73 + await main(function* () { 74 + let { columns, rows } = terminalSize(); 75 + 76 + setRawMode(true); 77 + 78 + let stdin = yield* useStdin(); 79 + let input = useInput(stdin); 80 + 81 + let term = yield* until(createTerm({ width: columns, height: rows })); 82 + 83 + let tty = settings(alternateBuffer(), cursor(false)); 84 + writeStdout(tty.apply); 85 + 86 + let modality = recognizer(); 87 + let context = modality.next().value; 88 + 89 + let flags = ttyFlags(context); 90 + writeStdout(flags.apply); 91 + 92 + yield* ensure(() => { 93 + setRawMode(false); 94 + writeStdout(flags.revert); 95 + writeStdout(tty.revert); 96 + }); 97 + 98 + let { output } = term.render(keyboard(context)); 99 + 100 + writeStdout(output); 101 + 102 + let pointer = { 103 + events: createChannel<PointerEvent, void>(), 104 + state: undefined as { x: number; y: number; down: boolean } | undefined, 105 + }; 106 + 107 + for (let event of yield* each(merge(input, pointer.events))) { 108 + if (event.type === "keydown" && event.ctrl && event.key === "c") { 109 + break; 110 + } 111 + if (event.type === "pointerenter") { 112 + context.entered.add(event.id); 113 + } 114 + if (event.type === "pointerleave") { 115 + context.entered.delete(event.id); 116 + } 117 + 118 + let prev = context.logged; 119 + context = modality.next(event).value; 120 + if (context.event && context.log[context.event.type as keyof EventFilter]) { 121 + context = { ...context, logged: context.event }; 122 + } else { 123 + context = { ...context, logged: prev }; 124 + } 125 + 126 + flags = updateFlagsIfChanged(flags, ttyFlags(context)); 127 + 128 + if (context["Capture mouse events"]) { 129 + if ("x" in event) { 130 + pointer.state = { 131 + x: event.x, 132 + y: event.y, 133 + down: event.type === "mousedown", 134 + }; 135 + } 136 + } else { 137 + pointer.state = undefined; 138 + } 139 + 140 + let { output, events } = term.render(keyboard(context), { 141 + pointer: pointer.state, 142 + }); 143 + 144 + for (let event of events) { 145 + yield* pointer.events.send(event); 146 + } 147 + 148 + writeStdout(output); 149 + 150 + yield* each.next(); 151 + } 152 + }); 153 + 154 + function terminalSize(): { columns: number; rows: number } { 155 + return process.stdout.isTTY 156 + ? { 157 + columns: process.stdout.columns ?? 80, 158 + rows: process.stdout.rows ?? 24, 159 + } 160 + : { columns: 80, rows: 24 }; 161 + } 162 + 163 + function setRawMode(enabled: boolean): void { 164 + if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { 165 + process.stdin.setRawMode(enabled); 166 + } 167 + } 168 + 169 + function writeStdout(bytes: Uint8Array): void { 170 + process.stdout.write(Buffer.from(bytes)); 171 + } 172 + 173 + function equalBytes(a: Uint8Array, b: Uint8Array): boolean { 174 + if (a.length !== b.length) { 175 + return false; 176 + } 177 + for (let i = 0; i < a.length; i++) { 178 + if (a[i] !== b[i]) { 179 + return false; 180 + } 181 + } 182 + return true; 183 + } 184 + 185 + function equalSetting(a: Setting, b: Setting): boolean { 186 + return equalBytes(a.apply, b.apply) && equalBytes(a.revert, b.revert); 187 + } 188 + 189 + // Avoid rewriting terminal input modes on every mousemove. Deno's `node:` TTY 190 + // compatibility layer on Windows is sensitive to that churn even when the 191 + // effective settings are unchanged. 192 + function updateFlagsIfChanged(current: Setting, next: Setting): Setting { 193 + if (equalSetting(current, next)) { 194 + return current; 195 + } 196 + 197 + writeStdout(current.revert); 198 + writeStdout(next.apply); 199 + return next; 200 + } 45 201 46 202 interface KeyDef { 47 203 label: string; ··· 57 213 return isKeyEvent(event) && event.type === "keydown" && 58 214 event.code.toUpperCase() === k.code.toUpperCase(); 59 215 } 60 - 61 - const hovered = rgba(80, 80, 100); 62 216 63 217 function key(ops: Op[], k: KeyDef, ctx: AppContext): void { 64 218 let pressed = ctx.event && matches(k, ctx.event); ··· 328 482 ); 329 483 } 330 484 331 - const flagNames: 332 - (keyof Omit<AppContext, "mode" | "event" | "logged" | "log" | "entered">)[] = 333 - [ 334 - "Disambiguate escape codes", 335 - "Report event types", 336 - "Report alternate keys", 337 - "Report all keys as escapes", 338 - "Report associated text", 339 - ]; 340 - 341 - const logEntries: { key: string; name: keyof EventFilter }[] = [ 342 - { key: "a", name: "keydown" }, 343 - { key: "b", name: "keyup" }, 344 - { key: "c", name: "keyrepeat" }, 345 - { key: "d", name: "mousedown" }, 346 - { key: "e", name: "mouseup" }, 347 - { key: "f", name: "mousemove" }, 348 - { key: "g", name: "wheel" }, 349 - { key: "h", name: "resize" }, 350 - { key: "i", name: "pointerenter" }, 351 - { key: "j", name: "pointerleave" }, 352 - { key: "k", name: "pointerclick" }, 353 - ]; 354 - 355 485 function logToggle( 356 486 ops: Op[], 357 487 entries: typeof logEntries, ··· 561 691 } 562 692 return settings(...parts); 563 693 } 564 - 565 - await main(function* () { 566 - let { columns, rows } = Deno.stdout.isTerminal() 567 - ? Deno.consoleSize() 568 - : { columns: 80, rows: 24 }; 569 - 570 - Deno.stdin.setRaw(true); 571 - 572 - let stdin = yield* useStdin(); 573 - let input = useInput(stdin); 574 - 575 - let term = yield* until(createTerm({ width: columns, height: rows })); 576 - 577 - let tty = settings(alternateBuffer(), cursor(false)); 578 - Deno.stdout.writeSync(tty.apply); 579 - 580 - let modality = recognizer(); 581 - let context = modality.next().value; 582 - 583 - let flags = ttyFlags(context); 584 - Deno.stdout.writeSync(flags.apply); 585 - 586 - yield* ensure(() => { 587 - Deno.stdout.writeSync(flags.revert); 588 - Deno.stdout.writeSync(tty.revert); 589 - }); 590 - 591 - let { output } = term.render(keyboard(context)); 592 - 593 - Deno.stdout.writeSync(output); 594 - 595 - let pointer = { 596 - events: createChannel<PointerEvent, void>(), 597 - state: undefined as { x: number; y: number; down: boolean } | undefined, 598 - }; 599 - 600 - for (let event of yield* each(merge(input, pointer.events))) { 601 - if (event.type === "keydown" && event.ctrl && event.key === "c") { 602 - break; 603 - } 604 - if (event.type === "pointerenter") { 605 - context.entered.add(event.id); 606 - } 607 - if (event.type === "pointerleave") { 608 - context.entered.delete(event.id); 609 - } 610 - 611 - let prev = context.logged; 612 - context = modality.next(event).value; 613 - if (context.event && context.log[context.event.type as keyof EventFilter]) { 614 - context = { ...context, logged: context.event }; 615 - } else { 616 - context = { ...context, logged: prev }; 617 - } 618 - 619 - Deno.stdout.writeSync(flags.revert); 620 - flags = ttyFlags(context); 621 - Deno.stdout.writeSync(flags.apply); 622 - 623 - if (context["Capture mouse events"]) { 624 - if ("x" in event) { 625 - pointer.state = { 626 - x: event.x, 627 - y: event.y, 628 - down: event.type === "mousedown", 629 - }; 630 - } 631 - } else { 632 - pointer.state = undefined; 633 - } 634 - 635 - let { output, events } = term.render(keyboard(context), { 636 - pointer: pointer.state, 637 - }); 638 - 639 - for (let event of events) { 640 - yield* pointer.events.send(event); 641 - } 642 - 643 - Deno.stdout.writeSync(output); 644 - 645 - yield* each.next(); 646 - } 647 - }); 648 694 649 695 function* recognizer(): Iterator<AppContext, never, InputEvent | PointerEvent> { 650 696 let current: AppContext = {
+2 -2
examples/keyboard/use-stdin.ts
··· 8 8 type Stream, 9 9 until, 10 10 } from "effection"; 11 + import process from "node:process"; 11 12 12 13 export function useStdin(): Operation<Stream<Uint8Array, void>> { 13 14 return resource(function* (provide) { 14 15 let channel = createChannel<Uint8Array, void>(); 15 - 16 - let iterator = Deno.stdin.readable[Symbol.asyncIterator](); 16 + let iterator = process.stdin.iterator() as AsyncIterator<Uint8Array>; 17 17 18 18 yield* spawn(function* () { 19 19 let next = yield* until(iterator.next());