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

Merge pull request #4 from thefrontside/line-mode

Add line rendering mode, termcodes, and inline region demo

authored by

Charles Lowell and committed by
GitHub
(Apr 10, 2026, 4:41 PM -0500) 5372af80 4971170e

+662 -81
+343
demo/inline-region.ts
··· 1 + /** 2 + * Inline region demo — renders animated regions into normal scrollback. 3 + * 4 + * Shows the region lifecycle: 5 + * 1. Allocate space with raw newlines 6 + * 2. DSR — queries cursor position to compute `top` 7 + * 3. CUP mode (all frames) — renders at `top` 8 + * 4. Commit — restore cursor past region, advance with \n 9 + */ 10 + 11 + import { main, type Operation, sleep, until } from "effection"; 12 + import { 13 + close, 14 + createInput, 15 + createTerm, 16 + CSI, 17 + type CursorEvent, 18 + DSR, 19 + ESC, 20 + fixed, 21 + grow, 22 + type Op, 23 + open, 24 + rgba, 25 + SHOWCURSOR, 26 + text, 27 + } from "../mod.ts"; 28 + import { cursor, settings } from "../settings.ts"; 29 + import { validated } from "../validate.ts"; 30 + 31 + const encode = (s: string) => new TextEncoder().encode(s); 32 + const write = (b: Uint8Array) => Deno.stdout.writeSync(b); 33 + 34 + const GREEN = rgba(80, 250, 123); 35 + const GRAY = rgba(100, 100, 100); 36 + const CYAN = rgba(139, 233, 253); 37 + 38 + const RED = rgba(255, 0, 0); 39 + const ORANGE = rgba(255, 153, 0); 40 + const YELLOW = rgba(255, 255, 0); 41 + const NGREEN = rgba(51, 255, 0); 42 + const BLUE = rgba(0, 153, 255); 43 + const VIOLET = rgba(102, 0, 255); 44 + const RAINBOW = [RED, ORANGE, YELLOW, NGREEN, BLUE, VIOLET]; 45 + 46 + const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; 47 + 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")); 124 + let tty = settings(cursor(false)); 125 + write(tty.apply); 126 + 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); 155 + 156 + // Introduction 157 + say("Clayterm can render entire scenes, but it can also render"); 158 + say('"inline" for a streaming UI. This is useful for semi-interactive'); 159 + say("CLI commands that write output to the normal console screen."); 160 + say(""); 161 + 162 + // Demo 1: Spinner box 163 + write(encode("\n\n\n")); 164 + 165 + let pos = yield* queryCursor(); 166 + /** 1-based terminal row where the region starts */ 167 + let row = pos.row - 2; 168 + 169 + write(ESC("7")); 170 + 171 + let frames = 30; 172 + let term = validated( 173 + yield* until(createTerm({ width: columns, height: 3 })), 174 + ); 175 + 176 + let first = term.render( 177 + box("Press any key to compile modules.", CYAN, GRAY), 178 + { row }, 179 + ); 180 + write(new Uint8Array(first.output)); 181 + 182 + waitKey(); 183 + 184 + for (let i = 0; i < frames; i++) { 185 + let done = i === frames - 1; 186 + let icon = done ? "✓" : BRAILLE[i % BRAILLE.length]; 187 + let time = `${((i + 1) * 0.08).toFixed(1)}s`; 188 + let label = done ? "Compiled modules" : "Compiling modules..."; 189 + let result = term.render( 190 + box( 191 + `${icon} ${label} ${time}`, 192 + done ? GREEN : CYAN, 193 + done ? GREEN : GRAY, 194 + ), 195 + { row }, 196 + ); 197 + write(new Uint8Array(result.output)); 198 + yield* sleep(80); 199 + } 200 + 201 + write(ESC("8")); 202 + write(CSI("0m")); 203 + write(encode("\n")); 204 + 205 + yield* sleep(500); 206 + 207 + write( 208 + encode( 209 + "\nRegions can be multi-line, but they can be a single line too. (continue...)", 210 + ), 211 + ); 212 + pause(); 213 + 214 + // Demo 2: Progress bar 215 + let barWidth = Math.min(columns, 50); 216 + let barFrames = 40; 217 + yield* transaction( 218 + 1, 219 + (i) => { 220 + let done = i === barFrames - 1; 221 + if (done) { 222 + return [ 223 + open("root", { 224 + layout: { 225 + width: fixed(barWidth), 226 + height: fixed(1), 227 + direction: "ltr", 228 + }, 229 + }), 230 + text("✓ Frobnicated", { color: GREEN }), 231 + close(), 232 + ]; 233 + } 234 + let progress = i / (barFrames - 1); 235 + let label = "Frobnicating.. "; 236 + let remaining = barWidth - label.length - 5; 237 + let filled = Math.round(remaining * Math.min(progress, 1)); 238 + let empty = remaining - filled; 239 + let pct = `${Math.round(progress * 100)}%`; 240 + let bar = "█".repeat(filled) + "░".repeat(empty); 241 + return [ 242 + open("root", { 243 + layout: { 244 + width: fixed(barWidth), 245 + height: fixed(1), 246 + direction: "ltr", 247 + }, 248 + }), 249 + text(label, { color: CYAN }), 250 + text(bar, { color: CYAN }), 251 + text(` ${pct.padStart(4)}`, { color: GRAY }), 252 + close(), 253 + ]; 254 + }, 255 + barFrames, 256 + 50, 257 + ); 258 + 259 + write(CSI("0m")); 260 + yield* sleep(500); 261 + write(encode("\nGoodbye sadness with limitless sky. (continue...)")); 262 + pause(); 263 + 264 + // Demo 3: Nyan cat 265 + let nyanWidth = Math.min(columns, 120); 266 + let nyanFrames = 50; 267 + let cat = [ 268 + "╭─────╮", 269 + "│ ^.^ │", 270 + "╰─────╯", 271 + ]; 272 + let catWidth = cat[0].length; 273 + 274 + yield* transaction( 275 + 3, 276 + (i) => { 277 + let done = i === nyanFrames - 1; 278 + let progress = i / (nyanFrames - 1); 279 + let trail = Math.round((nyanWidth - catWidth) * Math.min(progress, 1)); 280 + 281 + if (done) { 282 + // "IMAGINATION IS BEAUTIFUL WORLD!" in 3-row block font 283 + let font: string[] = [ 284 + "█ █▄█▄█ █▀█ █▀▀ █ █▀█ █▀█ ▀█▀ █ █▀█ █▀█ █ █▀▀ ██▄ █▀▀ █▀█ █ █ ▀█▀ █ █▀▀ █ █ █ █ █ █ █▀█ █▀█ █ █▀▄ █", 285 + "█ █ ▀ █ █▀█ █ █ █ █ █ █▀█ █ █ █ █ █ █ █ ▀▀█ █▀█ █▀▀ █▀█ █ █ █ █ █▀ █ █ █ █▄█▄█ █ █ █▀▄ █ █ █ ▀", 286 + "▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀ ▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀ █", 287 + ]; 288 + let ops: Op[] = [ 289 + open("root", { 290 + layout: { 291 + width: fixed(nyanWidth), 292 + height: fixed(3), 293 + direction: "ttb", 294 + }, 295 + }), 296 + ]; 297 + for (let row = 0; row < 3; row++) { 298 + let color = RAINBOW[(row * 2) % RAINBOW.length]; 299 + ops.push(text(font[row], { color })); 300 + } 301 + ops.push(close()); 302 + return ops; 303 + } 304 + 305 + let ops: Op[] = [ 306 + open("root", { 307 + layout: { 308 + width: fixed(nyanWidth), 309 + height: fixed(3), 310 + direction: "ttb", 311 + }, 312 + }), 313 + ]; 314 + 315 + for (let row = 0; row < 3; row++) { 316 + ops.push( 317 + open(`row${row}`, { 318 + layout: { width: grow(), height: fixed(1), direction: "ltr" }, 319 + }), 320 + ); 321 + 322 + if (trail > 0) { 323 + let color = RAINBOW[(row * 2 + i) % RAINBOW.length]; 324 + ops.push(text("█".repeat(trail), { color })); 325 + } 326 + 327 + ops.push(text(cat[row], { color: CYAN })); 328 + 329 + ops.push(close()); 330 + } 331 + 332 + ops.push(close()); 333 + return ops; 334 + }, 335 + nyanFrames, 336 + 60, 337 + ); 338 + 339 + write(CSI("0m")); 340 + write(encode("\n")); 341 + write(tty.revert); 342 + Deno.stdin.setRaw(false); 343 + });
+11 -11
demo/keyboard.ts
··· 33 33 import { useInput } from "./use-input.ts"; 34 34 import { useStdin } from "./use-stdin.ts"; 35 35 36 - let active = rgba(60, 120, 220); 37 - let inactive = rgba(50, 50, 60); 38 - let on = rgba(40, 180, 80); 39 - let label = rgba(220, 220, 220); 40 - let dim = rgba(100, 100, 120); 41 - let highlight = rgba(255, 220, 80); 36 + const active = rgba(60, 120, 220); 37 + const inactive = rgba(50, 50, 60); 38 + const on = rgba(40, 180, 80); 39 + const label = rgba(220, 220, 220); 40 + const dim = rgba(100, 100, 120); 41 + const highlight = rgba(255, 220, 80); 42 42 43 - let KEY_W = 5; 44 - let GAP = 1; 43 + const KEY_W = 5; 44 + const GAP = 1; 45 45 46 46 interface KeyDef { 47 47 label: string; ··· 58 58 event.code.toUpperCase() === k.code.toUpperCase(); 59 59 } 60 60 61 - let hovered = rgba(80, 80, 100); 61 + const hovered = rgba(80, 80, 100); 62 62 63 63 function key(ops: Op[], k: KeyDef, ctx: AppContext): void { 64 64 let pressed = ctx.event && matches(k, ctx.event); ··· 328 328 ); 329 329 } 330 330 331 - let flagNames: 331 + const flagNames: 332 332 (keyof Omit<AppContext, "mode" | "event" | "logged" | "log" | "entered">)[] = 333 333 [ 334 334 "Disambiguate escape codes", ··· 338 338 "Report associated text", 339 339 ]; 340 340 341 - let logEntries: { key: string; name: keyof EventFilter }[] = [ 341 + const logEntries: { key: string; name: keyof EventFilter }[] = [ 342 342 { key: "a", name: "keydown" }, 343 343 { key: "b", name: "keyup" }, 344 344 { key: "c", name: "keyrepeat" },
+5 -5
input.ts
··· 361 361 type: "cursor"; 362 362 363 363 /** 364 - * Cursor row (0-based). 364 + * Cursor row (1-based). Matches ECMA-48 DSR native format. 365 365 */ 366 - top: number; 366 + row: number; 367 367 368 368 /** 369 - * Cursor column (0-based). 369 + * Cursor column (1-based). Matches ECMA-48 DSR native format. 370 370 */ 371 - left: number; 371 + column: number; 372 372 } 373 373 374 374 import type { PointerEvent } from "./term.ts"; ··· 682 682 return { type: "resize", width: native.w, height: native.h }; 683 683 } 684 684 case EVENT_CURSOR: { 685 - return { type: "cursor", top: native.y, left: native.x }; 685 + return { type: "cursor", row: native.y, column: native.x }; 686 686 } 687 687 default: { 688 688 return mapKeyEvent(native);
+2
mod.ts
··· 1 1 export * from "./ops.ts"; 2 2 export * from "./term.ts"; 3 3 export * from "./input.ts"; 4 + export * from "./settings.ts"; 5 + export * from "./termcodes.ts";
+32 -20
settings.ts
··· 1 + import { 2 + ALTSCREEN, 3 + CSI, 4 + ESC, 5 + HIDECURSOR, 6 + MAINSCREEN, 7 + SHOWCURSOR, 8 + } from "./termcodes.ts"; 9 + 1 10 export interface Setting { 2 11 apply: Uint8Array; 3 12 revert: Uint8Array; ··· 12 21 13 22 export function alternateBuffer(): Setting { 14 23 return { 15 - apply: csi("?1049h"), 16 - revert: csi("?1049l"), 24 + apply: ALTSCREEN(), 25 + revert: MAINSCREEN(), 17 26 }; 18 27 } 19 28 20 29 export function cursor(visible: boolean): Setting { 21 30 if (visible) { 22 31 return { 23 - apply: csi("?25h"), 24 - revert: csi("?25l"), 32 + apply: SHOWCURSOR(), 33 + revert: HIDECURSOR(), 25 34 }; 26 35 } else { 27 36 return { 28 - apply: csi("?25l"), 29 - revert: csi("?25h"), 37 + apply: HIDECURSOR(), 38 + revert: SHOWCURSOR(), 30 39 }; 31 40 } 32 41 } 33 42 34 - export function progressiveInput(level: number): Setting { 43 + /** 44 + * Save and restore cursor position using DECSC (`ESC 7`) / DECRC (`ESC 8`). 45 + * 46 + * @see {@link https://vt100.net/docs/vt510-rm/DECSC.html | VT510 DECSC} 47 + * @see {@link https://vt100.net/docs/vt510-rm/DECRC.html | VT510 DECRC} 48 + */ 49 + export function saveCursorPosition(): Setting { 35 50 return { 36 - apply: csi(`>${level}u`), 37 - revert: csi("<u"), 51 + apply: ESC("7"), 52 + revert: ESC("8"), 38 53 }; 39 54 } 40 55 41 - export function mouseTracking(): Setting { 56 + export function progressiveInput(level: number): Setting { 42 57 return { 43 - apply: concat([csi("?1003h"), csi("?1006h")]), 44 - revert: concat([csi("?1006l"), csi("?1003l")]), 58 + apply: CSI(`>${level}u`), 59 + revert: CSI("<u"), 45 60 }; 46 61 } 47 62 48 - let encoder = new TextEncoder(); 49 - 50 - function encode(str: string): Uint8Array { 51 - return encoder.encode(str); 52 - } 53 - 54 - function csi(str: string): Uint8Array { 55 - return encode(`\x1b[${str}`); 63 + export function mouseTracking(): Setting { 64 + return { 65 + apply: concat([CSI("?1003h"), CSI("?1006h")]), 66 + revert: concat([CSI("?1006l"), CSI("?1003l")]), 67 + }; 56 68 } 57 69 58 70 function concat(arrays: Uint8Array[]): Uint8Array {
+5 -4
src/cell.c
··· 2 2 3 3 #include "cell.h" 4 4 5 - void cells_clear(Cell *buf, int w, int h) { 5 + void cells_fill(Cell *buf, int w, int h, uint32_t ch, uint32_t fg, 6 + uint32_t bg) { 6 7 for (int i = 0; i < w * h; i++) { 7 - buf[i].ch = ' '; 8 - buf[i].fg = ATTR_DEFAULT; 9 - buf[i].bg = ATTR_DEFAULT; 8 + buf[i].ch = ch; 9 + buf[i].fg = fg; 10 + buf[i].bg = bg; 10 11 } 11 12 } 12 13
+1 -1
src/cell.h
··· 24 24 #define ATTR_MASK 0xFF000000 25 25 #define COLOR_MASK 0x00FFFFFF 26 26 27 - void cells_clear(Cell *buf, int w, int h); 27 + void cells_fill(Cell *buf, int w, int h, uint32_t ch, uint32_t fg, uint32_t bg); 28 28 int cell_cmp(Cell *a, Cell *b); 29 29 30 30 #endif
+72 -18
src/clayterm.c
··· 35 35 /* ── Instance state ───────────────────────────────────────────────── */ 36 36 37 37 struct Clayterm { 38 - int w, h, row; 38 + int w, h; 39 39 Cell *front; 40 40 Cell *back; 41 41 Buffer out; ··· 129 129 ct->lastbg = bg; 130 130 } 131 131 132 - static void emit_cursor(struct Clayterm *ct, int x, int y) { 132 + /** Emit CUP sequence. `row` is the 1-based terminal row of the region. */ 133 + static void emit_cursor(struct Clayterm *ct, int x, int y, int row) { 133 134 buf_str(&ct->out, "\x1b["); 134 - buf_num(&ct->out, y + 1 + ct->row); 135 + buf_num(&ct->out, y + row); 135 136 buf_put(&ct->out, ";", 1); 136 137 buf_num(&ct->out, x + 1); 137 138 buf_put(&ct->out, "H", 1); 138 139 } 139 140 140 - static void emit_ch(struct Clayterm *ct, int x, int y, uint32_t ch) { 141 + static void emit_ch(struct Clayterm *ct, int x, int y, int row, uint32_t ch) { 141 142 if (ct->lastx != x - 1 || ct->lasty != y) { 142 - emit_cursor(ct, x, y); 143 + emit_cursor(ct, x, y, row); 143 144 } 144 145 ct->lastx = x; 145 146 ct->lasty = y; ··· 149 150 buf_char(&ct->out, ch); 150 151 } 151 152 152 - /* ── Double-buffer diff (from termbox2 tb_present) ────────────────── */ 153 - 154 - static void present(struct Clayterm *ct) { 153 + /** 154 + * Diff back buffer against front buffer and emit only changed cells 155 + * using absolute CUP positioning (\x1b[row;colH). Unchanged cells are 156 + * skipped, making this efficient for subsequent frames where most of 157 + * the screen is static. Derived from termbox2 tb_present. 158 + */ 159 + static void present_cups(struct Clayterm *ct, int row) { 155 160 ct->lastx = -1; 156 161 ct->lasty = -1; 157 162 ··· 173 178 if (w > 1 && x >= ct->w - (w - 1)) { 174 179 /* wide char doesn't fit, send spaces */ 175 180 for (int i = x; i < ct->w; i++) 176 - emit_ch(ct, i, y, ' '); 181 + emit_ch(ct, i, y, row, ' '); 177 182 } else { 178 - emit_ch(ct, x, y, back->ch); 183 + emit_ch(ct, x, y, row, back->ch); 179 184 /* mark trailing cells of wide char as invalid in front 180 185 * so they'll diff when overwritten by narrow chars */ 181 186 for (int i = 1; i < w; i++) { ··· 191 196 } 192 197 } 193 198 199 + /** 200 + * Emit back buffer as newline-separated rows without CUP positioning. 201 + * Every cell is written (no diffing), and the front buffer is primed 202 + * so that a subsequent present() call can diff efficiently. This is 203 + * used for inline "region" rendering where the caller manages cursor 204 + * positioning externally and the output must work in pipes. 205 + */ 206 + static void present_lines(struct Clayterm *ct) { 207 + for (int y = 0; y < ct->h; y++) { 208 + if (y > 0) 209 + buf_put(&ct->out, "\n", 1); 210 + for (int x = 0; x < ct->w;) { 211 + Cell *back = cell_at(ct, ct->back, x, y); 212 + Cell *front = cell_at(ct, ct->front, x, y); 213 + 214 + int w = wcwidth(back->ch); 215 + if (w < 1) 216 + w = 1; 217 + 218 + *front = *back; 219 + 220 + emit_attr(ct, back->fg, back->bg); 221 + 222 + if (w > 1 && x >= ct->w - (w - 1)) { 223 + for (int i = x; i < ct->w; i++) { 224 + buf_char(&ct->out, ' '); 225 + } 226 + } else { 227 + uint32_t ch = back->ch; 228 + if (!iswprint(ch)) 229 + ch = 0xfffd; 230 + buf_char(&ct->out, ch); 231 + for (int i = 1; i < w; i++) { 232 + Cell *fw = cell_at(ct, ct->front, x + i, y); 233 + fw->ch = 0xffffffff; 234 + fw->fg = 0xffffffff; 235 + fw->bg = 0xffffffff; 236 + } 237 + } 238 + x += w; 239 + } 240 + } 241 + } 242 + 194 243 /* ── Color conversion ─────────────────────────────────────────────── */ 195 244 196 245 static uint32_t color(Clay_Color c) { ··· 352 401 353 402 static void clay_error(Clay_ErrorData err) { (void)err; } 354 403 355 - struct Clayterm *init(void *mem, int w, int h, int row) { 404 + struct Clayterm *init(void *mem, int w, int h) { 356 405 struct Clayterm *ct = (struct Clayterm *)mem; 357 406 int cell_count = w * h; 358 407 int cell_bytes = align8(cell_count * (int)sizeof(Cell)); ··· 369 418 *ct = (struct Clayterm){ 370 419 .w = w, 371 420 .h = h, 372 - .row = row, 373 421 .front = (Cell *)base, 374 422 .back = (Cell *)(base + cell_bytes), 375 423 .out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL}, ··· 379 427 .lasty = -1, 380 428 }; 381 429 382 - cells_clear(ct->front, w, h); 383 - cells_clear(ct->back, w, h); 430 + // initialize back buffer with spaces and default fg/bg 431 + cells_fill(ct->back, w, h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); 432 + 433 + // initialize front buffer with zeros. Every cell will be 434 + cells_fill(ct->front, w, h, 0, 0, 0); 384 435 return ct; 385 436 } 386 437 387 - void reduce(struct Clayterm *ct, uint32_t *buf, int len) { 438 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { 388 439 int i = 0; 389 440 uint32_t idx = 0; 390 441 ··· 514 565 ct->lastfg = ct->lastbg = 0xffffffff; 515 566 ct->lastx = ct->lasty = -1; 516 567 517 - cells_clear(ct->back, ct->w, ct->h); 568 + cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); 518 569 519 570 /* walk Clay render commands into back buffer */ 520 571 for (int32_t j = 0; j < cmds.length; j++) { ··· 550 601 } 551 602 } 552 603 553 - /* diff front vs back, emit escape sequences */ 554 - present(ct); 604 + if (mode == 1) { 605 + present_lines(ct); 606 + } else { 607 + present_cups(ct, row); 608 + } 555 609 } 556 610 557 611 char *output(struct Clayterm *ct) { return ct->out.data; }
+2 -2
src/clayterm.h
··· 11 11 12 12 /* WASM exports */ 13 13 int clayterm_size(int w, int h); 14 - struct Clayterm *init(void *mem, int w, int h, int row); 15 - void reduce(struct Clayterm *ct, uint32_t *buf, int len); 14 + struct Clayterm *init(void *mem, int w, int h); 15 + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row); 16 16 char *output(struct Clayterm *ct); 17 17 int length(struct Clayterm *ct); 18 18 void measure(int ret, int txt);
+2 -2
src/input.c
··· 604 604 return PARSE_ERR; 605 605 i++; 606 606 ev->type = EVENT_CURSOR; 607 - ev->y = row - 1; 608 - ev->x = col - 1; 607 + ev->y = row; 608 + ev->x = col; 609 609 shift(st, i); 610 610 return PARSE_OK; 611 611 } else {
+10 -5
term-native.ts
··· 2 2 memory: WebAssembly.Memory; 3 3 statePtr: number; 4 4 opsBuf: number; 5 - reduce(ct: number, buf: number, len: number): void; 5 + reduce(ct: number, buf: number, len: number, mode: number, row: number): void; 6 6 output(ct: number): number; 7 7 length(ct: number): number; 8 8 setPointer(x: number, y: number, down: boolean): void; ··· 14 14 export async function createTermNative( 15 15 w: number, 16 16 h: number, 17 - row: number = 0, 18 17 ): Promise<Native> { 19 18 let memory = new WebAssembly.Memory({ initial: 2 }); 20 19 let exports: Record<string, CallableFunction> = {}; ··· 47 46 let ct = exports as unknown as { 48 47 __heap_base: WebAssembly.Global; 49 48 clayterm_size(w: number, h: number): number; 50 - init(mem: number, w: number, h: number, row: number): number; 51 - reduce(ct: number, buf: number, len: number): void; 49 + init(mem: number, w: number, h: number): number; 50 + reduce( 51 + ct: number, 52 + buf: number, 53 + len: number, 54 + mode: number, 55 + row: number, 56 + ): void; 52 57 output(ct: number): number; 53 58 length(ct: number): number; 54 59 Clay_SetPointerState(vec: number, down: number): void; ··· 68 73 memory.grow(pages - current); 69 74 } 70 75 71 - let statePtr = ct.init(heap, w, h, row); 76 + let statePtr = ct.init(heap, w, h); 72 77 let opsBuf = (heap + size + 3) & ~3; 73 78 74 79 return {
+18 -4
term.ts
··· 4 4 export interface TermOptions { 5 5 height: number; 6 6 width: number; 7 - top?: number; 8 7 } 9 8 10 9 export interface RenderOptions { 10 + mode?: "line"; 11 + 12 + /** 13 + * Row where to begin rendering. This should only be used when 14 + * rendering into a region as part of the CLI main screen. For 15 + * interfaces that use the entire screen, leave unset which will 16 + * default to 0. This is 1-based which which is the DSR native 17 + * format. 18 + * 19 + * https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ 20 + */ 21 + row?: number; 22 + 11 23 pointer?: { 12 24 x: number; 13 25 y: number; ··· 30 42 } 31 43 32 44 export async function createTerm(options: TermOptions): Promise<Term> { 33 - let { width, height, top = 0 } = options; 34 - let native = await createTermNative(width, height, top); 45 + let { width, height } = options; 46 + let native = await createTermNative(width, height); 35 47 let { memory, statePtr, opsBuf } = native; 36 48 37 49 let prev = new Set<string>(); ··· 41 53 return { 42 54 render(ops: Op[], options?: RenderOptions): RenderResult { 43 55 let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); 44 - native.reduce(statePtr, opsBuf, len); 56 + let mode = options?.mode === "line" ? 1 : 0; 57 + let row = options?.row ?? 1; 58 + native.reduce(statePtr, opsBuf, len, mode, row); 45 59 46 60 if (options?.pointer) { 47 61 let { x, y, down } = options.pointer;
+84
termcodes.ts
··· 1 + /** 2 + * Encode a plain escape sequence. 3 + * 4 + * Prepends `ESC` (`\x1b`) to the given string and returns the result as bytes. 5 + * 6 + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} 7 + */ 8 + export function ESC(str: string): Uint8Array { 9 + return encode(`\x1b${str}`); 10 + } 11 + 12 + /** 13 + * Encode a Control Sequence Introducer (CSI) command. 14 + * 15 + * Prepends `ESC[` to the given string and returns the result as bytes. 16 + * 17 + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} 18 + */ 19 + export function CSI(str: string): Uint8Array { 20 + return ESC(`[${str}`); 21 + } 22 + 23 + /** 24 + * Request the cursor position via Device Status Report (DSR). 25 + * 26 + * Sends `CSI 6n`. The terminal responds with a Cursor Position Report 27 + * (`CSI row ; column R`) where row and column are 1-based. 28 + * 29 + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} 30 + */ 31 + export function DSR(): Uint8Array { 32 + return CSI("6n"); 33 + } 34 + 35 + /** 36 + * Show the cursor (DECTCEM set). 37 + * 38 + * DEC private mode 25. Not part of ECMA-48; originates from the VT220. 39 + * 40 + * @see {@link https://vt100.net/docs/vt510-rm/DECTCEM.html | VT510 DECTCEM} 41 + */ 42 + export function SHOWCURSOR(): Uint8Array { 43 + return CSI("?25h"); 44 + } 45 + 46 + /** 47 + * Hide the cursor (DECTCEM reset). 48 + * 49 + * DEC private mode 25. Not part of ECMA-48; originates from the VT220. 50 + * 51 + * @see {@link https://vt100.net/docs/vt510-rm/DECTCEM.html | VT510 DECTCEM} 52 + */ 53 + export function HIDECURSOR(): Uint8Array { 54 + return CSI("?25l"); 55 + } 56 + 57 + /** 58 + * Switch to the alternate screen buffer (xterm private mode 1049). 59 + * 60 + * Saves the cursor and switches to a clean alternate screen. Use 61 + * {@link MAINSCREEN} to switch back. 62 + * 63 + * @see {@link https://invisible-island.net/xterm/ctlseqs/ctlseqs.html | xterm control sequences} 64 + */ 65 + export function ALTSCREEN(): Uint8Array { 66 + return CSI("?1049h"); 67 + } 68 + 69 + /** 70 + * Switch back to the main screen buffer (xterm private mode 1049). 71 + * 72 + * Restores the cursor and returns to the main screen with scrollback intact. 73 + * 74 + * @see {@link https://invisible-island.net/xterm/ctlseqs/ctlseqs.html | xterm control sequences} 75 + */ 76 + export function MAINSCREEN(): Uint8Array { 77 + return CSI("?1049l"); 78 + } 79 + 80 + const encoder = new TextEncoder(); 81 + 82 + function encode(str: string): Uint8Array { 83 + return encoder.encode(str); 84 + }
+6 -6
test/input.test.ts
··· 687 687 expect(result.events.length).toBe(1); 688 688 expect(result.events[0]).toMatchObject({ 689 689 type: "cursor", 690 - top: 23, 691 - left: 79, 690 + row: 24, 691 + column: 80, 692 692 }); 693 693 }); 694 694 ··· 697 697 expect(result.events.length).toBe(1); 698 698 expect(result.events[0]).toMatchObject({ 699 699 type: "cursor", 700 - top: 0, 701 - left: 0, 700 + row: 1, 701 + column: 1, 702 702 }); 703 703 }); 704 704 ··· 708 708 expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); 709 709 expect(result.events[1]).toMatchObject({ 710 710 type: "cursor", 711 - top: 9, 712 - left: 4, 711 + row: 10, 712 + column: 5, 713 713 }); 714 714 expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); 715 715 });
+4
test/print.ts
··· 38 38 // SGR — ignore 39 39 } 40 40 // ignore all other CSI sequences (?25l, ?25h, etc.) 41 + } else if (ansi[i] === "\n") { 42 + y++; 43 + x = 0; 44 + i++; 41 45 } else { 42 46 // regular character — could be multi-byte UTF-8 43 47 let cp = ansi.codePointAt(i)!;
+65 -3
test/term.test.ts
··· 4 4 import { print } from "./print.ts"; 5 5 6 6 const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); 7 + const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); 7 8 8 9 describe("term", () => { 9 10 let term: Term; ··· 89 90 ╰──────────────────────────────────────╯`.trim()); 90 91 }); 91 92 93 + describe("line mode", () => { 94 + let box = (msg: string) => [ 95 + open("root", { 96 + layout: { width: grow(), height: grow(), direction: "ttb" }, 97 + }), 98 + open("box", { 99 + layout: { 100 + width: grow(), 101 + height: grow(), 102 + direction: "ttb", 103 + padding: { left: 1, top: 1 }, 104 + }, 105 + border: { 106 + color: rgba(255, 255, 255), 107 + left: 1, 108 + right: 1, 109 + top: 1, 110 + bottom: 1, 111 + }, 112 + }), 113 + text(msg), 114 + close(), 115 + close(), 116 + ]; 117 + 118 + it("renders with newlines instead of CUP sequences", async () => { 119 + let term = await createTerm({ width: 20, height: 5 }); 120 + 121 + let out = decode( 122 + term.render(box("hello world"), { mode: "line" }).output, 123 + ); 124 + // deno-lint-ignore no-control-regex 125 + expect(out).not.toMatch(/\x1b\[\d+;\d+H/); 126 + expect(out.split("\n").length).toBe(5); 127 + expect(trim(print(out, 20, 5))).toEqual(` 128 + ┌──────────────────┐ 129 + │hello world │ 130 + │ │ 131 + │ │ 132 + └──────────────────┘`.trim()); 133 + }); 134 + 135 + it("primes front buffer for subsequent diff render", async () => { 136 + let term = await createTerm({ width: 20, height: 5 }); 137 + 138 + let first = decode( 139 + term.render(box("hello world"), { mode: "line" }).output, 140 + ); 141 + let second = decode(term.render(box("goodbye")).output); 142 + 143 + expect(trim(print(first + second, 20, 5))).toEqual(` 144 + ┌──────────────────┐ 145 + │goodbye │ 146 + │ │ 147 + │ │ 148 + └──────────────────┘`.trim()); 149 + 150 + expect(second.length).toBeLessThan(first.length); 151 + }); 152 + }); 153 + 92 154 describe("row offset", () => { 93 155 it("renders two frames at the offset position", async () => { 94 - let term = await createTerm({ width: 20, height: 5, top: 5 }); 156 + let term = await createTerm({ width: 20, height: 5 }); 95 157 let box = (msg: string) => [ 96 158 open("root", { 97 159 layout: { width: grow(), height: grow(), direction: "ttb" }, ··· 119 181 let header = await createTerm({ width: 20, height: 5 }); 120 182 let banner = decode(header.render(box("hello")).output); 121 183 122 - let first = decode(term.render(box("world")).output); 184 + let first = decode(term.render(box("world"), { row: 6 }).output); 123 185 expect(print(banner + first, 20, 10)).toEqual(`\ 124 186 ┌──────────────────┐ 125 187 │hello │ ··· 132 194 │ │ 133 195 └──────────────────┘`); 134 196 135 - let second = decode(term.render(box("universe")).output); 197 + let second = decode(term.render(box("universe"), { row: 6 }).output); 136 198 expect(print(banner + first + second, 20, 10)).toEqual(`\ 137 199 ┌──────────────────┐ 138 200 │hello │