diff --git a/justfile b/justfile index 939cb65..eafedf4 100644 --- a/justfile +++ b/justfile @@ -5,6 +5,9 @@ default: # Typescript checking check: deno-check bun-check +docs: + deno doc --html --unstable-ffi --private --name="Scroll" ./src/common/mod.ts + # Reformat the code fmt: deno fmt diff --git a/src/bun/mod.ts b/src/bun/mod.ts index e7382dc..e77d475 100644 --- a/src/bun/mod.ts +++ b/src/bun/mod.ts @@ -2,8 +2,26 @@ * The main entrypoint when using Bun as the runtime */ -export * from './terminal_io.ts'; +import { getTermios, IRuntime, RunTimeType } from '../common/mod.ts'; +import BunFFI from './ffi.ts'; +import BunTerminalIO from './terminal_io.ts'; -export const onExit = (cb: () => void): void => { - process.on('beforeExit', cb); +process.on('error', async (e) => { + (await getTermios()).disableRawMode(); + console.error(e); + process.exit(); +}); + +const BunRuntime: IRuntime = { + name: RunTimeType.Bun, + ffi: BunFFI, + io: BunTerminalIO, + onExit: (cb: () => void): void => { + process.on('beforeExit', cb); + process.on('exit', cb); + process.on('SIGINT', cb); + }, + exit: (code?: number) => process.exit(code), }; + +export default BunRuntime; diff --git a/src/bun/terminal_io.ts b/src/bun/terminal_io.ts index 7da04ba..933f2a7 100644 --- a/src/bun/terminal_io.ts +++ b/src/bun/terminal_io.ts @@ -2,22 +2,7 @@ * Wrap the runtime-specific hook into stdin */ import { ITerminalIO, ITerminalSize } from '../common/mod.ts'; - -function getSizeFromTput(): ITerminalSize { - const rows = parseInt( - Bun.spawnSync(['tput', 'lines']).stdout.toString().trim(), - 10, - ); - const cols = parseInt( - Bun.spawnSync(['tput', 'cols']).stdout.toString().trim(), - 10, - ); - - return { - rows: (rows > 0) ? rows + 1 : 25, - cols: (cols > 0) ? cols + 1 : 80, - }; -} +import Ansi from '../common/editor/ansi.ts'; const BunTerminalIO: ITerminalIO = { inputLoop: async function* inputLoop() { @@ -25,8 +10,40 @@ const BunTerminalIO: ITerminalIO = { yield chunk; } }, - getSize: function getSize(): ITerminalSize { - return getSizeFromTput(); + getTerminalSize: async function getTerminalSize(): Promise { + const encoder = new TextEncoder(); + const write = (s: string) => Bun.write(Bun.stdout, encoder.encode(s)); + + // Tell the cursor to move to Row 999 and Column 999 + // Since this command specifically doesn't go off the screen + // When we ask where the cursor is, we should get the size of the screen + await write(Ansi.moveCursorForward(999) + Ansi.moveCursorDown(999)); + + // Ask where the cursor is + await write(Ansi.GetCursorLocation); + + // Get the first chunk from stdin + // The response is \x1b[(rows);(cols)R.. + for await (const chunk of Bun.stdin.stream()) { + const rawCode = (new TextDecoder()).decode(chunk); + const res = rawCode.trim().replace(/^.\[([0-9]+;[0-9]+)R$/, '$1'); + const [srows, scols] = res.split(';'); + const rows = parseInt(srows, 10); + const cols = parseInt(scols, 10); + + // Clear the screen + await write(Ansi.ClearScreen + Ansi.ResetCursor); + + return { + rows, + cols, + }; + } + + return { + rows: 24, + cols: 80, + }; }, write: async function write(s: string): Promise { const buffer = new TextEncoder().encode(s); diff --git a/src/common/editor/ansi.ts b/src/common/editor/ansi.ts index 0df5304..5520a32 100644 --- a/src/common/editor/ansi.ts +++ b/src/common/editor/ansi.ts @@ -1,5 +1,17 @@ +export const ANSI_PREFIX = '\x1b['; + function esc(pieces: TemplateStringsArray): string { - return '\x1b[' + pieces[0]; + return ANSI_PREFIX + pieces[0]; +} + +/** + * ANSI escapes for various inputs + */ +export enum KeyCommand { + ArrowUp = `${ANSI_PREFIX}A`, + ArrowDown = `${ANSI_PREFIX}B`, + ArrowRight = `${ANSI_PREFIX}C`, + ArrowLeft = `${ANSI_PREFIX}D`, } export const Ansi = { @@ -8,9 +20,16 @@ export const Ansi = { ResetCursor: esc`H`, HideCursor: esc`?25l`, ShowCursor: esc`?25h`, + GetCursorLocation: esc`6n`, moveCursor: function moveCursor(row: number, col: number): string { + // Convert to 1-based counting + row++; + col++; + return `\x1b[${row};${col}H`; }, + moveCursorForward: (col: number): string => `${ANSI_PREFIX}${col}C`, + moveCursorDown: (row: number): string => `${ANSI_PREFIX}${row}B`, }; export default Ansi; diff --git a/src/common/editor/buffer.ts b/src/common/editor/buffer.ts index b57b921..061a126 100644 --- a/src/common/editor/buffer.ts +++ b/src/common/editor/buffer.ts @@ -1,4 +1,5 @@ import { strlen } from '../utils.ts'; +import { getRuntime } from '../runtime.ts'; class Buffer { #b = ''; @@ -22,6 +23,12 @@ class Buffer { return this.#b; } + async flush() { + const { io } = await getRuntime(); + await io.write(this.#b); + this.clear(); + } + strlen(): number { return strlen(this.#b); } diff --git a/src/common/editor/editor.ts b/src/common/editor/editor.ts index fa2ad77..da5c76e 100644 --- a/src/common/editor/editor.ts +++ b/src/common/editor/editor.ts @@ -1,13 +1,6 @@ -import Ansi from './ansi.ts'; +import Ansi, { KeyCommand } from './ansi.ts'; import Buffer from './buffer.ts'; -import { - ctrl_key, - importDefaultForRuntime, - IPoint, - ITerminalSize, - truncate, - VERSION, -} from '../mod.ts'; +import { ctrl_key, IPoint, ITerminalSize, truncate, VERSION } from '../mod.ts'; export class Editor { #buffer: Buffer; @@ -36,12 +29,12 @@ export class Editor { this.clearScreen().then(() => {}); return false; - case 'w': - case 's': - case 'a': - case 'd': - this.moveCursor(input); - break; + case KeyCommand.ArrowUp: + case KeyCommand.ArrowDown: + case KeyCommand.ArrowRight: + case KeyCommand.ArrowLeft: + this.moveCursor(input); + break; } return true; @@ -49,16 +42,16 @@ export class Editor { private moveCursor(char: string): void { switch (char) { - case 'a': + case KeyCommand.ArrowLeft: this.#cursor.x--; break; - case 'd': + case KeyCommand.ArrowRight: this.#cursor.x++; break; - case 'w': + case KeyCommand.ArrowUp: this.#cursor.y--; break; - case 's': + case KeyCommand.ArrowDown: this.#cursor.y++; break; } @@ -76,18 +69,18 @@ export class Editor { this.#buffer.append(Ansi.ResetCursor); this.drawRows(); this.#buffer.append( - Ansi.moveCursor(this.#cursor.y + 1, this.#cursor.x + 1), + Ansi.moveCursor(this.#cursor.y, this.#cursor.x), ); this.#buffer.append(Ansi.ShowCursor); - await this.writeToScreen(); + await this.#buffer.flush(); } private async clearScreen(): Promise { this.#buffer.append(Ansi.ClearScreen); this.#buffer.append(Ansi.ResetCursor); - await this.writeToScreen(); + await this.#buffer.flush(); } private drawRows(): void { @@ -120,11 +113,4 @@ export class Editor { } } } - - private async writeToScreen(): Promise { - const io = await importDefaultForRuntime('terminal_io'); - - await io.write(this.#buffer.getBuffer()); - this.#buffer.clear(); - } } diff --git a/src/common/mod.ts b/src/common/mod.ts index 6387563..f60d4b0 100644 --- a/src/common/mod.ts +++ b/src/common/mod.ts @@ -2,7 +2,7 @@ export * from './editor/mod.ts'; export * from './runtime.ts'; export * from './termios.ts'; export * from './utils.ts'; - +export * from './types.ts'; export type * from './types.ts'; export const VERSION = '0.0.1'; diff --git a/src/common/runtime.ts b/src/common/runtime.ts index a853207..f623142 100644 --- a/src/common/runtime.ts +++ b/src/common/runtime.ts @@ -1,31 +1,47 @@ import { getTermios } from './termios.ts'; +import { IRuntime, RunTimeType } from './types.ts'; -export enum RunTime { - Bun = 'bun', - Deno = 'deno', - Unknown = 'common', -} +let scrollRuntime: IRuntime | null = null; -export function die(s: string): void { - getTermios().then((t) => t.disableRawMode()); +/** + * Kill program, displaying an error message + * @param s + */ +export async function die(s: string | Error): Promise { + (await getTermios()).disableRawMode(); console.error(s); + (await getRuntime()).exit(); } /** * Determine which Typescript runtime we are operating under */ -export const getRuntime = (): RunTime => { - let runtime = RunTime.Unknown; +export function getRuntimeType(): RunTimeType { + let runtime = RunTimeType.Unknown; if ('Deno' in globalThis) { - runtime = RunTime.Deno; + runtime = RunTimeType.Deno; } if ('Bun' in globalThis) { - runtime = RunTime.Bun; + runtime = RunTimeType.Bun; } return runtime; -}; +} + +export async function getRuntime(): Promise { + if (scrollRuntime === null) { + const runtime = getRuntimeType(); + const path = `../${runtime}/mod.ts`; + + const pkg = await import(path); + if ('default' in pkg) { + scrollRuntime = pkg.default; + } + } + + return Promise.resolve(scrollRuntime!); +} /** * Import a runtime-specific module @@ -36,7 +52,7 @@ export const getRuntime = (): RunTime => { * @param path - the path within the runtime module */ export const importForRuntime = async (path: string) => { - const runtime = getRuntime(); + const runtime = getRuntimeType(); const suffix = '.ts'; const base = `../${runtime}/`; diff --git a/src/common/termios.ts b/src/common/termios.ts index 2f95940..bb91219 100644 --- a/src/common/termios.ts +++ b/src/common/termios.ts @@ -1,4 +1,4 @@ -import { die, IFFI, importDefaultForRuntime } from './mod.ts'; +import { die, getRuntime, IFFI } from './mod.ts'; export const STDIN_FILENO = 0; export const TCSANOW = 0; @@ -63,7 +63,7 @@ class Termios { // Get the current termios settings let res = this.#ffi.tcgetattr(STDIN_FILENO, this.#ptr); if (res === -1) { - die('Failed to get terminal settings'); + die('Failed to get terminal settings').then(() => {}); } // The #ptr property is pointing to the #termios TypedArray. As the pointer @@ -80,7 +80,9 @@ class Termios { // Actually set the new termios settings res = this.#ffi.tcsetattr(STDIN_FILENO, TCSANOW, this.#ptr); if (res === -1) { - die('Failed to update terminal settings. Can\'t enter raw mode'); + die('Failed to update terminal settings. Can\'t enter raw mode').then( + () => {}, + ); } this.#inRawMode = true; @@ -96,7 +98,7 @@ class Termios { const oldTermiosPtr = this.#ffi.getPointer(this.#cookedTermios); const res = this.#ffi.tcsetattr(STDIN_FILENO, TCSANOW, oldTermiosPtr); if (res === -1) { - die('Failed to restore canonical mode.'); + die('Failed to restore canonical mode.').then(() => {}); } this.#inRawMode = false; @@ -111,7 +113,7 @@ export const getTermios = async () => { } // Get the runtime-specific ffi wrappers - const ffi: IFFI = await importDefaultForRuntime('ffi'); + const { ffi } = await getRuntime(); termiosSingleton = new Termios(ffi); return termiosSingleton; diff --git a/src/common/types.ts b/src/common/types.ts index eb085fc..aa52d02 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -10,6 +10,12 @@ export interface IPoint { // ---------------------------------------------------------------------------- // Runtime adapter interfaces // ---------------------------------------------------------------------------- +export enum RunTimeType { + Bun = 'bun', + Deno = 'deno', + Unknown = 'common', +} + /** * The native functions for terminal settings */ @@ -52,7 +58,7 @@ export interface ITerminalIO { /** * Get the size of the terminal */ - getSize(): ITerminalSize; + getTerminalSize(): Promise; /** * Pipe a string to stdout @@ -61,11 +67,31 @@ export interface ITerminalIO { } export interface IRuntime { + /** + * The name of the runtime + */ + name: RunTimeType; + /** + * Runtime-specific FFI + */ + ffi: IFFI; + /** + * Runtime-specific terminal functionality + */ + io: ITerminalIO; + /** * Set a beforeExit/beforeUnload event handler for the runtime * @param cb - The event handler */ onExit(cb: () => void): void; + + /** + * Stop execution + * + * @param code + */ + exit(code?: number): void; } // ---------------------------------------------------------------------------- diff --git a/src/common/utils.ts b/src/common/utils.ts index a8e3138..643c1a9 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -55,11 +55,7 @@ export function is_control(char: string): boolean { export function ctrl_key(char: string): string { // This is the normal use case, of course if (is_ascii(char)) { - const point = char.codePointAt(0); - if (point === undefined) { - return char; - } - + const point = char.codePointAt(0)!; return String.fromCodePoint(point & 0x1f); } diff --git a/src/deno/mod.ts b/src/deno/mod.ts index fc403f2..18e3959 100644 --- a/src/deno/mod.ts +++ b/src/deno/mod.ts @@ -1,16 +1,19 @@ /** * The main entrypoint when using Deno as the runtime */ -import { IRuntime } from '../common/types.ts'; - -export * from './terminal_io.ts'; - -export const onExit = (cb: () => void): void => { - globalThis.addEventListener('onbeforeunload', cb); -}; +import { IRuntime, RunTimeType } from '../common/mod.ts'; +import DenoFFI from './ffi.ts'; +import DenoTerminalIO from './terminal_io.ts'; const DenoRuntime: IRuntime = { - onExit, + name: RunTimeType.Deno, + ffi: DenoFFI, + io: DenoTerminalIO, + onExit: (cb: () => void): void => { + globalThis.addEventListener('onbeforeunload', cb); + globalThis.onbeforeunload = cb; + }, + exit: (code?: number) => Deno.exit(code), }; export default DenoRuntime; diff --git a/src/deno/terminal_io.ts b/src/deno/terminal_io.ts index 572e319..2513882 100644 --- a/src/deno/terminal_io.ts +++ b/src/deno/terminal_io.ts @@ -4,29 +4,27 @@ const DenoTerminalIO: ITerminalIO = { /** * Wrap the runtime-specific hook into stdin */ - inputLoop: async function* inputLoop(): AsyncGenerator< - Uint8Array, - void, - unknown - > { + inputLoop: async function* inputLoop() { for await (const chunk of Deno.stdin.readable) { yield chunk; } }, - getSize: function getSize(): ITerminalSize { + getTerminalSize: function getSize(): Promise { const size: { rows: number; columns: number } = Deno.consoleSize(); - return { + return Promise.resolve({ rows: size.rows, cols: size.columns, - }; + }); }, write: async function write(s: string): Promise { - const buffer = new TextEncoder().encode(s); + const buffer: Uint8Array = new TextEncoder().encode(s); + const stdout: WritableStream = Deno.stdout.writable; - const stdout = Deno.stdout.writable.getWriter(); - await stdout.write(buffer); - stdout.releaseLock(); + const writer: WritableStreamDefaultWriter = stdout.getWriter(); + await writer.ready; + await writer.write(buffer); + writer.releaseLock(); }, }; diff --git a/src/scroll.ts b/src/scroll.ts index 3695c11..9c6f5de 100644 --- a/src/scroll.ts +++ b/src/scroll.ts @@ -1,47 +1,38 @@ /** * The starting point for running scroll */ -import { - Editor, - getTermios, - importDefaultForRuntime, - importForRuntime, -} from './common/mod.ts'; +import { Editor, getRuntime, getTermios } from './common/mod.ts'; const decoder = new TextDecoder(); export async function main() { - const { inputLoop, getSize } = await importDefaultForRuntime( - 'terminal_io.ts', - ); - const { onExit } = await importForRuntime('mod.ts'); + const runTime = await getRuntime(); + const { io, onExit } = runTime; // Setup raw mode, and tear down on error or normal exit const t = await getTermios(); t.enableRawMode(); onExit(() => { - console.log('Exit handler called, disabling raw mode\r\n'); t.disableRawMode(); }); + const terminalSize = await io.getTerminalSize(); + // Create the editor itself - const terminalSize = getSize(); const editor = new Editor(terminalSize); await editor.refreshScreen(); // The main event loop - for await (const chunk of inputLoop()) { - const char = String(decoder.decode(chunk)); - - // Clear the screen for output - await editor.refreshScreen(); - + for await (const chunk of io.inputLoop()) { // Process input + const char = String(decoder.decode(chunk)); const shouldLoop = editor.processKeyPress(char); - if (!shouldLoop) { return 0; } + + // Render output + await editor.refreshScreen(); } return -1;