Add text insertion
This commit is contained in:
parent
35f949c4b5
commit
95c979066a
@ -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;
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
65
src/common/row.ts
Normal 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;
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
@ -39,7 +39,7 @@ const DenoFFI: IFFI = {
|
||||
tcsetattr,
|
||||
cfmakeraw,
|
||||
getPointer: Deno.UnsafePointer.of,
|
||||
close: () => {},
|
||||
close: cStdLib.close,
|
||||
};
|
||||
|
||||
export default DenoFFI;
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user