Minor refactoring, add some more comments and tests

This commit is contained in:
Timothy Warren 2024-06-26 15:45:33 -04:00
parent 8093683f92
commit f71239ded5
8 changed files with 220 additions and 185 deletions

View File

@ -6,6 +6,8 @@ import { IRuntime, RunTimeType } from '../common/runtime.ts';
import BunTerminalIO from './terminal_io.ts'; import BunTerminalIO from './terminal_io.ts';
import BunFileIO from './file_io.ts'; import BunFileIO from './file_io.ts';
import * as process from 'node:process';
const BunRuntime: IRuntime = { const BunRuntime: IRuntime = {
name: RunTimeType.Bun, name: RunTimeType.Bun,
file: BunFileIO, file: BunFileIO,

View File

@ -1,12 +1,13 @@
import Ansi, * as _Ansi from './ansi.ts';
import Buffer from './buffer.ts'; import Buffer from './buffer.ts';
import Document from './document.ts'; import Document from './document.ts';
import Editor from './editor.ts'; import Editor from './editor.ts';
import Position from './position.ts';
import Row from './row.ts'; import Row from './row.ts';
import { Ansi, KeyCommand } from './ansi.ts';
import * as Fn from './fns.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 * as Fn from './fns.ts';
const { const {
assertEquals, assertEquals,
@ -19,16 +20,33 @@ const {
testSuite, testSuite,
} = await getTestRunner(); } = await getTestRunner();
const ANSITest = { // ----------------------------------------------------------------------------
'moveCursor()': () => {
assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H'); const ANSITest = () => {
}, const { AnsiColor, Ground } = _Ansi;
'moveCursorForward()': () => {
assertEquals(Ansi.moveCursorForward(2), '\x1b[2C'); return {
}, 'color()': () => {
'moveCursorDown()': () => { assertEquals(Ansi.color(AnsiColor.FgBlue), '\x1b[34m');
assertEquals(Ansi.moveCursorDown(7), '\x1b[7B'); },
}, 'color256()': () => {
assertEquals(Ansi.color256(128, Ground.Back), '\x1b[48;5;128m');
assertEquals(Ansi.color256(128, Ground.Fore), '\x1b[38;5;128m');
},
'rgb()': () => {
assertEquals(Ansi.rgb(32, 64, 128, Ground.Back), '\x1b[48;2;32;64;128m');
assertEquals(Ansi.rgb(32, 64, 128, Ground.Fore), '\x1b[38;2;32;64;128m');
},
'moveCursor()': () => {
assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H');
},
'moveCursorForward()': () => {
assertEquals(Ansi.moveCursorForward(2), '\x1b[2C');
},
'moveCursorDown()': () => {
assertEquals(Ansi.moveCursorDown(7), '\x1b[7B');
},
};
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -244,185 +262,187 @@ const RowTest = {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
const fnTest = { const fnTest = () => {
'defined()': () => { const {
const { defined } = Fn; some,
assertFalse(defined(null)); none,
assertFalse(defined(void 0)); arrayInsert,
assertFalse(defined(undefined)); noop,
assertTrue(defined(0)); posSub,
assertTrue(defined(false)); minSub,
}, maxAdd,
'nullish()': () => { ord,
const { nullish } = Fn; strChars,
ctrlKey,
isControl,
isAscii,
isAsciiDigit,
strlen,
truncate,
} = Fn;
assertTrue(nullish(null)); return {
assertTrue(nullish(void 0)); 'some()': () => {
assertTrue(nullish(undefined)); assertFalse(some(null));
assertFalse(nullish(0)); assertFalse(some(void 0));
assertFalse(nullish(false)); assertFalse(some(undefined));
}, assertTrue(some(0));
'arrayInsert() strings': () => { assertTrue(some(false));
const { arrayInsert } = Fn; },
'none()': () => {
assertTrue(none(null));
assertTrue(none(void 0));
assertTrue(none(undefined));
assertFalse(none(0));
assertFalse(none(false));
},
'arrayInsert() strings': () => {
const a = ['😺', '😸', '😹'];
const b = arrayInsert(a, 1, 'x');
const c = ['😺', 'x', '😸', '😹'];
assertEquals(b, c);
const a = ['😺', '😸', '😹']; const d = arrayInsert(c, 17, 'y');
const b = arrayInsert(a, 1, 'x'); const e = ['😺', 'x', '😸', '😹', 'y'];
const c = ['😺', 'x', '😸', '😹']; assertEquals(d, e);
assertEquals(b, c);
const d = arrayInsert(c, 17, 'y'); assertEquals(arrayInsert([], 0, 'foo'), ['foo']);
const e = ['😺', 'x', '😸', '😹', 'y']; },
assertEquals(d, e); 'arrayInsert() numbers': () => {
const a = [1, 3, 5];
const b = [1, 3, 4, 5];
assertEquals(arrayInsert(a, 2, 4), b);
assertEquals(arrayInsert([], 0, 'foo'), ['foo']); const c = [1, 2, 3, 4, 5];
}, assertEquals(arrayInsert(b, 1, 2), c);
'arrayInsert() numbers': () => { },
const { arrayInsert } = Fn; 'noop fn': () => {
assertExists(noop);
assertEquals(noop(), undefined);
},
'posSub()': () => {
assertEquals(posSub(14, 15), 0);
assertEquals(posSub(15, 1), 14);
},
'minSub()': () => {
assertEquals(minSub(13, 25, -1), -1);
assertEquals(minSub(25, 13, 0), 12);
},
'maxAdd()': () => {
assertEquals(maxAdd(99, 99, 75), 75);
assertEquals(maxAdd(25, 74, 101), 99);
},
'ord()': () => {
// Invalid output
assertEquals(ord(''), 256);
const a = [1, 3, 5]; // Valid output
const b = [1, 3, 4, 5]; assertEquals(ord('a'), 97);
assertEquals(arrayInsert(a, 2, 4), b); },
'strChars() properly splits strings into unicode characters': () => {
assertEquals(strChars('😺😸😹'), ['😺', '😸', '😹']);
},
'ctrlKey()': () => {
const ctrl_a = ctrlKey('a');
assertTrue(isControl(ctrl_a));
assertEquals(ctrl_a, String.fromCodePoint(0x01));
const c = [1, 2, 3, 4, 5]; const invalid = ctrlKey('😺');
assertEquals(arrayInsert(b, 1, 2), c); assertFalse(isControl(invalid));
}, assertEquals(invalid, '😺');
'noop fn': () => { },
assertExists(Fn.noop); 'isAscii()': () => {
assertEquals(Fn.noop(), undefined); assertTrue(isAscii('asjyverkjhsdf1928374'));
}, assertFalse(isAscii('😺acalskjsdf'));
'posSub()': () => { assertFalse(isAscii('ab😺ac'));
assertEquals(Fn.posSub(14, 15), 0); },
assertEquals(Fn.posSub(15, 1), 14); 'isAsciiDigit()': () => {
}, assertTrue(isAsciiDigit('1234567890'));
'minSub()': () => { assertFalse(isAsciiDigit('A1'));
assertEquals(Fn.minSub(13, 25, -1), -1); assertFalse(isAsciiDigit('/'));
assertEquals(Fn.minSub(25, 13, 0), 12); assertFalse(isAsciiDigit(':'));
}, },
'maxAdd()': () => { 'isControl()': () => {
assertEquals(Fn.maxAdd(99, 99, 75), 75); assertFalse(isControl('abc'));
assertEquals(Fn.maxAdd(25, 74, 101), 99); assertTrue(isControl(String.fromCodePoint(0x01)));
}, assertFalse(isControl('😺'));
'ord()': () => { },
const { ord } = Fn; 'strlen()': () => {
// Ascii length
assertEquals(strlen('abc'), 'abc'.length);
// Invalid output // Get number of visible unicode characters
assertEquals(ord(''), 256); assertEquals(strlen('😺😸😹'), 3);
assertNotEquals('😺😸😹'.length, strlen('😺😸😹'));
// Valid output // Skin tone modifier + base character
assertEquals(ord('a'), 97); assertEquals(strlen('🤰🏼'), 2);
}, assertNotEquals('🤰🏼'.length, strlen('🤰🏼'));
'strChars() properly splits strings into unicode characters': () => {
const { strChars } = Fn;
assertEquals(strChars('😺😸😹'), ['😺', '😸', '😹']); // This has 4 sub-characters, and 3 zero-width-joiners
}, assertEquals(strlen('👨‍👩‍👧‍👦'), 7);
'ctrlKey()': () => { assertNotEquals('👨‍👩‍👧‍👦'.length, strlen('👨‍👩‍👧‍👦'));
const { ctrlKey, isControl } = Fn; },
'truncate()': () => {
const ctrl_a = ctrlKey('a'); assertEquals(truncate('😺😸😹', 1), '😺');
assertTrue(isControl(ctrl_a)); assertEquals(truncate('😺😸😹', 5), '😺😸😹');
assertEquals(ctrl_a, String.fromCodePoint(0x01)); assertEquals(truncate('👨‍👩‍👧‍👦', 5), '👨‍👩‍👧');
},
const invalid = ctrlKey('😺'); };
assertFalse(isControl(invalid));
assertEquals(invalid, '😺');
},
'isAscii()': () => {
const { isAscii } = Fn;
assertTrue(isAscii('asjyverkjhsdf1928374'));
assertFalse(isAscii('😺acalskjsdf'));
assertFalse(isAscii('ab😺ac'));
},
'isAsciiDigit()': () => {
const { isAsciiDigit } = Fn;
assertTrue(isAsciiDigit('1234567890'));
assertFalse(isAsciiDigit('A1'));
assertFalse(isAsciiDigit('/'));
assertFalse(isAsciiDigit(':'));
},
'isControl()': () => {
const { isControl } = Fn;
assertFalse(isControl('abc'));
assertTrue(isControl(String.fromCodePoint(0x01)));
assertFalse(isControl('😺'));
},
'strlen()': () => {
const { strlen } = Fn;
// Ascii length
assertEquals(strlen('abc'), 'abc'.length);
// Get number of visible unicode characters
assertEquals(strlen('😺😸😹'), 3);
assertNotEquals('😺😸😹'.length, strlen('😺😸😹'));
// Skin tone modifier + base character
assertEquals(strlen('🤰🏼'), 2);
assertNotEquals('🤰🏼'.length, strlen('🤰🏼'));
// This has 4 sub-characters, and 3 zero-width-joiners
assertEquals(strlen('👨‍👩‍👧‍👦'), 7);
assertNotEquals('👨‍👩‍👧‍👦'.length, strlen('👨‍👩‍👧‍👦'));
},
'truncate()': () => {
const { truncate } = Fn;
assertEquals(truncate('😺😸😹', 1), '😺');
assertEquals(truncate('😺😸😹', 5), '😺😸😹');
assertEquals(truncate('👨‍👩‍👧‍👦', 5), '👨‍👩‍👧');
},
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
const encoder = new TextEncoder(); const readKeyTest = () => {
const { KeyCommand } = _Ansi;
const { readKey, ctrlKey } = Fn;
const testKeyMap = (codes: string[], expected: string) => { const encoder = new TextEncoder();
codes.forEach((code) => {
assertEquals(Fn.readKey(encoder.encode(code)), expected);
});
};
const readKeyTest = { const testKeyMap = (codes: string[], expected: string) => {
'empty input': () => { codes.forEach((code) => {
assertEquals(Fn.readKey(new Uint8Array(0)), ''); assertEquals(readKey(encoder.encode(code)), expected);
}, });
'passthrough': () => { };
// Ignore unhandled escape sequences
assertEquals(Fn.readKey(encoder.encode('\x1b[]')), '\x1b[]');
// Pass explicitly mapped values right through return {
assertEquals( 'empty input': () => {
Fn.readKey(encoder.encode(KeyCommand.ArrowUp)), assertEquals(readKey(new Uint8Array(0)), '');
KeyCommand.ArrowUp, },
); 'passthrough': () => {
assertEquals( // Ignore unhandled escape sequences
Fn.readKey(encoder.encode(KeyCommand.Home)), assertEquals(readKey(encoder.encode('\x1b[]')), '\x1b[]');
KeyCommand.Home,
);
assertEquals(
Fn.readKey(encoder.encode(KeyCommand.Delete)),
KeyCommand.Delete,
);
// And pass through whatever else // Pass explicitly mapped values right through
assertEquals(Fn.readKey(encoder.encode('foobaz')), 'foobaz'); assertEquals(
}, readKey(encoder.encode(KeyCommand.ArrowUp)),
KeyCommand.ArrowUp,
);
assertEquals(
readKey(encoder.encode(KeyCommand.Home)),
KeyCommand.Home,
);
assertEquals(
readKey(encoder.encode(KeyCommand.Delete)),
KeyCommand.Delete,
);
'Esc': () => testKeyMap(['\x1b', Fn.ctrlKey('l')], KeyCommand.Escape), // And pass through whatever else
'Backspace': () => assertEquals(readKey(encoder.encode('foobaz')), 'foobaz');
testKeyMap( },
[Fn.ctrlKey('h'), '\x7f'],
KeyCommand.Backspace, 'Esc': () => testKeyMap(['\x1b', ctrlKey('l')], KeyCommand.Escape),
), 'Backspace': () =>
'Home': () => testKeyMap(
testKeyMap(['\x1b[1~', '\x1b[7~', '\x1b[H', '\x1bOH'], KeyCommand.Home), [ctrlKey('h'), '\x7f'],
'End': () => KeyCommand.Backspace,
testKeyMap(['\x1b[4~', '\x1b[8~', '\x1b[F', '\x1bOF'], KeyCommand.End), ),
'Enter': () => testKeyMap(['\n', '\r', '\v'], KeyCommand.Enter), 'Home': () =>
testKeyMap(['\x1b[1~', '\x1b[7~', '\x1b[H', '\x1bOH'], KeyCommand.Home),
'End': () =>
testKeyMap(['\x1b[4~', '\x1b[8~', '\x1b[F', '\x1bOF'], KeyCommand.End),
'Enter': () => testKeyMap(['\n', '\r', '\v'], KeyCommand.Enter),
};
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -430,12 +450,12 @@ const readKeyTest = {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
testSuite({ testSuite({
'ANSI utils': ANSITest, 'ANSI utils': ANSITest(),
Buffer: BufferTest, Buffer: BufferTest,
Document: DocumentTest, Document: DocumentTest,
Editor: EditorTest, Editor: EditorTest,
Position: PositionTest, Position: PositionTest,
Row: RowTest, Row: RowTest,
fns: fnTest, fns: fnTest(),
'readKey()': readKeyTest, 'readKey()': readKeyTest(),
}); });

View File

@ -1,5 +1,5 @@
import Row from './row.ts'; import Row from './row.ts';
import { arrayInsert, strlen } from './fns.ts'; import { arrayInsert, some, strlen } from './fns.ts';
import { HighlightType } from './highlight.ts'; import { HighlightType } from './highlight.ts';
import { getRuntime } from './runtime.ts'; import { getRuntime } from './runtime.ts';
import { Position } from './types.ts'; import { Position } from './types.ts';
@ -76,7 +76,7 @@ export class Document {
key: string, key: string,
): Position | null { ): Position | null {
const potential = this.#search.search(q, key); const potential = this.#search.search(q, key);
if (potential !== null) { if (some(potential) && potential instanceof Position) {
// Update highlight of search match // Update highlight of search match
const row = this.#rows[potential.y]; const row = this.#rows[potential.y];

View File

@ -8,8 +8,10 @@ import {
ctrlKey, ctrlKey,
isControl, isControl,
maxAdd, maxAdd,
none,
posSub, posSub,
readKey, readKey,
some,
truncate, truncate,
} from './fns.ts'; } from './fns.ts';
import { getRuntime, log, LogLevel } from './runtime.ts'; import { getRuntime, log, LogLevel } from './runtime.ts';
@ -254,7 +256,7 @@ class Editor {
await this.refreshScreen(); await this.refreshScreen();
for await (const chunk of term.inputLoop()) { for await (const chunk of term.inputLoop()) {
const char = readKey(chunk); const char = readKey(chunk);
if (char === null) { if (none(char)) {
continue; continue;
} }
@ -312,7 +314,7 @@ class Editor {
return null; return null;
} }
if (query !== null && query.length > 0) { if (some(query) && query.length > 0) {
const pos = this.#document.find(query, key); const pos = this.#document.find(query, key);
if (pos !== null) { if (pos !== null) {
// We have a match here // We have a match here

View File

@ -14,14 +14,14 @@ export const noop = () => {};
/** /**
* Does a value exist? (not null or undefined) * Does a value exist? (not null or undefined)
*/ */
export function defined(v: unknown): boolean { export function some(v: unknown): boolean {
return v !== null && typeof v !== 'undefined'; return v !== null && typeof v !== 'undefined';
} }
/** /**
* Is the value null or undefined? * Is the value null or undefined?
*/ */
export function nullish(v: unknown): boolean { export function none(v: unknown): boolean {
return v === null || typeof v === 'undefined'; return v === null || typeof v === 'undefined';
} }

View File

@ -16,3 +16,5 @@ export class Position {
return new Position(); return new Position();
} }
} }
export default Position;

View File

@ -1,5 +1,5 @@
import { SCROLL_TAB_SIZE } from './config.ts'; import { SCROLL_TAB_SIZE } from './config.ts';
import { arrayInsert, isAsciiDigit, strChars } from './fns.ts'; import { arrayInsert, isAsciiDigit, some, strChars } from './fns.ts';
import { highlightToColor, HighlightType } from './highlight.ts'; import { highlightToColor, HighlightType } from './highlight.ts';
import Ansi from './ansi.ts'; import Ansi from './ansi.ts';
@ -68,6 +68,9 @@ export class Row {
} }
} }
/**
* Truncate the current row, and return a new one at the specified index
*/
public split(at: number): Row { public split(at: number): Row {
const newRow = new Row(this.chars.slice(at)); const newRow = new Row(this.chars.slice(at));
this.chars = this.chars.slice(0, at); this.chars = this.chars.slice(0, at);
@ -76,6 +79,9 @@ export class Row {
return newRow; return newRow;
} }
/**
* Remove a character at the specified index
*/
public delete(at: number): void { public delete(at: number): void {
if (at >= this.size) { if (at >= this.size) {
return; return;

View File

@ -85,10 +85,13 @@ export async function getRuntime(): Promise<IRuntime> {
const pkg = await import(path); const pkg = await import(path);
if ('default' in pkg) { if ('default' in pkg) {
scrollRuntime = pkg.default; scrollRuntime = pkg.default;
if (scrollRuntime !== null) {
return Promise.resolve(scrollRuntime);
}
} }
} }
return Promise.resolve(scrollRuntime!); return Promise.reject('Missing default import');
} }
/** /**