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) {
return appendFileSync(path, contents);
},
saveFile: async function (path: string, contents: string): Promise<void> {
await Bun.write(path, contents);
return;
},
};
export default BunFileIO;

View File

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

View File

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

View File

@ -1,51 +1,17 @@
import { chars } from './utils.ts';
import Row from './row.ts';
import { getRuntime } from './runtime.ts';
import { 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('');
}
}
import { Position, TAB_SIZE } from './mod.ts';
export class Document {
#filename: string | null;
#rows: Row[];
dirty: boolean;
private constructor() {
this.#rows = [];
this.#filename = null;
this.dirty = false;
}
get numRows(): number {
@ -72,9 +38,35 @@ export class Document {
rawFile.split(/\r?\n/)
.forEach((row) => this.appendRow(row));
this.#filename = filename;
this.dirty = false;
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 {
return this.#rows[i] ?? null;
}
@ -83,11 +75,17 @@ export class Document {
const at = this.numRows;
this.#rows[at] = new Row(s);
this.updateRow(this.#rows[at]);
this.dirty = true;
}
private updateRow(r: Row): void {
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;

View File

@ -1,10 +1,11 @@
import Ansi, { KeyCommand } from './ansi.ts';
import Buffer from './buffer.ts';
import Document, { Row } from './document.ts';
import { IPoint, ITerminalSize, logToFile, VERSION } from './mod.ts';
import Document from './document.ts';
import { ITerminalSize, logToFile, Position, VERSION } from './mod.ts';
import Row from './row.ts';
import { ctrlKey, maxAdd, posSub, truncate } from './utils.ts';
export class Editor {
class Editor {
/**
* The document being edited
* @private
@ -24,11 +25,11 @@ export class Editor {
* The current location of the mouse cursor
* @private
*/
#cursor: IPoint;
#cursor: Position;
/**
* The current scrolling offset
*/
#offset: IPoint;
#offset: Position;
/**
* The scrolling offset for the rendered row
* @private
@ -52,19 +53,22 @@ export class Editor {
constructor(terminalSize: ITerminalSize) {
this.#buffer = new Buffer();
// Subtract two rows from the terminal size
// for displaying the status bar
// and message bar
this.#screen = terminalSize;
this.#screen.rows -= 2;
this.#cursor = {
x: 0,
y: 0,
};
this.#offset = {
x: 0,
y: 0,
};
this.#cursor = Position.default();
this.#offset = Position.default();
this.#document = Document.empty();
}
private get numRows(): number {
return this.#document.numRows;
}
private get currentRow(): Row | null {
return this.#document.row(this.#cursor.y);
}
@ -84,12 +88,21 @@ export class Editor {
* Determine what to do based on input
* @param input - the decoded chunk of stdin
*/
public processKeyPress(input: string): boolean {
public async processKeyPress(input: string): Promise<boolean> {
switch (input) {
case KeyCommand.Enter:
// TODO
break;
case ctrlKey('q'):
this.clearScreen().then(() => {});
await this.clearScreen();
return false;
case ctrlKey('s'):
await this.#document.save();
this.setStatusMessage(`${this.#filename} was saved to disk.`);
break;
case KeyCommand.Home:
this.#cursor.x = 0;
break;
@ -100,6 +113,11 @@ export class Editor {
}
break;
case KeyCommand.Backspace:
case KeyCommand.Delete:
// TODO
break;
case KeyCommand.PageUp:
case KeyCommand.PageDown:
{
@ -109,7 +127,7 @@ export class Editor {
this.#cursor.y = maxAdd(
this.#offset.y,
this.#screen.rows - 1,
this.#document.numRows,
this.numRows,
);
}
@ -130,6 +148,13 @@ export class Editor {
case KeyCommand.ArrowLeft:
this.moveCursor(input);
break;
case KeyCommand.Escape:
break;
default:
this.#document.insert(this.#cursor, input);
this.#cursor.x++;
}
return true;
@ -166,7 +191,7 @@ export class Editor {
}
break;
case KeyCommand.ArrowDown:
if (this.#cursor.y < this.#document.numRows) {
if (this.#cursor.y < this.numRows) {
this.#cursor.y++;
}
break;
@ -239,11 +264,11 @@ export class Editor {
private drawRows(): void {
for (let y = 0; y < this.#screen.rows; y++) {
this.#buffer.append(Ansi.ClearLine);
const filerow = y + this.#offset.y;
if (filerow >= this.#document.numRows) {
this.drawPlaceholderRow(filerow);
const fileRow = y + this.#offset.y;
if (fileRow >= this.numRows) {
this.drawPlaceholderRow(fileRow);
} else {
this.drawFileRow(filerow);
this.drawFileRow(fileRow);
}
this.#buffer.appendLine();
@ -288,14 +313,15 @@ export class Editor {
private drawStatusBar(): void {
this.#buffer.append(Ansi.InvertColor);
const name = (this.#filename !== '') ? this.#filename : '[No Name]';
const status = `${truncate(name, 20)} - ${this.#document.numRows} lines`;
const rstatus = `${this.#cursor.y + 1}/${this.#document.numRows}`;
const modified = (this.#document.dirty) ? '(modified)' : '';
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);
this.#buffer.append(status, len);
while (len < this.#screen.cols) {
if (this.#screen.cols - len === rstatus.length) {
this.#buffer.append(rstatus);
if (this.#screen.cols - len === rStatus.length) {
this.#buffer.append(rStatus);
break;
} else {
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 { getRuntime } from './runtime.ts';
import { getTermios } from './termios.ts';
import { Editor } from './editor.ts';
import Editor from './editor.ts';
export async function main() {
const decoder = new TextDecoder();
const runTime = await getRuntime();
const { term, file, onExit, onEvent } = runTime;
const { term, file, onExit, onEvent } = await getRuntime();
// Setup raw mode, and tear down on error or normal exit
const t = await getTermios();
@ -26,8 +25,9 @@ export async function main() {
// Create the editor itself
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) {
const filename = term.argv[0];
if (filename.trim() !== '') {
@ -38,11 +38,10 @@ export async function main() {
// Clear the screen
await editor.refreshScreen();
// The main event loop
for await (const chunk of term.inputLoop()) {
// Process input
const char = readKey(decoder.decode(chunk));
const shouldLoop = editor.processKeyPress(char);
const shouldLoop = await editor.processKeyPress(char);
if (!shouldLoop) {
return 0;
}

View File

@ -1,7 +1,9 @@
export * from './editor.ts';
export * from './runtime.ts';
export * from './termios.ts';
export * from './types.ts';
export * from './utils.ts';
export type * from './types.ts';
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;
appendFile(path: string, contents: string): Promise<void>;
appendFileSync(path: string, contents: string): void;
saveFile(path: string, contents: string): Promise<void>;
}
/**

View File

@ -2,9 +2,22 @@
// General types
// ----------------------------------------------------------------------------
export interface IPoint {
x: number;
y: number;
export class Position {
public x: 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,
cfmakeraw,
getPointer: Deno.UnsafePointer.of,
close: () => {},
close: cStdLib.close,
};
export default DenoFFI;

View File

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