[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay bomb.sh/docs/tty
Something went wrong. Try again.
tty term.ts
6.3 kB · 241 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242import { type Op, pack } from "./ops.ts";import { type BoundingBox, createTermNative } from "./term-native.ts";
export interface TermOptions { height: number; width: number;}
/** * Structural resize event accepted by update() (renderer-spec 7.7). * The input parser's ResizeEvent is assignable to this shape. */export interface TermResizeEvent { type: "resize"; width: number; height: number;}
/** * Options bag for update() (renderer-spec 8.6): explicit dimensions, * or an event array from which resize events are read (last one wins; * non-resize events are ignored). */export type UpdateOptions = | { width: number; height: number } | { events: ReadonlyArray<TermResizeEvent | { type: string }> };
export interface RenderOptions { mode?: "line";
/** * Row where to begin rendering. This should only be used when * rendering into a region as part of the CLI main screen. For * interfaces that use the entire screen, leave unset which will * default to 0. This is 1-based which which is the DSR native * format. * * https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ */ row?: number;
pointer?: { x: number; y: number; down: boolean; }; deltaTime?: number;}
export type PointerEvent = | { type: "pointerenter"; id: string } | { type: "pointerleave"; id: string } | { type: "pointerclick"; id: string };
export type { BoundingBox };
export interface ElementInfo { bounds: BoundingBox;}
const ERROR_TYPES = [ "TEXT_MEASUREMENT_FUNCTION_NOT_PROVIDED", "ARENA_CAPACITY_EXCEEDED", "ELEMENTS_CAPACITY_EXCEEDED", "TEXT_MEASUREMENT_CAPACITY_EXCEEDED", "DUPLICATE_ID", "FLOATING_CONTAINER_PARENT_NOT_FOUND", "PERCENTAGE_OVER_1", "INTERNAL_ERROR", "UNBALANCED_OPEN_CLOSE", "CLIP_DEPTH_EXCEEDED",] as const;
export interface ClayError { type: string; message: string;}
export interface RenderInfo { get(id: string): ElementInfo | undefined;}
export interface RenderResult { output: Uint8Array; events: PointerEvent[]; info: RenderInfo; errors: ClayError[]; animating: boolean;}
export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult;
/** * Change dimensions in place (renderer-spec 7.7). Synchronous. The * next render() after a non-no-op update emits a complete redraw, * and output views from prior renders become invalid. */ update(options: UpdateOptions): void;}
export async function createTerm(options: TermOptions): Promise<Term> { let { width, height } = options; let native = await createTermNative(width, height); let { memory } = native;
let prev = new Set<string>(); let pressed = new Set<string>(); let wasDown = false; let lastRenderAt: number | undefined; let wasAnimating = false;
return { render(ops: Op[], options?: RenderOptions): RenderResult { let len = pack( ops, memory.buffer, native.opsBuf, memory.buffer.byteLength, ); let mode = options?.mode === "line" ? 1 : 0; let row = options?.row ?? 1; let now = performance.now() / 1000; let dt: number; if (options?.deltaTime !== undefined) { dt = options.deltaTime; } else if (!wasAnimating || lastRenderAt === undefined) { dt = 0; } else { dt = now - lastRenderAt; } lastRenderAt = now; native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt);
if (options?.pointer) { let { x, y, down } = options.pointer; native.setPointer(x, y, down); }
let output = new Uint8Array( memory.buffer, native.output(native.statePtr), native.length(native.statePtr), );
let current = new Set( options?.pointer ? native.getPointerOverIds() : [], ); let down = options?.pointer?.down ?? false; let events: PointerEvent[] = [];
for (let id of current) { if (!prev.has(id)) { events.push({ type: "pointerenter", id }); } }
for (let id of prev) { if (!current.has(id)) { events.push({ type: "pointerleave", id }); } }
if (wasDown && !down) { for (let id of pressed) { if (current.has(id)) { events.push({ type: "pointerclick", id }); } } }
if (down && !wasDown) { pressed = new Set(current); } else if (!down) { pressed.clear(); }
prev = current; wasDown = down;
let info: RenderInfo = { get(id: string): ElementInfo | undefined { let bounds = native.getElementBounds(id); if (bounds) { return { bounds }; } return undefined; }, };
let errors: ClayError[] = []; let count = native.errorCount(native.statePtr); for (let i = 0; i < count; i++) { let code = native.errorType(native.statePtr, i); errors.push({ type: ERROR_TYPES[code] ?? `UNKNOWN_${code}`, message: native.errorMessage(native.statePtr, i), }); }
let animating = native.animating(native.statePtr) > 0; wasAnimating = animating; return { output, events, info, errors, animating }; }, update(options: UpdateOptions): void { let w: number | undefined; let h: number | undefined; if ("events" in options) { for (let e of options.events) { if (e.type === "resize") { let r = e as TermResizeEvent; w = r.width; h = r.height; } } if (w === undefined || h === undefined) { return; } } else { w = options.width; h = options.height; } if ( !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0 ) { throw new RangeError(`invalid terminal dimensions ${w}x${h}`); } if (w === width && h === height) { return; } width = w; height = h; native.update(w, h); prev = new Set(); pressed = new Set(); wasDown = false; lastRenderAt = undefined; wasAnimating = false; }, };}