Add text insertion

This commit is contained in:
Timothy Warren 2023-11-21 15:14:08 -05:00
parent 35f949c4b5
commit 95c979066a
12 changed files with 238 additions and 123 deletions

View File

@ -17,6 +17,10 @@ const BunFileIO: IFileIO = {
appendFileSync: function (path: string, contents: string) { appendFileSync: function (path: string, contents: string) {
return appendFileSync(path, contents); return appendFileSync(path, contents);
}, },
saveFile: async function (path: string, contents: string): Promise<void> {
await Bun.write(path, contents);
return;
},
}; };
export default BunFileIO; export default BunFileIO;

View File

@ -1,19 +1,11 @@
import { getTestRunner } from './runtime.ts';
import { Ansi, KeyCommand, readKey } from './ansi.ts'; import { Ansi, KeyCommand, readKey } from './ansi.ts';
import { Document, Row } from './document.ts';
import Buffer from './buffer.ts'; import Buffer from './buffer.ts';
import { import Document from './document.ts';
chars, import Editor from './editor.ts';
ctrlKey, import Row from './row.ts';
isAscii, import { getTestRunner } from './runtime.ts';
isControl,
noop,
ord,
strlen,
truncate,
} from './utils.ts';
import { Editor } from './editor.ts';
import { defaultTerminalSize } from './termios.ts'; import { defaultTerminalSize } from './termios.ts';
import * as Util from './utils.ts';
const { const {
assertEquals, assertEquals,
@ -25,7 +17,7 @@ const {
testSuite, testSuite,
} = await getTestRunner(); } = await getTestRunner();
const testObj = { testSuite({
'ANSI::ANSI utils': { 'ANSI::ANSI utils': {
'Ansi.moveCursor': () => { 'Ansi.moveCursor': () => {
assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H'); assertEquals(Ansi.moveCursor(1, 2), '\x1b[2;3H');
@ -112,76 +104,74 @@ const testObj = {
assertInstanceOf(doc.row(0), Row); assertInstanceOf(doc.row(0), Row);
}, },
}, },
'Document::Row': {
'new Row': () => {
const row = new Row();
assertEquals(row.toString(), '');
},
},
Editor: { Editor: {
'new Editor': () => { 'new Editor': () => {
const e = new Editor(defaultTerminalSize); const e = new Editor(defaultTerminalSize);
assertInstanceOf(e, Editor); assertInstanceOf(e, Editor);
}, },
}, },
Row: {
'new Row': () => {
const row = new Row();
assertEquals(row.toString(), '');
},
},
'Util::Misc fns': { 'Util::Misc fns': {
'noop fn': () => { 'noop fn': () => {
assertExists(noop); assertExists(Util.noop);
assertEquals(noop(), undefined); assertEquals(Util.noop(), undefined);
}, },
}, },
'Util::String fns': { 'Util::String fns': {
'ord()': () => { 'ord()': () => {
// Invalid output // Invalid output
assertEquals(ord(''), 256); assertEquals(Util.ord(''), 256);
// Valid output // Valid output
assertEquals(ord('a'), 97); assertEquals(Util.ord('a'), 97);
}, },
'chars() properly splits strings into unicode characters': () => { 'chars() properly splits strings into unicode characters': () => {
assertEquals(chars('😺😸😹'), ['😺', '😸', '😹']); assertEquals(Util.chars('😺😸😹'), ['😺', '😸', '😹']);
}, },
'ctrl_key()': () => { 'ctrl_key()': () => {
const ctrl_a = ctrlKey('a'); const ctrl_a = Util.ctrlKey('a');
assertTrue(isControl(ctrl_a)); assertTrue(Util.isControl(ctrl_a));
assertEquals(ctrl_a, String.fromCodePoint(0x01)); assertEquals(ctrl_a, String.fromCodePoint(0x01));
const invalid = ctrlKey('😺'); const invalid = Util.ctrlKey('😺');
assertFalse(isControl(invalid)); assertFalse(Util.isControl(invalid));
assertEquals(invalid, '😺'); assertEquals(invalid, '😺');
}, },
'is_ascii()': () => { 'is_ascii()': () => {
assertTrue(isAscii('asjyverkjhsdf1928374')); assertTrue(Util.isAscii('asjyverkjhsdf1928374'));
assertFalse(isAscii('😺acalskjsdf')); assertFalse(Util.isAscii('😺acalskjsdf'));
assertFalse(isAscii('ab😺ac')); assertFalse(Util.isAscii('ab😺ac'));
}, },
'is_control()': () => { 'is_control()': () => {
assertFalse(isControl('abc')); assertFalse(Util.isControl('abc'));
assertTrue(isControl(String.fromCodePoint(0x01))); assertTrue(Util.isControl(String.fromCodePoint(0x01)));
assertFalse(isControl('😺')); assertFalse(Util.isControl('😺'));
}, },
'strlen()': () => { 'strlen()': () => {
// Ascii length // Ascii length
assertEquals(strlen('abc'), 'abc'.length); assertEquals(Util.strlen('abc'), 'abc'.length);
// Get number of visible unicode characters // Get number of visible unicode characters
assertEquals(strlen('😺😸😹'), 3); assertEquals(Util.strlen('😺😸😹'), 3);
assertNotEquals('😺😸😹'.length, strlen('😺😸😹')); assertNotEquals('😺😸😹'.length, Util.strlen('😺😸😹'));
// Skin tone modifier + base character // Skin tone modifier + base character
assertEquals(strlen('🤰🏼'), 2); assertEquals(Util.strlen('🤰🏼'), 2);
assertNotEquals('🤰🏼'.length, strlen('🤰🏼')); assertNotEquals('🤰🏼'.length, Util.strlen('🤰🏼'));
// This has 4 sub-characters, and 3 zero-width-joiners // This has 4 sub-characters, and 3 zero-width-joiners
assertEquals(strlen('👨‍👩‍👧‍👦'), 7); assertEquals(Util.strlen('👨‍👩‍👧‍👦'), 7);
assertNotEquals('👨‍👩‍👧‍👦'.length, strlen('👨‍👩‍👧‍👦')); assertNotEquals('👨‍👩‍👧‍👦'.length, Util.strlen('👨‍👩‍👧‍👦'));
}, },
'truncate()': () => { 'truncate()': () => {
assertEquals(truncate('😺😸😹', 1), '😺'); assertEquals(Util.truncate('😺😸😹', 1), '😺');
assertEquals(truncate('😺😸😹', 5), '😺😸😹'); assertEquals(Util.truncate('😺😸😹', 5), '😺😸😹');
assertEquals(truncate('👨‍👩‍👧‍👦', 5), '👨‍👩‍👧'); assertEquals(Util.truncate('👨‍👩‍👧‍👦', 5), '👨‍👩‍👧');
}, },
}, },
}; });
testSuite(testObj);

View File

@ -1,6 +1,7 @@
/** /**
* ANSI/VT terminal escape code handling * ANSI/VT terminal escape code handling
*/ */
import { ctrlKey } from './utils.ts';
export const ANSI_PREFIX = '\x1b['; export const ANSI_PREFIX = '\x1b[';
@ -15,8 +16,11 @@ export enum KeyCommand {
Delete = ANSI_PREFIX + '3~', Delete = ANSI_PREFIX + '3~',
PageUp = ANSI_PREFIX + '5~', PageUp = ANSI_PREFIX + '5~',
PageDown = ANSI_PREFIX + '6~', PageDown = ANSI_PREFIX + '6~',
Enter = '\r',
// These keys have several possible escape sequences // These keys have several possible escape sequences
Escape = 'EscapeKey',
Backspace = 'BackspaceKey',
Home = 'LineHome', Home = 'LineHome',
End = 'LineEnd', End = 'LineEnd',
} }
@ -67,6 +71,14 @@ export function readKey(parsed: string): string {
case '\x1b[F': case '\x1b[F':
return KeyCommand.End; return KeyCommand.End;
case ctrlKey('l'):
case '\x1b':
return KeyCommand.Escape;
case ctrlKey('H'):
case String.fromCodePoint(127):
return KeyCommand.Backspace;
default: default:
return parsed; return parsed;
} }

View File

@ -1,51 +1,17 @@
import { chars } from './utils.ts'; import { chars } from './utils.ts';
import Row from './row.ts';
import { getRuntime } from './runtime.ts'; import { getRuntime } from './runtime.ts';
import { TAB_SIZE } from './mod.ts'; import { Position, TAB_SIZE } from './mod.ts';
export class Row {
chars: string[] = [];
render: string[] = [];
constructor(s: string = '') {
this.chars = chars(s);
this.render = [];
}
public get size(): number {
return this.chars.length;
}
public get rsize(): number {
return this.render.length;
}
public rstring(offset: number = 0): string {
return this.render.slice(offset).join('');
}
public cxToRx(cx: number): number {
let rx = 0;
let j = 0;
for (; j < cx; j++) {
if (this.chars[j] === '\t') {
rx += (TAB_SIZE - 1) - (rx % TAB_SIZE);
}
rx++;
}
return rx;
}
public toString(): string {
return this.chars.join('');
}
}
export class Document { export class Document {
#filename: string | null;
#rows: Row[]; #rows: Row[];
dirty: boolean;
private constructor() { private constructor() {
this.#rows = []; this.#rows = [];
this.#filename = null;
this.dirty = false;
} }
get numRows(): number { get numRows(): number {
@ -72,9 +38,35 @@ export class Document {
rawFile.split(/\r?\n/) rawFile.split(/\r?\n/)
.forEach((row) => this.appendRow(row)); .forEach((row) => this.appendRow(row));
this.#filename = filename;
this.dirty = false;
return this; return this;
} }
public async save() {
if (this.#filename === null) {
return;
}
const { file } = await getRuntime();
await file.saveFile(this.#filename, this.rowsToString());
this.dirty = false;
}
public insert(at: Position, c: string): void {
if (at.y === this.numRows) {
this.appendRow(c);
} else {
this.#rows[at.y].insertChar(at.x, c);
this.updateRow(this.#rows[at.y]);
}
this.dirty = true;
}
public row(i: number): Row | null { public row(i: number): Row | null {
return this.#rows[i] ?? null; return this.#rows[i] ?? null;
} }
@ -83,11 +75,17 @@ export class Document {
const at = this.numRows; const at = this.numRows;
this.#rows[at] = new Row(s); this.#rows[at] = new Row(s);
this.updateRow(this.#rows[at]); this.updateRow(this.#rows[at]);
this.dirty = true;
} }
private updateRow(r: Row): void { private updateRow(r: Row): void {
r.render = chars(r.toString().replace('\t', ' '.repeat(TAB_SIZE))); r.render = chars(r.toString().replace('\t', ' '.repeat(TAB_SIZE)));
} }
private rowsToString(): string {
return this.#rows.map((r) => r.toString()).join('\n');
}
} }
export default Document; export default Document;

View File

@ -1,10 +1,11 @@
import Ansi, { KeyCommand } from './ansi.ts'; import Ansi, { KeyCommand } from './ansi.ts';
import Buffer from './buffer.ts'; import Buffer from './buffer.ts';
import Document, { Row } from './document.ts'; import Document from './document.ts';
import { IPoint, ITerminalSize, logToFile, VERSION } from './mod.ts'; import { ITerminalSize, logToFile, Position, VERSION } from './mod.ts';
import Row from './row.ts';
import { ctrlKey, maxAdd, posSub, truncate } from './utils.ts'; import { ctrlKey, maxAdd, posSub, truncate } from './utils.ts';
export class Editor { class Editor {
/** /**
* The document being edited * The document being edited
* @private * @private
@ -24,11 +25,11 @@ export class Editor {
* The current location of the mouse cursor * The current location of the mouse cursor
* @private * @private
*/ */
#cursor: IPoint; #cursor: Position;
/** /**
* The current scrolling offset * The current scrolling offset
*/ */
#offset: IPoint; #offset: Position;
/** /**
* The scrolling offset for the rendered row * The scrolling offset for the rendered row
* @private * @private
@ -52,19 +53,22 @@ export class Editor {
constructor(terminalSize: ITerminalSize) { constructor(terminalSize: ITerminalSize) {
this.#buffer = new Buffer(); this.#buffer = new Buffer();
// Subtract two rows from the terminal size
// for displaying the status bar
// and message bar
this.#screen = terminalSize; this.#screen = terminalSize;
this.#screen.rows -= 2; this.#screen.rows -= 2;
this.#cursor = {
x: 0, this.#cursor = Position.default();
y: 0, this.#offset = Position.default();
};
this.#offset = {
x: 0,
y: 0,
};
this.#document = Document.empty(); this.#document = Document.empty();
} }
private get numRows(): number {
return this.#document.numRows;
}
private get currentRow(): Row | null { private get currentRow(): Row | null {
return this.#document.row(this.#cursor.y); return this.#document.row(this.#cursor.y);
} }
@ -84,12 +88,21 @@ export class Editor {
* Determine what to do based on input * Determine what to do based on input
* @param input - the decoded chunk of stdin * @param input - the decoded chunk of stdin
*/ */
public processKeyPress(input: string): boolean { public async processKeyPress(input: string): Promise<boolean> {
switch (input) { switch (input) {
case KeyCommand.Enter:
// TODO
break;
case ctrlKey('q'): case ctrlKey('q'):
this.clearScreen().then(() => {}); await this.clearScreen();
return false; return false;
case ctrlKey('s'):
await this.#document.save();
this.setStatusMessage(`${this.#filename} was saved to disk.`);
break;
case KeyCommand.Home: case KeyCommand.Home:
this.#cursor.x = 0; this.#cursor.x = 0;
break; break;
@ -100,6 +113,11 @@ export class Editor {
} }
break; break;
case KeyCommand.Backspace:
case KeyCommand.Delete:
// TODO
break;
case KeyCommand.PageUp: case KeyCommand.PageUp:
case KeyCommand.PageDown: case KeyCommand.PageDown:
{ {
@ -109,7 +127,7 @@ export class Editor {
this.#cursor.y = maxAdd( this.#cursor.y = maxAdd(
this.#offset.y, this.#offset.y,
this.#screen.rows - 1, this.#screen.rows - 1,
this.#document.numRows, this.numRows,
); );
} }
@ -130,6 +148,13 @@ export class Editor {
case KeyCommand.ArrowLeft: case KeyCommand.ArrowLeft:
this.moveCursor(input); this.moveCursor(input);
break; break;
case KeyCommand.Escape:
break;
default:
this.#document.insert(this.#cursor, input);
this.#cursor.x++;
} }
return true; return true;
@ -166,7 +191,7 @@ export class Editor {
} }
break; break;
case KeyCommand.ArrowDown: case KeyCommand.ArrowDown:
if (this.#cursor.y < this.#document.numRows) { if (this.#cursor.y < this.numRows) {
this.#cursor.y++; this.#cursor.y++;
} }
break; break;
@ -239,11 +264,11 @@ export class Editor {
private drawRows(): void { private drawRows(): void {
for (let y = 0; y < this.#screen.rows; y++) { for (let y = 0; y < this.#screen.rows; y++) {
this.#buffer.append(Ansi.ClearLine); this.#buffer.append(Ansi.ClearLine);
const filerow = y + this.#offset.y; const fileRow = y + this.#offset.y;
if (filerow >= this.#document.numRows) { if (fileRow >= this.numRows) {
this.drawPlaceholderRow(filerow); this.drawPlaceholderRow(fileRow);
} else { } else {
this.drawFileRow(filerow); this.drawFileRow(fileRow);
} }
this.#buffer.appendLine(); this.#buffer.appendLine();
@ -288,14 +313,15 @@ export class Editor {
private drawStatusBar(): void { private drawStatusBar(): void {
this.#buffer.append(Ansi.InvertColor); this.#buffer.append(Ansi.InvertColor);
const name = (this.#filename !== '') ? this.#filename : '[No Name]'; const name = (this.#filename !== '') ? this.#filename : '[No Name]';
const status = `${truncate(name, 20)} - ${this.#document.numRows} lines`; const modified = (this.#document.dirty) ? '(modified)' : '';
const rstatus = `${this.#cursor.y + 1}/${this.#document.numRows}`; const status = `${truncate(name, 20)} - ${this.numRows} lines ${modified}`;
const rStatus = `${this.#cursor.y + 1}/${this.numRows}`;
let len = Math.min(status.length, this.#screen.cols); let len = Math.min(status.length, this.#screen.cols);
this.#buffer.append(status, len); this.#buffer.append(status, len);
while (len < this.#screen.cols) { while (len < this.#screen.cols) {
if (this.#screen.cols - len === rstatus.length) { if (this.#screen.cols - len === rStatus.length) {
this.#buffer.append(rstatus); this.#buffer.append(rStatus);
break; break;
} else { } else {
this.#buffer.append(' '); this.#buffer.append(' ');
@ -313,3 +339,5 @@ export class Editor {
} }
} }
} }
export default Editor;

View File

@ -1,12 +1,11 @@
import { readKey } from './ansi.ts'; import { readKey } from './ansi.ts';
import { getRuntime } from './runtime.ts'; import { getRuntime } from './runtime.ts';
import { getTermios } from './termios.ts'; import { getTermios } from './termios.ts';
import { Editor } from './editor.ts'; import Editor from './editor.ts';
export async function main() { export async function main() {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const runTime = await getRuntime(); const { term, file, onExit, onEvent } = await getRuntime();
const { term, file, onExit, onEvent } = runTime;
// 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();
@ -26,8 +25,9 @@ export async function main() {
// Create the editor itself // Create the editor itself
const editor = new Editor(terminalSize); const editor = new Editor(terminalSize);
editor.setStatusMessage('HELP: Ctrl-Q = quit'); editor.setStatusMessage('HELP: Ctrl-S = save | Ctrl-Q = quit');
// Process cli arguments
if (term.argv.length > 0) { if (term.argv.length > 0) {
const filename = term.argv[0]; const filename = term.argv[0];
if (filename.trim() !== '') { if (filename.trim() !== '') {
@ -38,11 +38,10 @@ export async function main() {
// Clear the screen // Clear the screen
await editor.refreshScreen(); await editor.refreshScreen();
// The main event loop
for await (const chunk of term.inputLoop()) { for await (const chunk of term.inputLoop()) {
// Process input // Process input
const char = readKey(decoder.decode(chunk)); const char = readKey(decoder.decode(chunk));
const shouldLoop = editor.processKeyPress(char); const shouldLoop = await editor.processKeyPress(char);
if (!shouldLoop) { if (!shouldLoop) {
return 0; return 0;
} }

View File

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

65
src/common/row.ts Normal file
View File

@ -0,0 +1,65 @@
import { TAB_SIZE } from './mod.ts';
import * as Util from './utils.ts';
/**
* One row of text in the current document
*/
export class Row {
/**
* The actual characters in the current row
*/
chars: string[] = [];
/**
* The characters rendered for the current row
* (like replacing tabs with spaces)
*/
render: string[] = [];
constructor(s: string = '') {
this.chars = Util.chars(s);
this.render = [];
}
public get size(): number {
return this.chars.length;
}
public get rsize(): number {
return this.render.length;
}
public rstring(offset: number = 0): string {
return this.render.slice(offset).join('');
}
public insertChar(at: number, c: string): void {
const newSlice = Util.chars(c);
if (at >= this.size) {
this.chars = this.chars.concat(newSlice);
} else {
const front = this.chars.slice(0, at + 1);
const back = this.chars.slice(at + 1);
this.chars = front.concat(newSlice, back);
}
}
public cxToRx(cx: number): number {
let rx = 0;
let j = 0;
for (; j < cx; j++) {
if (this.chars[j] === '\t') {
rx += (TAB_SIZE - 1) - (rx % TAB_SIZE);
}
rx++;
}
return rx;
}
public toString(): string {
return this.chars.join('');
}
}
export default Row;

View File

@ -84,6 +84,7 @@ export interface IFileIO {
openFileSync(path: string): string; openFileSync(path: string): string;
appendFile(path: string, contents: string): Promise<void>; appendFile(path: string, contents: string): Promise<void>;
appendFileSync(path: string, contents: string): void; appendFileSync(path: string, contents: string): void;
saveFile(path: string, contents: string): Promise<void>;
} }
/** /**

View File

@ -2,9 +2,22 @@
// General types // General types
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
export interface IPoint { export class Position {
x: number; public x: number;
y: number; public y: number;
private constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
public static at(x: number, y: number): Position {
return new Position(x, y);
}
public static default(): Position {
return new Position(0, 0);
}
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@ -39,7 +39,7 @@ const DenoFFI: IFFI = {
tcsetattr, tcsetattr,
cfmakeraw, cfmakeraw,
getPointer: Deno.UnsafePointer.of, getPointer: Deno.UnsafePointer.of,
close: () => {}, close: cStdLib.close,
}; };
export default DenoFFI; export default DenoFFI;

View File

@ -27,6 +27,9 @@ const DenoFileIO: IFileIO = {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
Deno.writeFileSync(path, encoder.encode(contents)); Deno.writeFileSync(path, encoder.encode(contents));
}, },
saveFile: async function (path: string, contents: string): Promise<void> {
return await Deno.writeTextFile(path, contents);
},
}; };
export default DenoFileIO; export default DenoFileIO;