Minor refactoring, build up the ansi escape codes to handle colors

This commit is contained in:
Timothy Warren 2024-01-10 15:44:19 -05:00
parent 30230520a0
commit 15496646d6
7 changed files with 176 additions and 84 deletions

View File

@ -18,13 +18,17 @@ const BunTerminalIO: ITerminal = {
return null; return null;
}, },
/**
* Get the size of the terminal window via ANSI codes
* @see https://viewsourcecode.org/snaptoken/kilo/03.rawInputAndOutput.html#window-size-the-hard-way
*/
getTerminalSize: async function getTerminalSize(): Promise<ITerminalSize> { getTerminalSize: async function getTerminalSize(): Promise<ITerminalSize> {
const encoder = new TextEncoder();
// Tell the cursor to move to Row 999 and Column 999 // Tell the cursor to move to Row 999 and Column 999
// Since this command specifically doesn't go off the screen // 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 // When we ask where the cursor is, we should get the size of the screen
await BunTerminalIO.writeStdout(Ansi.moveCursorForward(999) + Ansi.moveCursorDown(999)); await BunTerminalIO.writeStdout(
Ansi.moveCursorForward(999) + Ansi.moveCursorDown(999),
);
// Ask where the cursor is // Ask where the cursor is
await BunTerminalIO.writeStdout(Ansi.GetCursorLocation); await BunTerminalIO.writeStdout(Ansi.GetCursorLocation);

View File

@ -6,7 +6,7 @@ import { Ansi, KeyCommand } from './ansi.ts';
import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts'; import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts';
import { getTestRunner } from './runtime.ts'; import { getTestRunner } from './runtime.ts';
import { Position } from './types.ts'; import { Position } from './types.ts';
import * as Util from './fns.ts'; import * as Fn from './fns.ts';
const { const {
assertEquals, assertEquals,
@ -22,7 +22,7 @@ const {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const testKeyMap = (codes: string[], expected: string) => { const testKeyMap = (codes: string[], expected: string) => {
codes.forEach((code) => { codes.forEach((code) => {
assertEquals(Util.readKey(encoder.encode(code)), expected); assertEquals(Fn.readKey(encoder.encode(code)), expected);
}); });
}; };
@ -192,7 +192,7 @@ testSuite({
assertEquals(row.byteIndexToCharIndex(0), 0); assertEquals(row.byteIndexToCharIndex(0), 0);
// Return count on nonsense index // Return count on nonsense index
assertEquals(Util.strlen(row.toString()), 10); assertEquals(Fn.strlen(row.toString()), 10);
assertEquals(row.byteIndexToCharIndex(72), 10); assertEquals(row.byteIndexToCharIndex(72), 10);
const row2 = Row.from('foobar'); const row2 = Row.from('foobar');
@ -224,121 +224,121 @@ testSuite({
'fns': { 'fns': {
'arrayInsert() strings': () => { 'arrayInsert() strings': () => {
const a = ['😺', '😸', '😹']; const a = ['😺', '😸', '😹'];
const b = Util.arrayInsert(a, 1, 'x'); const b = Fn.arrayInsert(a, 1, 'x');
const c = ['😺', 'x', '😸', '😹']; const c = ['😺', 'x', '😸', '😹'];
assertEquals(b, c); assertEquals(b, c);
const d = Util.arrayInsert(c, 17, 'y'); const d = Fn.arrayInsert(c, 17, 'y');
const e = ['😺', 'x', '😸', '😹', 'y']; const e = ['😺', 'x', '😸', '😹', 'y'];
assertEquals(d, e); assertEquals(d, e);
assertEquals(Util.arrayInsert([], 0, 'foo'), ['foo']); assertEquals(Fn.arrayInsert([], 0, 'foo'), ['foo']);
}, },
'arrayInsert() numbers': () => { 'arrayInsert() numbers': () => {
const a = [1, 3, 5]; const a = [1, 3, 5];
const b = [1, 3, 4, 5]; const b = [1, 3, 4, 5];
assertEquals(Util.arrayInsert(a, 2, 4), b); assertEquals(Fn.arrayInsert(a, 2, 4), b);
const c = [1, 2, 3, 4, 5]; const c = [1, 2, 3, 4, 5];
assertEquals(Util.arrayInsert(b, 1, 2), c); assertEquals(Fn.arrayInsert(b, 1, 2), c);
}, },
'noop fn': () => { 'noop fn': () => {
assertExists(Util.noop); assertExists(Fn.noop);
assertEquals(Util.noop(), undefined); assertEquals(Fn.noop(), undefined);
}, },
'posSub()': () => { 'posSub()': () => {
assertEquals(Util.posSub(14, 15), 0); assertEquals(Fn.posSub(14, 15), 0);
assertEquals(Util.posSub(15, 1), 14); assertEquals(Fn.posSub(15, 1), 14);
}, },
'minSub()': () => { 'minSub()': () => {
assertEquals(Util.minSub(13, 25, -1), -1); assertEquals(Fn.minSub(13, 25, -1), -1);
assertEquals(Util.minSub(25, 13, 0), 12); assertEquals(Fn.minSub(25, 13, 0), 12);
}, },
'maxAdd()': () => { 'maxAdd()': () => {
assertEquals(Util.maxAdd(99, 99, 75), 75); assertEquals(Fn.maxAdd(99, 99, 75), 75);
assertEquals(Util.maxAdd(25, 74, 101), 99); assertEquals(Fn.maxAdd(25, 74, 101), 99);
}, },
'ord()': () => { 'ord()': () => {
// Invalid output // Invalid output
assertEquals(Util.ord(''), 256); assertEquals(Fn.ord(''), 256);
// Valid output // Valid output
assertEquals(Util.ord('a'), 97); assertEquals(Fn.ord('a'), 97);
}, },
'chars() properly splits strings into unicode characters': () => { 'strChars() properly splits strings into unicode characters': () => {
assertEquals(Util.chars('😺😸😹'), ['😺', '😸', '😹']); assertEquals(Fn.strChars('😺😸😹'), ['😺', '😸', '😹']);
}, },
'ctrlKey()': () => { 'ctrlKey()': () => {
const ctrl_a = Util.ctrlKey('a'); const ctrl_a = Fn.ctrlKey('a');
assertTrue(Util.isControl(ctrl_a)); assertTrue(Fn.isControl(ctrl_a));
assertEquals(ctrl_a, String.fromCodePoint(0x01)); assertEquals(ctrl_a, String.fromCodePoint(0x01));
const invalid = Util.ctrlKey('😺'); const invalid = Fn.ctrlKey('😺');
assertFalse(Util.isControl(invalid)); assertFalse(Fn.isControl(invalid));
assertEquals(invalid, '😺'); assertEquals(invalid, '😺');
}, },
'isAscii()': () => { 'isAscii()': () => {
assertTrue(Util.isAscii('asjyverkjhsdf1928374')); assertTrue(Fn.isAscii('asjyverkjhsdf1928374'));
assertFalse(Util.isAscii('😺acalskjsdf')); assertFalse(Fn.isAscii('😺acalskjsdf'));
assertFalse(Util.isAscii('ab😺ac')); assertFalse(Fn.isAscii('ab😺ac'));
}, },
'isControl()': () => { 'isControl()': () => {
assertFalse(Util.isControl('abc')); assertFalse(Fn.isControl('abc'));
assertTrue(Util.isControl(String.fromCodePoint(0x01))); assertTrue(Fn.isControl(String.fromCodePoint(0x01)));
assertFalse(Util.isControl('😺')); assertFalse(Fn.isControl('😺'));
}, },
'strlen()': () => { 'strlen()': () => {
// Ascii length // Ascii length
assertEquals(Util.strlen('abc'), 'abc'.length); assertEquals(Fn.strlen('abc'), 'abc'.length);
// Get number of visible unicode characters // Get number of visible unicode characters
assertEquals(Util.strlen('😺😸😹'), 3); assertEquals(Fn.strlen('😺😸😹'), 3);
assertNotEquals('😺😸😹'.length, Util.strlen('😺😸😹')); assertNotEquals('😺😸😹'.length, Fn.strlen('😺😸😹'));
// Skin tone modifier + base character // Skin tone modifier + base character
assertEquals(Util.strlen('🤰🏼'), 2); assertEquals(Fn.strlen('🤰🏼'), 2);
assertNotEquals('🤰🏼'.length, Util.strlen('🤰🏼')); assertNotEquals('🤰🏼'.length, Fn.strlen('🤰🏼'));
// This has 4 sub-characters, and 3 zero-width-joiners // This has 4 sub-characters, and 3 zero-width-joiners
assertEquals(Util.strlen('👨‍👩‍👧‍👦'), 7); assertEquals(Fn.strlen('👨‍👩‍👧‍👦'), 7);
assertNotEquals('👨‍👩‍👧‍👦'.length, Util.strlen('👨‍👩‍👧‍👦')); assertNotEquals('👨‍👩‍👧‍👦'.length, Fn.strlen('👨‍👩‍👧‍👦'));
}, },
'truncate()': () => { 'truncate()': () => {
assertEquals(Util.truncate('😺😸😹', 1), '😺'); assertEquals(Fn.truncate('😺😸😹', 1), '😺');
assertEquals(Util.truncate('😺😸😹', 5), '😺😸😹'); assertEquals(Fn.truncate('😺😸😹', 5), '😺😸😹');
assertEquals(Util.truncate('👨‍👩‍👧‍👦', 5), '👨‍👩‍👧'); assertEquals(Fn.truncate('👨‍👩‍👧‍👦', 5), '👨‍👩‍👧');
}, },
}, },
'readKey()': { 'readKey()': {
'empty input': () => { 'empty input': () => {
assertEquals(Util.readKey(new Uint8Array(0)), ''); assertEquals(Fn.readKey(new Uint8Array(0)), '');
}, },
'passthrough': () => { 'passthrough': () => {
// Ignore unhandled escape sequences // Ignore unhandled escape sequences
assertEquals(Util.readKey(encoder.encode('\x1b[]')), '\x1b[]'); assertEquals(Fn.readKey(encoder.encode('\x1b[]')), '\x1b[]');
// Pass explicitly mapped values right through // Pass explicitly mapped values right through
assertEquals( assertEquals(
Util.readKey(encoder.encode(KeyCommand.ArrowUp)), Fn.readKey(encoder.encode(KeyCommand.ArrowUp)),
KeyCommand.ArrowUp, KeyCommand.ArrowUp,
); );
assertEquals( assertEquals(
Util.readKey(encoder.encode(KeyCommand.Home)), Fn.readKey(encoder.encode(KeyCommand.Home)),
KeyCommand.Home, KeyCommand.Home,
); );
assertEquals( assertEquals(
Util.readKey(encoder.encode(KeyCommand.Delete)), Fn.readKey(encoder.encode(KeyCommand.Delete)),
KeyCommand.Delete, KeyCommand.Delete,
); );
// And pass through whatever else // And pass through whatever else
assertEquals(Util.readKey(encoder.encode('foobaz')), 'foobaz'); assertEquals(Fn.readKey(encoder.encode('foobaz')), 'foobaz');
}, },
'Esc': () => testKeyMap(['\x1b', Util.ctrlKey('l')], KeyCommand.Escape), 'Esc': () => testKeyMap(['\x1b', Fn.ctrlKey('l')], KeyCommand.Escape),
'Backspace': () => 'Backspace': () =>
testKeyMap( testKeyMap(
[Util.ctrlKey('h'), '\x7f'], [Fn.ctrlKey('h'), '\x7f'],
KeyCommand.Backspace, KeyCommand.Backspace,
), ),
'Home': () => 'Home': () =>

View File

@ -24,24 +24,112 @@ export enum KeyCommand {
End = 'LineEnd', End = 'LineEnd',
} }
export const Ansi = { /**
ClearLine: ANSI_PREFIX + 'K', * Values for Basic ANSI colors and formatting
ClearScreen: ANSI_PREFIX + '2J', */
ResetCursor: ANSI_PREFIX + 'H', export enum AnsiColor {
HideCursor: ANSI_PREFIX + '?25l', TypeRGB = 2,
ShowCursor: ANSI_PREFIX + '?25h', Type256 = 5,
GetCursorLocation: ANSI_PREFIX + '6n',
InvertColor: ANSI_PREFIX + '7m',
ResetFormatting: ANSI_PREFIX + 'm',
moveCursor: (row: number, col: number): string => {
// Convert to 1-based counting
row++;
col++;
return ANSI_PREFIX + `${row};${col}H`; Invert = 7,
},
moveCursorForward: (col: number): string => ANSI_PREFIX + `${col}C`, // Foreground Colors
moveCursorDown: (row: number): string => ANSI_PREFIX + `${row}B`, FgBlack = 30,
FgRed,
FgGreen,
FgYellow,
FgBlue,
FgMagenta,
FgCyan,
FgWhite,
FgDefault,
// Background Colors
BgBlack = 40,
BgRed,
BgGreen,
BgYellow,
BgBlue,
BgMagenta,
BgCyan,
BgWhite,
BgDefault,
// Bright Foreground Colors
FgBrightBlack = 90,
FgBrightRed,
FgBrightGreen,
FgBrightYellow,
FgBrightBlue,
FgBrightMagenta,
FgBrightCyan,
FgBrightWhite,
// Bright Background Colors
BgBrightBlack = 100,
BgBrightRed,
BgBrightGreen,
BgBrightYellow,
BgBrightBlue,
BgBrightMagenta,
BgBrightCyan,
BgBrightWhite,
}
export enum Ground {
Fore = AnsiColor.FgDefault,
Back = AnsiColor.BgDefault,
}
// ----------------------------------------------------------------------------
// ANSI escape code generation fns
// ----------------------------------------------------------------------------
const code = (
param: string | number | string[] | number[],
suffix: string = '',
): string => {
if (Array.isArray(param)) {
param = param.join(';');
}
return [ANSI_PREFIX, param, suffix].join('');
};
const moveCursor = (row: number, col: number): string => {
// Convert to 1-based counting
row++;
col++;
return code([row, col], 'H');
};
const moveCursorForward = (col: number): string => code(col, 'C');
const moveCursorDown = (row: number): string => code(row, 'B');
const textFormat = (param: string | number | string[] | number[]): string =>
code(param, 'm');
const color256 = (value: number, ground: Ground = Ground.Fore): string =>
textFormat([ground, AnsiColor.Type256, value]);
const rgb = (
r: number,
g: number,
b: number,
ground: Ground = Ground.Fore,
): string => textFormat([ground, AnsiColor.TypeRGB, r, g, b]);
export const Ansi = {
ClearLine: code('K'),
ClearScreen: code('2J'),
ResetCursor: code('H'),
HideCursor: code('?25l'),
ShowCursor: code('?25h'),
GetCursorLocation: code('6n'),
InvertColor: textFormat(AnsiColor.Invert),
ResetFormatting: textFormat(''),
moveCursor,
moveCursorForward,
moveCursorDown,
textFormat,
color256,
rgb,
}; };
export default Ansi; export default Ansi;

View File

@ -137,7 +137,7 @@ export function ord(s: string): number {
* *
* @param s - the string to split into 'characters' * @param s - the string to split into 'characters'
*/ */
export function chars(s: string): string[] { export function strChars(s: string): string[] {
return s.split(/(?:)/u); return s.split(/(?:)/u);
} }
@ -147,7 +147,7 @@ export function chars(s: string): string[] {
* @param s - the string to check * @param s - the string to check
*/ */
export function strlen(s: string): number { export function strlen(s: string): number {
return chars(s).length; return strChars(s).length;
} }
/** /**
@ -156,7 +156,7 @@ export function strlen(s: string): number {
* @param char - string to check * @param char - string to check
*/ */
export function isAscii(char: string): boolean { export function isAscii(char: string): boolean {
return chars(char).every((char) => ord(char) < 0x80); return strChars(char).every((char) => ord(char) < 0x80);
} }
/** /**
@ -191,7 +191,7 @@ export function ctrlKey(char: string): string {
* @param maxLen * @param maxLen
*/ */
export function truncate(s: string, maxLen: number): string { export function truncate(s: string, maxLen: number): string {
const chin = chars(s); const chin = strChars(s);
if (maxLen >= chin.length) { if (maxLen >= chin.length) {
return s; return s;
} }

View File

@ -17,7 +17,7 @@ export class Row {
render: string[] = []; render: string[] = [];
private constructor(s: string | string[] = '') { private constructor(s: string | string[] = '') {
this.chars = Array.isArray(s) ? s : Util.chars(s); this.chars = Array.isArray(s) ? s : Util.strChars(s);
this.render = []; this.render = [];
} }
@ -46,12 +46,12 @@ export class Row {
} }
public append(s: string): void { public append(s: string): void {
this.chars = this.chars.concat(Util.chars(s)); this.chars = this.chars.concat(Util.strChars(s));
this.updateRender(); this.updateRender();
} }
public insertChar(at: number, c: string): void { public insertChar(at: number, c: string): void {
const newSlice = Util.chars(c); const newSlice = Util.strChars(c);
if (at >= this.size) { if (at >= this.size) {
this.chars = this.chars.concat(newSlice); this.chars = this.chars.concat(newSlice);
} else { } else {
@ -96,8 +96,8 @@ export class Row {
public cxToRx(cx: number): number { public cxToRx(cx: number): number {
let rx = 0; let rx = 0;
let j = 0; let j;
for (; j < cx; j++) { for (j = 0; j < cx; j++) {
if (this.chars[j] === '\t') { if (this.chars[j] === '\t') {
rx += (SCROLL_TAB_SIZE - 1) - (rx % SCROLL_TAB_SIZE); rx += (SCROLL_TAB_SIZE - 1) - (rx % SCROLL_TAB_SIZE);
} }
@ -162,7 +162,7 @@ export class Row {
' '.repeat(SCROLL_TAB_SIZE), ' '.repeat(SCROLL_TAB_SIZE),
); );
this.render = Util.chars(newString); this.render = Util.strChars(newString);
} }
} }

View File

@ -90,7 +90,7 @@ 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");
} }
this.#inRawMode = true; this.#inRawMode = true;
@ -101,7 +101,7 @@ class Termios {
// and aren't in raw mode. It just doesn't really matter. // and aren't in raw mode. It just doesn't really matter.
if (!this.#inRawMode) { if (!this.#inRawMode) {
log( log(
'Attampting to disable raw mode when not in raw mode', 'Attempting to disable raw mode when not in raw mode',
LogLevel.Warning, LogLevel.Warning,
); );
return; return;

View File

@ -1,9 +1,5 @@
import { RunTimeType } from './runtime.ts'; import { RunTimeType } from './runtime.ts';
// ----------------------------------------------------------------------------
// Runtime adapter interfaces
// ----------------------------------------------------------------------------
/** /**
* The size of terminal in rows and columns * The size of terminal in rows and columns
*/ */
@ -43,6 +39,10 @@ export interface IFFI {
close(): void; close(): void;
} }
// ----------------------------------------------------------------------------
// Runtime adapter interfaces
// ----------------------------------------------------------------------------
/** /**
* The common interface for runtime adapters * The common interface for runtime adapters
*/ */