diff --git a/demo/inline-region.ts b/demo/inline-region.ts new file mode 100644 index 0000000..75a5b92 --- /dev/null +++ b/demo/inline-region.ts @@ -0,0 +1,343 @@ +/** + * 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, + CSI, + type CursorEvent, + DSR, + ESC, + fixed, + grow, + type Op, + open, + rgba, + SHOWCURSOR, + text, +} from "../mod.ts"; +import { cursor, settings } from "../settings.ts"; +import { validated } from "../validate.ts"; + +const encode = (s: string) => new TextEncoder().encode(s); +const write = (b: Uint8Array) => Deno.stdout.writeSync(b); + +const GREEN = rgba(80, 250, 123); +const GRAY = rgba(100, 100, 100); +const CYAN = rgba(139, 233, 253); + +const RED = rgba(255, 0, 0); +const ORANGE = rgba(255, 153, 0); +const YELLOW = rgba(255, 255, 0); +const NGREEN = rgba(51, 255, 0); +const BLUE = rgba(0, 153, 255); +const VIOLET = rgba(102, 0, 255); +const RAINBOW = [RED, ORANGE, YELLOW, NGREEN, BLUE, VIOLET]; + +const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +function* queryCursor(): Operation { + let parser = yield* until(createInput({ escLatency: 100 })); + write(DSR()); + + 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); + write(SHOWCURSOR()); + 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(); + + write(encode("\n".repeat(height))); + + let pos = yield* queryCursor(); + /** 1-based terminal row where the region starts */ + let row = pos.row - height + 1; + + write(ESC("7")); + 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); + write(ESC("8")); + write(encode("\n")); +} + +function say(msg: string) { + write(encode(msg + "\n")); +} + +function pause() { + waitKey(); + write(encode("\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 + write(encode("\n\n\n")); + + let pos = yield* queryCursor(); + /** 1-based terminal row where the region starts */ + let row = pos.row - 2; + + write(ESC("7")); + + 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); + } + + write(ESC("8")); + write(CSI("0m")); + write(encode("\n")); + + yield* sleep(500); + + write( + encode( + "\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, + ); + + write(CSI("0m")); + yield* sleep(500); + write(encode("\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, + ); + + write(CSI("0m")); + write(encode("\n")); + write(tty.revert); + Deno.stdin.setRaw(false); +}); diff --git a/demo/keyboard.ts b/demo/keyboard.ts index 4c5bfd2..0dce0c3 100644 --- a/demo/keyboard.ts +++ b/demo/keyboard.ts @@ -33,15 +33,15 @@ import { import { useInput } from "./use-input.ts"; import { useStdin } from "./use-stdin.ts"; -let active = rgba(60, 120, 220); -let inactive = rgba(50, 50, 60); -let on = rgba(40, 180, 80); -let label = rgba(220, 220, 220); -let dim = rgba(100, 100, 120); -let highlight = rgba(255, 220, 80); +const active = rgba(60, 120, 220); +const inactive = rgba(50, 50, 60); +const on = rgba(40, 180, 80); +const label = rgba(220, 220, 220); +const dim = rgba(100, 100, 120); +const highlight = rgba(255, 220, 80); -let KEY_W = 5; -let GAP = 1; +const KEY_W = 5; +const GAP = 1; interface KeyDef { label: string; @@ -58,7 +58,7 @@ function matches(k: KeyDef, event: InputEvent | PointerEvent): boolean { event.code.toUpperCase() === k.code.toUpperCase(); } -let hovered = rgba(80, 80, 100); +const hovered = rgba(80, 80, 100); function key(ops: Op[], k: KeyDef, ctx: AppContext): void { let pressed = ctx.event && matches(k, ctx.event); @@ -328,7 +328,7 @@ function toggle(ops: Op[], enabled: boolean, name: string): void { ); } -let flagNames: +const flagNames: (keyof Omit)[] = [ "Disambiguate escape codes", @@ -338,7 +338,7 @@ let flagNames: "Report associated text", ]; -let logEntries: { key: string; name: keyof EventFilter }[] = [ +const logEntries: { key: string; name: keyof EventFilter }[] = [ { key: "a", name: "keydown" }, { key: "b", name: "keyup" }, { key: "c", name: "keyrepeat" }, diff --git a/input.ts b/input.ts index 494f6fa..5163e3a 100644 --- a/input.ts +++ b/input.ts @@ -361,14 +361,14 @@ export interface CursorEvent { type: "cursor"; /** - * Cursor row (0-based). + * Cursor row (1-based). Matches ECMA-48 DSR native format. */ - top: number; + row: number; /** - * Cursor column (0-based). + * Cursor column (1-based). Matches ECMA-48 DSR native format. */ - left: number; + column: number; } import type { PointerEvent } from "./term.ts"; @@ -682,7 +682,7 @@ function mapEvent(native: NativeInputEvent): InputEvent { return { type: "resize", width: native.w, height: native.h }; } case EVENT_CURSOR: { - return { type: "cursor", top: native.y, left: native.x }; + return { type: "cursor", row: native.y, column: native.x }; } default: { return mapKeyEvent(native); diff --git a/mod.ts b/mod.ts index 8841400..8862d13 100644 --- a/mod.ts +++ b/mod.ts @@ -1,3 +1,5 @@ export * from "./ops.ts"; export * from "./term.ts"; export * from "./input.ts"; +export * from "./settings.ts"; +export * from "./termcodes.ts"; diff --git a/settings.ts b/settings.ts index 0d9be06..48077e7 100644 --- a/settings.ts +++ b/settings.ts @@ -1,3 +1,12 @@ +import { + ALTSCREEN, + CSI, + ESC, + HIDECURSOR, + MAINSCREEN, + SHOWCURSOR, +} from "./termcodes.ts"; + export interface Setting { apply: Uint8Array; revert: Uint8Array; @@ -12,47 +21,50 @@ export function settings(...sequence: Setting[]): Setting { export function alternateBuffer(): Setting { return { - apply: csi("?1049h"), - revert: csi("?1049l"), + apply: ALTSCREEN(), + revert: MAINSCREEN(), }; } export function cursor(visible: boolean): Setting { if (visible) { return { - apply: csi("?25h"), - revert: csi("?25l"), + apply: SHOWCURSOR(), + revert: HIDECURSOR(), }; } else { return { - apply: csi("?25l"), - revert: csi("?25h"), + apply: HIDECURSOR(), + revert: SHOWCURSOR(), }; } } -export function progressiveInput(level: number): Setting { +/** + * Save and restore cursor position using DECSC (`ESC 7`) / DECRC (`ESC 8`). + * + * @see {@link https://vt100.net/docs/vt510-rm/DECSC.html | VT510 DECSC} + * @see {@link https://vt100.net/docs/vt510-rm/DECRC.html | VT510 DECRC} + */ +export function saveCursorPosition(): Setting { return { - apply: csi(`>${level}u`), - revert: csi("${level}u`), + revert: CSI("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; @@ -149,9 +150,13 @@ static void emit_ch(struct Clayterm *ct, int x, int y, uint32_t ch) { buf_char(&ct->out, ch); } -/* ── Double-buffer diff (from termbox2 tb_present) ────────────────── */ - -static void present(struct Clayterm *ct) { +/** + * Diff back buffer against front buffer and emit only changed cells + * using absolute CUP positioning (\x1b[row;colH). Unchanged cells are + * 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, int row) { ct->lastx = -1; ct->lasty = -1; @@ -173,9 +178,9 @@ static void present(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++) { @@ -191,6 +196,50 @@ static void present(struct Clayterm *ct) { } } +/** + * Emit back buffer as newline-separated rows without CUP positioning. + * Every cell is written (no diffing), and the front buffer is primed + * so that a subsequent present() call can diff efficiently. This is + * used for inline "region" rendering where the caller manages cursor + * positioning externally and the output must work in pipes. + */ +static void present_lines(struct Clayterm *ct) { + for (int y = 0; y < ct->h; y++) { + if (y > 0) + buf_put(&ct->out, "\n", 1); + for (int x = 0; x < ct->w;) { + Cell *back = cell_at(ct, ct->back, x, y); + Cell *front = cell_at(ct, ct->front, x, y); + + int w = wcwidth(back->ch); + if (w < 1) + w = 1; + + *front = *back; + + emit_attr(ct, back->fg, back->bg); + + if (w > 1 && x >= ct->w - (w - 1)) { + for (int i = x; i < ct->w; i++) { + buf_char(&ct->out, ' '); + } + } else { + uint32_t ch = back->ch; + if (!iswprint(ch)) + ch = 0xfffd; + buf_char(&ct->out, ch); + for (int i = 1; i < w; i++) { + Cell *fw = cell_at(ct, ct->front, x + i, y); + fw->ch = 0xffffffff; + fw->fg = 0xffffffff; + fw->bg = 0xffffffff; + } + } + x += w; + } + } +} + /* ── Color conversion ─────────────────────────────────────────────── */ static uint32_t color(Clay_Color c) { @@ -352,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)); @@ -369,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}, @@ -379,12 +427,15 @@ struct Clayterm *init(void *mem, int w, int h, int row) { .lasty = -1, }; - cells_clear(ct->front, w, h); - cells_clear(ct->back, w, h); + // initialize back buffer with spaces and default fg/bg + cells_fill(ct->back, w, h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); + + // initialize front buffer with zeros. Every cell will be + cells_fill(ct->front, w, h, 0, 0, 0); return ct; } -void reduce(struct Clayterm *ct, uint32_t *buf, int len) { +void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { int i = 0; uint32_t idx = 0; @@ -514,7 +565,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { ct->lastfg = ct->lastbg = 0xffffffff; ct->lastx = ct->lasty = -1; - cells_clear(ct->back, ct->w, ct->h); + cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); /* walk Clay render commands into back buffer */ for (int32_t j = 0; j < cmds.length; j++) { @@ -550,8 +601,11 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len) { } } - /* diff front vs back, emit escape sequences */ - present(ct); + if (mode == 1) { + present_lines(ct); + } else { + present_cups(ct, row); + } } char *output(struct Clayterm *ct) { return ct->out.data; } diff --git a/src/clayterm.h b/src/clayterm.h index f6bcbd7..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); +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/src/input.c b/src/input.c index b25de44..1f6257c 100644 --- a/src/input.c +++ b/src/input.c @@ -604,8 +604,8 @@ static int parse_cursor(struct InputState *st, struct InputEvent *ev) { return PARSE_ERR; i++; ev->type = EVENT_CURSOR; - ev->y = row - 1; - ev->x = col - 1; + ev->y = row; + ev->x = col; shift(st, i); return PARSE_OK; } else { diff --git a/term-native.ts b/term-native.ts index d17834a..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): 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): 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 8f83c65..2b6e9c1 100644 --- a/term.ts +++ b/term.ts @@ -4,10 +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; @@ -30,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(); @@ -41,7 +53,9 @@ export async function createTerm(options: TermOptions): Promise { return { render(ops: Op[], options?: RenderOptions): RenderResult { let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); - native.reduce(statePtr, opsBuf, len); + let mode = options?.mode === "line" ? 1 : 0; + let row = options?.row ?? 1; + native.reduce(statePtr, opsBuf, len, mode, row); if (options?.pointer) { let { x, y, down } = options.pointer; diff --git a/termcodes.ts b/termcodes.ts new file mode 100644 index 0000000..d7e0f01 --- /dev/null +++ b/termcodes.ts @@ -0,0 +1,84 @@ +/** + * Encode a plain escape sequence. + * + * Prepends `ESC` (`\x1b`) to the given string and returns the result as bytes. + * + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} + */ +export function ESC(str: string): Uint8Array { + return encode(`\x1b${str}`); +} + +/** + * Encode a Control Sequence Introducer (CSI) command. + * + * Prepends `ESC[` to the given string and returns the result as bytes. + * + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} + */ +export function CSI(str: string): Uint8Array { + return ESC(`[${str}`); +} + +/** + * Request the cursor position via Device Status Report (DSR). + * + * Sends `CSI 6n`. The terminal responds with a Cursor Position Report + * (`CSI row ; column R`) where row and column are 1-based. + * + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} + */ +export function DSR(): Uint8Array { + return CSI("6n"); +} + +/** + * Show the cursor (DECTCEM set). + * + * DEC private mode 25. Not part of ECMA-48; originates from the VT220. + * + * @see {@link https://vt100.net/docs/vt510-rm/DECTCEM.html | VT510 DECTCEM} + */ +export function SHOWCURSOR(): Uint8Array { + return CSI("?25h"); +} + +/** + * Hide the cursor (DECTCEM reset). + * + * DEC private mode 25. Not part of ECMA-48; originates from the VT220. + * + * @see {@link https://vt100.net/docs/vt510-rm/DECTCEM.html | VT510 DECTCEM} + */ +export function HIDECURSOR(): Uint8Array { + return CSI("?25l"); +} + +/** + * Switch to the alternate screen buffer (xterm private mode 1049). + * + * Saves the cursor and switches to a clean alternate screen. Use + * {@link MAINSCREEN} to switch back. + * + * @see {@link https://invisible-island.net/xterm/ctlseqs/ctlseqs.html | xterm control sequences} + */ +export function ALTSCREEN(): Uint8Array { + return CSI("?1049h"); +} + +/** + * Switch back to the main screen buffer (xterm private mode 1049). + * + * Restores the cursor and returns to the main screen with scrollback intact. + * + * @see {@link https://invisible-island.net/xterm/ctlseqs/ctlseqs.html | xterm control sequences} + */ +export function MAINSCREEN(): Uint8Array { + return CSI("?1049l"); +} + +const encoder = new TextEncoder(); + +function encode(str: string): Uint8Array { + return encoder.encode(str); +} diff --git a/test/input.test.ts b/test/input.test.ts index 3eb81b8..38af941 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -687,8 +687,8 @@ describe("input", () => { expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "cursor", - top: 23, - left: 79, + row: 24, + column: 80, }); }); @@ -697,8 +697,8 @@ describe("input", () => { expect(result.events.length).toBe(1); expect(result.events[0]).toMatchObject({ type: "cursor", - top: 0, - left: 0, + row: 1, + column: 1, }); }); @@ -708,8 +708,8 @@ describe("input", () => { expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); expect(result.events[1]).toMatchObject({ type: "cursor", - top: 9, - left: 4, + row: 10, + column: 5, }); expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); }); diff --git a/test/print.ts b/test/print.ts index bd32cf4..1801bb3 100644 --- a/test/print.ts +++ b/test/print.ts @@ -38,6 +38,10 @@ export function print(ansi: string, w: number, h: number): string { // SGR — ignore } // ignore all other CSI sequences (?25l, ?25h, etc.) + } else if (ansi[i] === "\n") { + y++; + x = 0; + i++; } else { // regular character — could be multi-byte UTF-8 let cp = ansi.codePointAt(i)!; diff --git a/test/term.test.ts b/test/term.test.ts index c1ac926..00a4b4e 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -4,6 +4,7 @@ import { close, grow, open, rgba, text } from "../ops.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); +const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); describe("term", () => { let term: Term; @@ -89,9 +90,70 @@ describe("term", () => { ╰──────────────────────────────────────╯`.trim()); }); + describe("line mode", () => { + let box = (msg: string) => [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("box", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text(msg), + close(), + close(), + ]; + + it("renders with newlines instead of CUP sequences", async () => { + let term = await createTerm({ width: 20, height: 5 }); + + let out = decode( + term.render(box("hello world"), { mode: "line" }).output, + ); + // deno-lint-ignore no-control-regex + expect(out).not.toMatch(/\x1b\[\d+;\d+H/); + expect(out.split("\n").length).toBe(5); + expect(trim(print(out, 20, 5))).toEqual(` +┌──────────────────┐ +│hello world │ +│ │ +│ │ +└──────────────────┘`.trim()); + }); + + it("primes front buffer for subsequent diff render", async () => { + let term = await createTerm({ width: 20, height: 5 }); + + let first = decode( + term.render(box("hello world"), { mode: "line" }).output, + ); + let second = decode(term.render(box("goodbye")).output); + + expect(trim(print(first + second, 20, 5))).toEqual(` +┌──────────────────┐ +│goodbye │ +│ │ +│ │ +└──────────────────┘`.trim()); + + expect(second.length).toBeLessThan(first.length); + }); + }); + 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" }, @@ -119,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 │ @@ -132,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 │