Finally get the terminal size via ansi codes for bun

This commit is contained in:
Timothy Warren 2023-11-10 18:22:09 -05:00
parent 1723219452
commit 61d222a9af
14 changed files with 197 additions and 115 deletions

View File

@ -5,6 +5,9 @@ default:
# Typescript checking # Typescript checking
check: deno-check bun-check check: deno-check bun-check
docs:
deno doc --html --unstable-ffi --private --name="Scroll" ./src/common/mod.ts
# Reformat the code # Reformat the code
fmt: fmt:
deno fmt deno fmt

View File

@ -2,8 +2,26 @@
* The main entrypoint when using Bun as the runtime * 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('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('beforeExit', cb);
process.on('exit', cb);
process.on('SIGINT', cb);
},
exit: (code?: number) => process.exit(code),
}; };
export default BunRuntime;

View File

@ -2,22 +2,7 @@
* Wrap the runtime-specific hook into stdin * Wrap the runtime-specific hook into stdin
*/ */
import { ITerminalIO, ITerminalSize } from '../common/mod.ts'; import { ITerminalIO, ITerminalSize } from '../common/mod.ts';
import Ansi from '../common/editor/ansi.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,
};
}
const BunTerminalIO: ITerminalIO = { const BunTerminalIO: ITerminalIO = {
inputLoop: async function* inputLoop() { inputLoop: async function* inputLoop() {
@ -25,8 +10,40 @@ const BunTerminalIO: ITerminalIO = {
yield chunk; yield chunk;
} }
}, },
getSize: function getSize(): ITerminalSize { getTerminalSize: async function getTerminalSize(): Promise<ITerminalSize> {
return getSizeFromTput(); 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<void> { write: async function write(s: string): Promise<void> {
const buffer = new TextEncoder().encode(s); const buffer = new TextEncoder().encode(s);

View File

@ -1,5 +1,17 @@
export const ANSI_PREFIX = '\x1b[';
function esc(pieces: TemplateStringsArray): string { 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 = { export const Ansi = {
@ -8,9 +20,16 @@ export const Ansi = {
ResetCursor: esc`H`, ResetCursor: esc`H`,
HideCursor: esc`?25l`, HideCursor: esc`?25l`,
ShowCursor: esc`?25h`, ShowCursor: esc`?25h`,
GetCursorLocation: esc`6n`,
moveCursor: function moveCursor(row: number, col: number): string { moveCursor: function moveCursor(row: number, col: number): string {
// Convert to 1-based counting
row++;
col++;
return `\x1b[${row};${col}H`; return `\x1b[${row};${col}H`;
}, },
moveCursorForward: (col: number): string => `${ANSI_PREFIX}${col}C`,
moveCursorDown: (row: number): string => `${ANSI_PREFIX}${row}B`,
}; };
export default Ansi; export default Ansi;

View File

@ -1,4 +1,5 @@
import { strlen } from '../utils.ts'; import { strlen } from '../utils.ts';
import { getRuntime } from '../runtime.ts';
class Buffer { class Buffer {
#b = ''; #b = '';
@ -22,6 +23,12 @@ class Buffer {
return this.#b; return this.#b;
} }
async flush() {
const { io } = await getRuntime();
await io.write(this.#b);
this.clear();
}
strlen(): number { strlen(): number {
return strlen(this.#b); return strlen(this.#b);
} }

View File

@ -1,13 +1,6 @@
import Ansi from './ansi.ts'; import Ansi, { KeyCommand } from './ansi.ts';
import Buffer from './buffer.ts'; import Buffer from './buffer.ts';
import { import { ctrl_key, IPoint, ITerminalSize, truncate, VERSION } from '../mod.ts';
ctrl_key,
importDefaultForRuntime,
IPoint,
ITerminalSize,
truncate,
VERSION,
} from '../mod.ts';
export class Editor { export class Editor {
#buffer: Buffer; #buffer: Buffer;
@ -36,10 +29,10 @@ export class Editor {
this.clearScreen().then(() => {}); this.clearScreen().then(() => {});
return false; return false;
case 'w': case KeyCommand.ArrowUp:
case 's': case KeyCommand.ArrowDown:
case 'a': case KeyCommand.ArrowRight:
case 'd': case KeyCommand.ArrowLeft:
this.moveCursor(input); this.moveCursor(input);
break; break;
} }
@ -49,16 +42,16 @@ export class Editor {
private moveCursor(char: string): void { private moveCursor(char: string): void {
switch (char) { switch (char) {
case 'a': case KeyCommand.ArrowLeft:
this.#cursor.x--; this.#cursor.x--;
break; break;
case 'd': case KeyCommand.ArrowRight:
this.#cursor.x++; this.#cursor.x++;
break; break;
case 'w': case KeyCommand.ArrowUp:
this.#cursor.y--; this.#cursor.y--;
break; break;
case 's': case KeyCommand.ArrowDown:
this.#cursor.y++; this.#cursor.y++;
break; break;
} }
@ -76,18 +69,18 @@ export class Editor {
this.#buffer.append(Ansi.ResetCursor); this.#buffer.append(Ansi.ResetCursor);
this.drawRows(); this.drawRows();
this.#buffer.append( 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); this.#buffer.append(Ansi.ShowCursor);
await this.writeToScreen(); await this.#buffer.flush();
} }
private async clearScreen(): Promise<void> { private async clearScreen(): Promise<void> {
this.#buffer.append(Ansi.ClearScreen); this.#buffer.append(Ansi.ClearScreen);
this.#buffer.append(Ansi.ResetCursor); this.#buffer.append(Ansi.ResetCursor);
await this.writeToScreen(); await this.#buffer.flush();
} }
private drawRows(): void { private drawRows(): void {
@ -120,11 +113,4 @@ export class Editor {
} }
} }
} }
private async writeToScreen(): Promise<void> {
const io = await importDefaultForRuntime('terminal_io');
await io.write(this.#buffer.getBuffer());
this.#buffer.clear();
}
} }

View File

@ -2,7 +2,7 @@ export * from './editor/mod.ts';
export * from './runtime.ts'; export * from './runtime.ts';
export * from './termios.ts'; export * from './termios.ts';
export * from './utils.ts'; export * from './utils.ts';
export * from './types.ts';
export type * from './types.ts'; export type * from './types.ts';
export const VERSION = '0.0.1'; export const VERSION = '0.0.1';

View File

@ -1,31 +1,47 @@
import { getTermios } from './termios.ts'; import { getTermios } from './termios.ts';
import { IRuntime, RunTimeType } from './types.ts';
export enum RunTime { let scrollRuntime: IRuntime | null = null;
Bun = 'bun',
Deno = 'deno',
Unknown = 'common',
}
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<void> {
(await getTermios()).disableRawMode();
console.error(s); console.error(s);
(await getRuntime()).exit();
} }
/** /**
* Determine which Typescript runtime we are operating under * Determine which Typescript runtime we are operating under
*/ */
export const getRuntime = (): RunTime => { export function getRuntimeType(): RunTimeType {
let runtime = RunTime.Unknown; let runtime = RunTimeType.Unknown;
if ('Deno' in globalThis) { if ('Deno' in globalThis) {
runtime = RunTime.Deno; runtime = RunTimeType.Deno;
} }
if ('Bun' in globalThis) { if ('Bun' in globalThis) {
runtime = RunTime.Bun; runtime = RunTimeType.Bun;
} }
return runtime; return runtime;
}; }
export async function getRuntime(): Promise<IRuntime> {
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 * Import a runtime-specific module
@ -36,7 +52,7 @@ export const getRuntime = (): RunTime => {
* @param path - the path within the runtime module * @param path - the path within the runtime module
*/ */
export const importForRuntime = async (path: string) => { export const importForRuntime = async (path: string) => {
const runtime = getRuntime(); const runtime = getRuntimeType();
const suffix = '.ts'; const suffix = '.ts';
const base = `../${runtime}/`; const base = `../${runtime}/`;

View File

@ -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 STDIN_FILENO = 0;
export const TCSANOW = 0; export const TCSANOW = 0;
@ -63,7 +63,7 @@ class Termios {
// Get the current termios settings // Get the current termios settings
let res = this.#ffi.tcgetattr(STDIN_FILENO, this.#ptr); let res = this.#ffi.tcgetattr(STDIN_FILENO, this.#ptr);
if (res === -1) { 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 // The #ptr property is pointing to the #termios TypedArray. As the pointer
@ -80,7 +80,9 @@ class Termios {
// Actually set the new termios settings // Actually set the new termios settings
res = this.#ffi.tcsetattr(STDIN_FILENO, TCSANOW, this.#ptr); res = this.#ffi.tcsetattr(STDIN_FILENO, TCSANOW, this.#ptr);
if (res === -1) { 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; this.#inRawMode = true;
@ -96,7 +98,7 @@ class Termios {
const oldTermiosPtr = this.#ffi.getPointer(this.#cookedTermios); const oldTermiosPtr = this.#ffi.getPointer(this.#cookedTermios);
const res = this.#ffi.tcsetattr(STDIN_FILENO, TCSANOW, oldTermiosPtr); const res = this.#ffi.tcsetattr(STDIN_FILENO, TCSANOW, oldTermiosPtr);
if (res === -1) { if (res === -1) {
die('Failed to restore canonical mode.'); die('Failed to restore canonical mode.').then(() => {});
} }
this.#inRawMode = false; this.#inRawMode = false;
@ -111,7 +113,7 @@ export const getTermios = async () => {
} }
// Get the runtime-specific ffi wrappers // Get the runtime-specific ffi wrappers
const ffi: IFFI = await importDefaultForRuntime('ffi'); const { ffi } = await getRuntime();
termiosSingleton = new Termios(ffi); termiosSingleton = new Termios(ffi);
return termiosSingleton; return termiosSingleton;

View File

@ -10,6 +10,12 @@ export interface IPoint {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Runtime adapter interfaces // Runtime adapter interfaces
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
export enum RunTimeType {
Bun = 'bun',
Deno = 'deno',
Unknown = 'common',
}
/** /**
* The native functions for terminal settings * The native functions for terminal settings
*/ */
@ -52,7 +58,7 @@ export interface ITerminalIO {
/** /**
* Get the size of the terminal * Get the size of the terminal
*/ */
getSize(): ITerminalSize; getTerminalSize(): Promise<ITerminalSize>;
/** /**
* Pipe a string to stdout * Pipe a string to stdout
@ -61,11 +67,31 @@ export interface ITerminalIO {
} }
export interface IRuntime { 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 * Set a beforeExit/beforeUnload event handler for the runtime
* @param cb - The event handler * @param cb - The event handler
*/ */
onExit(cb: () => void): void; onExit(cb: () => void): void;
/**
* Stop execution
*
* @param code
*/
exit(code?: number): void;
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@ -55,11 +55,7 @@ export function is_control(char: string): boolean {
export function ctrl_key(char: string): string { export function ctrl_key(char: string): string {
// This is the normal use case, of course // This is the normal use case, of course
if (is_ascii(char)) { if (is_ascii(char)) {
const point = char.codePointAt(0); const point = char.codePointAt(0)!;
if (point === undefined) {
return char;
}
return String.fromCodePoint(point & 0x1f); return String.fromCodePoint(point & 0x1f);
} }

View File

@ -1,16 +1,19 @@
/** /**
* The main entrypoint when using Deno as the runtime * The main entrypoint when using Deno as the runtime
*/ */
import { IRuntime } from '../common/types.ts'; import { IRuntime, RunTimeType } from '../common/mod.ts';
import DenoFFI from './ffi.ts';
export * from './terminal_io.ts'; import DenoTerminalIO from './terminal_io.ts';
export const onExit = (cb: () => void): void => {
globalThis.addEventListener('onbeforeunload', cb);
};
const DenoRuntime: IRuntime = { 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; export default DenoRuntime;

View File

@ -4,29 +4,27 @@ const DenoTerminalIO: ITerminalIO = {
/** /**
* Wrap the runtime-specific hook into stdin * Wrap the runtime-specific hook into stdin
*/ */
inputLoop: async function* inputLoop(): AsyncGenerator< inputLoop: async function* inputLoop() {
Uint8Array,
void,
unknown
> {
for await (const chunk of Deno.stdin.readable) { for await (const chunk of Deno.stdin.readable) {
yield chunk; yield chunk;
} }
}, },
getSize: function getSize(): ITerminalSize { getTerminalSize: function getSize(): Promise<ITerminalSize> {
const size: { rows: number; columns: number } = Deno.consoleSize(); const size: { rows: number; columns: number } = Deno.consoleSize();
return { return Promise.resolve({
rows: size.rows, rows: size.rows,
cols: size.columns, cols: size.columns,
}; });
}, },
write: async function write(s: string): Promise<void> { write: async function write(s: string): Promise<void> {
const buffer = new TextEncoder().encode(s); const buffer: Uint8Array = new TextEncoder().encode(s);
const stdout: WritableStream<Uint8Array> = Deno.stdout.writable;
const stdout = Deno.stdout.writable.getWriter(); const writer: WritableStreamDefaultWriter<Uint8Array> = stdout.getWriter();
await stdout.write(buffer); await writer.ready;
stdout.releaseLock(); await writer.write(buffer);
writer.releaseLock();
}, },
}; };

View File

@ -1,47 +1,38 @@
/** /**
* The starting point for running scroll * The starting point for running scroll
*/ */
import { import { Editor, getRuntime, getTermios } from './common/mod.ts';
Editor,
getTermios,
importDefaultForRuntime,
importForRuntime,
} from './common/mod.ts';
const decoder = new TextDecoder(); const decoder = new TextDecoder();
export async function main() { export async function main() {
const { inputLoop, getSize } = await importDefaultForRuntime( const runTime = await getRuntime();
'terminal_io.ts', const { io, onExit } = runTime;
);
const { onExit } = await importForRuntime('mod.ts');
// Setup raw mode, and tear down on error or normal exit // Setup raw mode, and tear down on error or normal exit
const t = await getTermios(); const t = await getTermios();
t.enableRawMode(); t.enableRawMode();
onExit(() => { onExit(() => {
console.log('Exit handler called, disabling raw mode\r\n');
t.disableRawMode(); t.disableRawMode();
}); });
const terminalSize = await io.getTerminalSize();
// Create the editor itself // Create the editor itself
const terminalSize = getSize();
const editor = new Editor(terminalSize); const editor = new Editor(terminalSize);
await editor.refreshScreen(); await editor.refreshScreen();
// The main event loop // The main event loop
for await (const chunk of inputLoop()) { for await (const chunk of io.inputLoop()) {
const char = String(decoder.decode(chunk));
// Clear the screen for output
await editor.refreshScreen();
// Process input // Process input
const char = String(decoder.decode(chunk));
const shouldLoop = editor.processKeyPress(char); const shouldLoop = editor.processKeyPress(char);
if (!shouldLoop) { if (!shouldLoop) {
return 0; return 0;
} }
// Render output
await editor.refreshScreen();
} }
return -1; return -1;