From 7b801bb8a3bd1a4bcd127885ed016cc14a013edb Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 10 Apr 2026 14:58:09 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20termcodes=20module=20and=20ma?= =?UTF-8?q?ke=20CursorEvent=201-based?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename csi.ts to termcodes.ts and add ESC(), SHOWCURSOR(), HIDECURSOR(), ALTSCREEN(), and MAINSCREEN() helpers. Make CursorEvent.row/column 1-based to match DSR native format. Replace all raw escape sequences in the demo with termcodes. Use DECSC/DECRC (ESC 7/8) for cursor save/restore instead of SCO (CSI s/u). --- demo/inline-region.ts | 50 ++++++++++++++------------ input.ts | 10 +++--- mod.ts | 2 ++ settings.ts | 52 ++++++++++++++++----------- src/input.c | 4 +-- termcodes.ts | 84 +++++++++++++++++++++++++++++++++++++++++++ test/input.test.ts | 12 +++---- 7 files changed, 159 insertions(+), 55 deletions(-) create mode 100644 termcodes.ts diff --git a/demo/inline-region.ts b/demo/inline-region.ts index 48eb7d2..53720b9 100644 --- a/demo/inline-region.ts +++ b/demo/inline-region.ts @@ -13,20 +13,23 @@ 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"; -let write = (b: Uint8Array) => Deno.stdout.writeSync(b); let encode = (s: string) => new TextEncoder().encode(s); -let esc = (s: string) => write(encode(s)); +let write = (b: Uint8Array) => Deno.stdout.writeSync(b); let GREEN = rgba(80, 250, 123); let GRAY = rgba(100, 100, 100); @@ -44,7 +47,7 @@ let BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", " function* queryCursor(): Operation { let parser = yield* until(createInput({ escLatency: 100 })); - esc("\x1b[6n"); + write(DSR()); let buf = new Uint8Array(32); while (true) { @@ -67,7 +70,7 @@ function waitKey() { for (let i = 0; i < n; i++) { if (buf[i] === 0x03) { Deno.stdin.setRaw(false); - esc("\x1b[?25h"); + write(SHOWCURSOR()); Deno.exit(0); } } @@ -111,13 +114,13 @@ function* transaction( ): Operation { let { columns } = Deno.consoleSize(); - esc("\n".repeat(height)); + write(encode("\n".repeat(height))); let pos = yield* queryCursor(); /** 1-based terminal row where the region starts */ - let row = pos.top - height + 1; + let row = pos.row - height + 1; - esc("\x1b[s"); + write(ESC("7")); let tty = settings(cursor(false)); write(tty.apply); @@ -131,17 +134,17 @@ function* transaction( } write(tty.revert); - esc("\x1b[u"); - esc("\n"); + write(ESC("8")); + write(encode("\n")); } function say(msg: string) { - esc(msg + "\n"); + write(encode(msg + "\n")); } function pause() { waitKey(); - esc("\n"); + write(encode("\n")); } await main(function* () { @@ -157,13 +160,13 @@ await main(function* () { say(""); // Demo 1: Spinner box - esc("\n\n\n"); + write(encode("\n\n\n")); let pos = yield* queryCursor(); /** 1-based terminal row where the region starts */ - let row = pos.top - 2; + let row = pos.row - 2; - esc("\x1b[s"); + write(ESC("7")); let frames = 30; let term = validated( @@ -195,14 +198,16 @@ await main(function* () { yield* sleep(80); } - esc("\x1b[u"); - esc("\x1b[0m"); - esc("\n"); + write(ESC("8")); + write(CSI("0m")); + write(encode("\n")); yield* sleep(500); - esc( - "\nRegions can be multi-line, but they can be a single line too. (continue...)", + write( + encode( + "\nRegions can be multi-line, but they can be a single line too. (continue...)", + ), ); pause(); @@ -251,9 +256,9 @@ await main(function* () { 50, ); - esc("\x1b[0m"); + write(CSI("0m")); yield* sleep(500); - esc("\nGoodbye sadness with limitless sky. (continue...)"); + write(encode("\nGoodbye sadness with limitless sky. (continue...)")); pause(); // Demo 3: Nyan cat @@ -331,7 +336,8 @@ await main(function* () { 60, ); - esc("\x1b[0m\n"); + write(CSI("0m")); + write(encode("\n")); write(tty.revert); Deno.stdin.setRaw(false); }); 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("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/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" }); }); -- 2.51.2