diff --git a/demo/inline-region.out.txt b/demo/inline-region.out.txt new file mode 100644 index 0000000..1d15499 --- /dev/null +++ b/demo/inline-region.out.txt @@ -0,0 +1,16 @@ +[step 1] line mode render + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠋ Compiling modules...","color":-7607811},{"id":4},{"id":4}] +[step 2] DSR query + cursor at top=52, region top=50 +[step 3] save cursor, start CUP renders +[step 4] CUP frame 1 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠙ Compiling modules...","color":-7607811},{"id":4},{"id":4}] +[step 5] CUP frame 2 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠹ Compiling modules...","color":-7607811},{"id":4},{"id":4}] +[step 6] CUP frame 3 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠸ Compiling modules...","color":-7607811},{"id":4},{"id":4}] +[step 7] CUP frame 4 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-10197916,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"⠼ Compiling modules...","color":-7607811},{"id":4},{"id":4}] +[step 8] CUP frame 5 + ops: [{"id":2,"name":"root","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb"},"bg":-14144970},{"id":2,"name":"box","layout":{"width":{"type":"grow","min":0,"max":0},"height":{"type":"grow","min":0,"max":0},"direction":"ttb","padding":{"left":1},"alignY":2},"border":{"color":-11470213,"left":1,"right":1,"top":1,"bottom":1},"cornerRadius":{"tl":1,"tr":1,"bl":1,"br":1}},{"id":3,"content":"✓ Compiling modules...","color":-11470213},{"id":4},{"id":4}] +[step 9] commit — restore cursor, advance diff --git a/demo/inline-region.ts b/demo/inline-region.ts new file mode 100644 index 0000000..48eb7d2 --- /dev/null +++ b/demo/inline-region.ts @@ -0,0 +1,337 @@ +/** + * Inline region demo — renders animated regions into normal scrollback. + * + * Shows the region lifecycle: + * 1. Allocate space with raw newlines + * 2. DSR — queries cursor position to compute `top` + * 3. CUP mode (all frames) — renders at `top` + * 4. Commit — restore cursor past region, advance with \n + */ + +import { main, type Operation, sleep, until } from "effection"; +import { + close, + createInput, + createTerm, + type CursorEvent, + fixed, + grow, + type Op, + open, + rgba, + text, +} from "../mod.ts"; +import { cursor, settings } from "../settings.ts"; +import { validated } from "../validate.ts"; + +let write = (b: Uint8Array) => Deno.stdout.writeSync(b); +let encode = (s: string) => new TextEncoder().encode(s); +let esc = (s: string) => write(encode(s)); + +let GREEN = rgba(80, 250, 123); +let GRAY = rgba(100, 100, 100); +let CYAN = rgba(139, 233, 253); + +let RED = rgba(255, 0, 0); +let ORANGE = rgba(255, 153, 0); +let YELLOW = rgba(255, 255, 0); +let NGREEN = rgba(51, 255, 0); +let BLUE = rgba(0, 153, 255); +let VIOLET = rgba(102, 0, 255); +let RAINBOW = [RED, ORANGE, YELLOW, NGREEN, BLUE, VIOLET]; + +let BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +function* queryCursor(): Operation { + let parser = yield* until(createInput({ escLatency: 100 })); + esc("\x1b[6n"); + + let buf = new Uint8Array(32); + while (true) { + let n = Deno.stdin.readSync(buf); + if (n === null) continue; + let result = parser.scan(buf.subarray(0, n)); + for (let ev of result.events) { + if (ev.type === "cursor") { + return ev; + } + } + } +} + +function waitKey() { + let buf = new Uint8Array(32); + while (true) { + let n = Deno.stdin.readSync(buf); + if (n === null) continue; + for (let i = 0; i < n; i++) { + if (buf[i] === 0x03) { + Deno.stdin.setRaw(false); + esc("\x1b[?25h"); + Deno.exit(0); + } + } + return; + } +} + +function box(msg: string, fg: number, border: number): Op[] { + return [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("box", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 1 }, + alignY: 2, + }, + border: { + color: border, + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + }), + text(msg, { color: fg }), + close(), + close(), + ]; +} + +function* transaction( + height: number, + renderFrame: (frame: number) => Op[], + frames: number, + interval: number, +): Operation { + let { columns } = Deno.consoleSize(); + + esc("\n".repeat(height)); + + let pos = yield* queryCursor(); + /** 1-based terminal row where the region starts */ + let row = pos.top - height + 1; + + esc("\x1b[s"); + let tty = settings(cursor(false)); + write(tty.apply); + + let term = validated( + yield* until(createTerm({ width: columns, height })), + ); + for (let i = 0; i < frames; i++) { + let result = term.render(renderFrame(i), { row }); + write(new Uint8Array(result.output)); + yield* sleep(interval); + } + + write(tty.revert); + esc("\x1b[u"); + esc("\n"); +} + +function say(msg: string) { + esc(msg + "\n"); +} + +function pause() { + waitKey(); + esc("\n"); +} + +await main(function* () { + let { columns } = Deno.consoleSize(); + Deno.stdin.setRaw(true); + let tty = settings(cursor(false)); + write(tty.apply); + + // Introduction + say("Clayterm can render entire scenes, but it can also render"); + say('"inline" for a streaming UI. This is useful for semi-interactive'); + say("CLI commands that write output to the normal console screen."); + say(""); + + // Demo 1: Spinner box + esc("\n\n\n"); + + let pos = yield* queryCursor(); + /** 1-based terminal row where the region starts */ + let row = pos.top - 2; + + esc("\x1b[s"); + + let frames = 30; + let term = validated( + yield* until(createTerm({ width: columns, height: 3 })), + ); + + let first = term.render( + box("Press any key to compile modules.", CYAN, GRAY), + { row }, + ); + write(new Uint8Array(first.output)); + + waitKey(); + + for (let i = 0; i < frames; i++) { + let done = i === frames - 1; + let icon = done ? "✓" : BRAILLE[i % BRAILLE.length]; + let time = `${((i + 1) * 0.08).toFixed(1)}s`; + let label = done ? "Compiled modules" : "Compiling modules..."; + let result = term.render( + box( + `${icon} ${label} ${time}`, + done ? GREEN : CYAN, + done ? GREEN : GRAY, + ), + { row }, + ); + write(new Uint8Array(result.output)); + yield* sleep(80); + } + + esc("\x1b[u"); + esc("\x1b[0m"); + esc("\n"); + + yield* sleep(500); + + esc( + "\nRegions can be multi-line, but they can be a single line too. (continue...)", + ); + pause(); + + // Demo 2: Progress bar + let barWidth = Math.min(columns, 50); + let barFrames = 40; + yield* transaction( + 1, + (i) => { + let done = i === barFrames - 1; + if (done) { + return [ + open("root", { + layout: { + width: fixed(barWidth), + height: fixed(1), + direction: "ltr", + }, + }), + text("✓ Frobnicated", { color: GREEN }), + close(), + ]; + } + let progress = i / (barFrames - 1); + let label = "Frobnicating.. "; + let remaining = barWidth - label.length - 5; + let filled = Math.round(remaining * Math.min(progress, 1)); + let empty = remaining - filled; + let pct = `${Math.round(progress * 100)}%`; + let bar = "█".repeat(filled) + "░".repeat(empty); + return [ + open("root", { + layout: { + width: fixed(barWidth), + height: fixed(1), + direction: "ltr", + }, + }), + text(label, { color: CYAN }), + text(bar, { color: CYAN }), + text(` ${pct.padStart(4)}`, { color: GRAY }), + close(), + ]; + }, + barFrames, + 50, + ); + + esc("\x1b[0m"); + yield* sleep(500); + esc("\nGoodbye sadness with limitless sky. (continue...)"); + pause(); + + // Demo 3: Nyan cat + let nyanWidth = Math.min(columns, 120); + let nyanFrames = 50; + let cat = [ + "╭─────╮", + "│ ^.^ │", + "╰─────╯", + ]; + let catWidth = cat[0].length; + + yield* transaction( + 3, + (i) => { + let done = i === nyanFrames - 1; + let progress = i / (nyanFrames - 1); + let trail = Math.round((nyanWidth - catWidth) * Math.min(progress, 1)); + + if (done) { + // "IMAGINATION IS BEAUTIFUL WORLD!" in 3-row block font + let font: string[] = [ + "█ █▄█▄█ █▀█ █▀▀ █ █▀█ █▀█ ▀█▀ █ █▀█ █▀█ █ █▀▀ ██▄ █▀▀ █▀█ █ █ ▀█▀ █ █▀▀ █ █ █ █ █ █ █▀█ █▀█ █ █▀▄ █", + "█ █ ▀ █ █▀█ █ █ █ █ █ █▀█ █ █ █ █ █ █ █ ▀▀█ █▀█ █▀▀ █▀█ █ █ █ █ █▀ █ █ █ █▄█▄█ █ █ █▀▄ █ █ █ ▀", + "▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀ ▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀ █", + ]; + let ops: Op[] = [ + open("root", { + layout: { + width: fixed(nyanWidth), + height: fixed(3), + direction: "ttb", + }, + }), + ]; + for (let row = 0; row < 3; row++) { + let color = RAINBOW[(row * 2) % RAINBOW.length]; + ops.push(text(font[row], { color })); + } + ops.push(close()); + return ops; + } + + let ops: Op[] = [ + open("root", { + layout: { + width: fixed(nyanWidth), + height: fixed(3), + direction: "ttb", + }, + }), + ]; + + for (let row = 0; row < 3; row++) { + ops.push( + open(`row${row}`, { + layout: { width: grow(), height: fixed(1), direction: "ltr" }, + }), + ); + + if (trail > 0) { + let color = RAINBOW[(row * 2 + i) % RAINBOW.length]; + ops.push(text("█".repeat(trail), { color })); + } + + ops.push(text(cat[row], { color: CYAN })); + + ops.push(close()); + } + + ops.push(close()); + return ops; + }, + nyanFrames, + 60, + ); + + esc("\x1b[0m\n"); + write(tty.revert); + Deno.stdin.setRaw(false); +}); diff --git a/src/clayterm.c b/src/clayterm.c index 494c9fb..a9bedf6 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -35,7 +35,7 @@ /* ── Instance state ───────────────────────────────────────────────── */ struct Clayterm { - int w, h, row; + int w, h; Cell *front; Cell *back; Buffer out; @@ -129,17 +129,18 @@ static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { ct->lastbg = bg; } -static void emit_cursor(struct Clayterm *ct, int x, int y) { +/** Emit CUP sequence. `row` is the 1-based terminal row of the region. */ +static void emit_cursor(struct Clayterm *ct, int x, int y, int row) { buf_str(&ct->out, "\x1b["); - buf_num(&ct->out, y + 1 + ct->row); + buf_num(&ct->out, y + row); buf_put(&ct->out, ";", 1); buf_num(&ct->out, x + 1); buf_put(&ct->out, "H", 1); } -static void emit_ch(struct Clayterm *ct, int x, int y, uint32_t ch) { +static void emit_ch(struct Clayterm *ct, int x, int y, int row, uint32_t ch) { if (ct->lastx != x - 1 || ct->lasty != y) { - emit_cursor(ct, x, y); + emit_cursor(ct, x, y, row); } ct->lastx = x; ct->lasty = y; @@ -155,7 +156,7 @@ static void emit_ch(struct Clayterm *ct, int x, int y, uint32_t ch) { * skipped, making this efficient for subsequent frames where most of * the screen is static. Derived from termbox2 tb_present. */ -static void present_cups(struct Clayterm *ct) { +static void present_cups(struct Clayterm *ct, int row) { ct->lastx = -1; ct->lasty = -1; @@ -177,9 +178,9 @@ static void present_cups(struct Clayterm *ct) { if (w > 1 && x >= ct->w - (w - 1)) { /* wide char doesn't fit, send spaces */ for (int i = x; i < ct->w; i++) - emit_ch(ct, i, y, ' '); + emit_ch(ct, i, y, row, ' '); } else { - emit_ch(ct, x, y, back->ch); + emit_ch(ct, x, y, row, back->ch); /* mark trailing cells of wide char as invalid in front * so they'll diff when overwritten by narrow chars */ for (int i = 1; i < w; i++) { @@ -400,7 +401,7 @@ int clayterm_size(int w, int h) { static void clay_error(Clay_ErrorData err) { (void)err; } -struct Clayterm *init(void *mem, int w, int h, int row) { +struct Clayterm *init(void *mem, int w, int h) { struct Clayterm *ct = (struct Clayterm *)mem; int cell_count = w * h; int cell_bytes = align8(cell_count * (int)sizeof(Cell)); @@ -417,7 +418,6 @@ struct Clayterm *init(void *mem, int w, int h, int row) { *ct = (struct Clayterm){ .w = w, .h = h, - .row = row, .front = (Cell *)base, .back = (Cell *)(base + cell_bytes), .out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL}, @@ -435,7 +435,7 @@ struct Clayterm *init(void *mem, int w, int h, int row) { return ct; } -void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode) { +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { int i = 0; uint32_t idx = 0; @@ -604,7 +604,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode) { if (mode == 1) { present_lines(ct); } else { - present_cups(ct); + present_cups(ct, row); } } diff --git a/src/clayterm.h b/src/clayterm.h index 43997b3..c5d127a 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -11,8 +11,8 @@ struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); -struct Clayterm *init(void *mem, int w, int h, int row); -void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode); +struct Clayterm *init(void *mem, int w, int h); +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); void measure(int ret, int txt); diff --git a/term-native.ts b/term-native.ts index 7a5e5e3..6e87598 100644 --- a/term-native.ts +++ b/term-native.ts @@ -2,7 +2,7 @@ export interface Native { memory: WebAssembly.Memory; statePtr: number; opsBuf: number; - reduce(ct: number, buf: number, len: number, mode: number): void; + reduce(ct: number, buf: number, len: number, mode: number, row: number): void; output(ct: number): number; length(ct: number): number; setPointer(x: number, y: number, down: boolean): void; @@ -14,7 +14,6 @@ import { compiled } from "./wasm.ts"; export async function createTermNative( w: number, h: number, - row: number = 0, ): Promise { let memory = new WebAssembly.Memory({ initial: 2 }); let exports: Record = {}; @@ -47,8 +46,14 @@ export async function createTermNative( let ct = exports as unknown as { __heap_base: WebAssembly.Global; clayterm_size(w: number, h: number): number; - init(mem: number, w: number, h: number, row: number): number; - reduce(ct: number, buf: number, len: number, mode: number): void; + init(mem: number, w: number, h: number): number; + reduce( + ct: number, + buf: number, + len: number, + mode: number, + row: number, + ): void; output(ct: number): number; length(ct: number): number; Clay_SetPointerState(vec: number, down: number): void; @@ -68,7 +73,7 @@ export async function createTermNative( memory.grow(pages - current); } - let statePtr = ct.init(heap, w, h, row); + let statePtr = ct.init(heap, w, h); let opsBuf = (heap + size + 3) & ~3; return { diff --git a/term.ts b/term.ts index b4f0c93..2b6e9c1 100644 --- a/term.ts +++ b/term.ts @@ -4,11 +4,22 @@ import { createTermNative } from "./term-native.ts"; export interface TermOptions { height: number; width: number; - top?: number; } 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; @@ -31,8 +42,8 @@ export interface Term { } export async function createTerm(options: TermOptions): Promise { - let { width, height, top = 0 } = options; - let native = await createTermNative(width, height, top); + let { width, height } = options; + let native = await createTermNative(width, height); let { memory, statePtr, opsBuf } = native; let prev = new Set(); @@ -43,7 +54,8 @@ export async function createTerm(options: TermOptions): Promise { render(ops: Op[], options?: RenderOptions): RenderResult { let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); let mode = options?.mode === "line" ? 1 : 0; - native.reduce(statePtr, opsBuf, len, mode); + let row = options?.row ?? 1; + native.reduce(statePtr, opsBuf, len, mode, row); if (options?.pointer) { let { x, y, down } = options.pointer; diff --git a/test/term.test.ts b/test/term.test.ts index c45b1ea..00a4b4e 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -153,7 +153,7 @@ describe("term", () => { describe("row offset", () => { it("renders two frames at the offset position", async () => { - let term = await createTerm({ width: 20, height: 5, top: 5 }); + let term = await createTerm({ width: 20, height: 5 }); let box = (msg: string) => [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -181,7 +181,7 @@ describe("term", () => { let header = await createTerm({ width: 20, height: 5 }); let banner = decode(header.render(box("hello")).output); - let first = decode(term.render(box("world")).output); + let first = decode(term.render(box("world"), { row: 6 }).output); expect(print(banner + first, 20, 10)).toEqual(`\ ┌──────────────────┐ │hello │ @@ -194,7 +194,7 @@ describe("term", () => { │ │ └──────────────────┘`); - let second = decode(term.render(box("universe")).output); + let second = decode(term.render(box("universe"), { row: 6 }).output); expect(print(banner + first + second, 20, 10)).toEqual(`\ ┌──────────────────┐ │hello │