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
check: deno-check bun-check
docs:
deno doc --html --unstable-ffi --private --name="Scroll" ./src/common/mod.ts
# Reformat the code
fmt:
deno fmt

View File

@ -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;

View File

@ -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<ITerminalSize> {
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> {
const buffer = new TextEncoder().encode(s);

View File

@ -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;

View File

@ -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);
}

View File

@ -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<void> {
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<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 './termios.ts';
export * from './utils.ts';
export * from './types.ts';
export type * from './types.ts';
export const VERSION = '0.0.1';

View File

@ -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<void> {
(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<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
@ -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}/`;

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 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;

View File

@ -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<ITerminalSize>;
/**
* 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;
}
// ----------------------------------------------------------------------------

View File

@ -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);
}

View File

@ -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;

View File

@ -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<ITerminalSize> {
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<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();
await stdout.write(buffer);
stdout.releaseLock();
const writer: WritableStreamDefaultWriter<Uint8Array> = stdout.getWriter();
await writer.ready;
await writer.write(buffer);
writer.releaseLock();
},
};

View File

@ -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;