Polish text editing functionality

This commit is contained in:
Timothy Warren 2023-11-22 17:09:41 -05:00
parent 2babbf5c68
commit ddb5eb783e
6 changed files with 186 additions and 48 deletions

View File

@ -60,6 +60,7 @@ testSuite({
testKeyMap(['\x1b[1~', '\x1b[7~', '\x1b[H', '\x1bOH'], KeyCommand.Home), testKeyMap(['\x1b[1~', '\x1b[7~', '\x1b[H', '\x1bOH'], KeyCommand.Home),
'readKey End': () => 'readKey End': () =>
testKeyMap(['\x1b[4~', '\x1b[8~', '\x1b[F', '\x1bOF'], KeyCommand.End), testKeyMap(['\x1b[4~', '\x1b[8~', '\x1b[F', '\x1bOF'], KeyCommand.End),
'readKey Enter': () => testKeyMap(['\n', '\r', '\v'], KeyCommand.Enter),
}, },
Buffer: { Buffer: {
'Buffer exists': () => { 'Buffer exists': () => {
@ -107,9 +108,9 @@ testSuite({
assertTrue(doc.isEmpty()); assertTrue(doc.isEmpty());
assertEquals(doc.row(0), null); assertEquals(doc.row(0), null);
}, },
'Document.appendRow': () => { 'Document.insertRow': () => {
const doc = Document.empty(); const doc = Document.empty();
doc.appendRow('foobar'); doc.insertRow(undefined, 'foobar');
assertEquals(doc.numRows, 1); assertEquals(doc.numRows, 1);
assertFalse(doc.isEmpty()); assertFalse(doc.isEmpty());
assertInstanceOf(doc.row(0), Row); assertInstanceOf(doc.row(0), Row);
@ -163,12 +164,43 @@ testSuite({
}, },
}, },
Row: { Row: {
'new Row': () => { 'Row.default': () => {
const row = new Row(); const row = Row.default();
assertEquals(row.toString(), ''); assertEquals(row.toString(), '');
}, },
'Row.from': () => {
// From string
const row = Row.from('xyz');
assertEquals(row.toString(), 'xyz');
// From existing Row
assertEquals(Row.from(row).toString(), row.toString());
// From 'chars'
assertEquals(Row.from(['😺', '😸', '😹']).toString(), '😺😸😹');
},
}, },
'Util misc fns': { 'Util misc fns': {
'arrayInsert() strings': () => {
const a = ['😺', '😸', '😹'];
const b = Util.arrayInsert(a, 1, 'x');
const c = ['😺', 'x', '😸', '😹'];
assertEquals(b, c);
const d = Util.arrayInsert(c, 17, 'y');
const e = ['😺', 'x', '😸', '😹', 'y'];
assertEquals(d, e);
assertEquals(Util.arrayInsert([], 0, 'foo'), ['foo']);
},
'arrayInsert() numbers': () => {
const a = [1, 3, 5];
const b = [1, 3, 4, 5];
assertEquals(Util.arrayInsert(a, 2, 4), b);
const c = [1, 2, 3, 4, 5];
assertEquals(Util.arrayInsert(b, 1, 2), c);
},
'noop fn': () => { 'noop fn': () => {
assertExists(Util.noop); assertExists(Util.noop);
assertEquals(Util.noop(), undefined); assertEquals(Util.noop(), undefined);

View File

@ -17,10 +17,10 @@ export enum KeyCommand {
PageUp = ANSI_PREFIX + '5~', PageUp = ANSI_PREFIX + '5~',
PageDown = ANSI_PREFIX + '6~', PageDown = ANSI_PREFIX + '6~',
Enter = '\r', Enter = '\r',
Escape = '\x1b',
Backspace = '\x7f',
// 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',
} }
@ -71,12 +71,14 @@ export function readKey(parsed: string): string {
case '\x1b[F': case '\x1b[F':
return KeyCommand.End; return KeyCommand.End;
case '\n':
case '\v':
return KeyCommand.Enter;
case ctrlKey('l'): case ctrlKey('l'):
case '\x1b':
return KeyCommand.Escape; return KeyCommand.Escape;
case ctrlKey('h'): case ctrlKey('h'):
case '\x7f':
return KeyCommand.Backspace; return KeyCommand.Backspace;
default: default:

View File

@ -1,6 +1,6 @@
import Row from './row.ts'; import Row from './row.ts';
import { getRuntime } from './runtime.ts'; import { getRuntime } from './runtime.ts';
import { Position } from './mod.ts'; import { arrayInsert, Position } from './mod.ts';
export class Document { export class Document {
#filename: string | null; #filename: string | null;
@ -35,7 +35,7 @@ export class Document {
const rawFile = await file.openFile(filename); const rawFile = await file.openFile(filename);
rawFile.split(/\r?\n/) rawFile.split(/\r?\n/)
.forEach((row) => this.appendRow(row)); .forEach((row) => this.insertRow(this.numRows, row));
this.#filename = filename; this.#filename = filename;
this.dirty = false; this.dirty = false;
@ -57,7 +57,7 @@ export class Document {
public insert(at: Position, c: string): void { public insert(at: Position, c: string): void {
if (at.y === this.numRows) { if (at.y === this.numRows) {
this.appendRow(c); this.insertRow(this.numRows, c);
} else { } else {
this.#rows[at.y].insertChar(at.x, c); this.#rows[at.y].insertChar(at.x, c);
this.#rows[at.y].updateRender(); this.#rows[at.y].updateRender();
@ -66,6 +66,23 @@ export class Document {
this.dirty = true; this.dirty = true;
} }
public insertNewline(at: Position): void {
if (at.y > this.numRows) {
return;
}
if (at.y === this.numRows) {
this.#rows.push(Row.default());
return;
}
const newRow = this.#rows[at.y].split(at.x);
newRow.updateRender();
this.#rows = arrayInsert(this.#rows, at.y + 1, newRow);
this.dirty = true;
}
public delete(at: Position): void { public delete(at: Position): void {
const len = this.numRows; const len = this.numRows;
if (at.y >= len) { if (at.y >= len) {
@ -73,7 +90,7 @@ export class Document {
} }
const row = this.row(at.y)!; const row = this.row(at.y)!;
const mergeNextRow = at.x === row.size - 1 && at.y < len - 1; const mergeNextRow = at.x === row.size && at.y < len - 1;
const mergeIntoPrevRow = at.x === 0 && at.y > 0; const mergeIntoPrevRow = at.x === 0 && at.y > 0;
// If we are at the end of a line, and press delete, // If we are at the end of a line, and press delete,
@ -104,9 +121,8 @@ export class Document {
return this.#rows[i] ?? null; return this.#rows[i] ?? null;
} }
public appendRow(s: string): void { public insertRow(at: number = this.numRows, s: string = ''): void {
const at = this.numRows; this.#rows = arrayInsert(this.#rows, at, Row.from(s));
this.#rows[at] = new Row(s);
this.#rows[at].updateRender(); this.#rows[at].updateRender();
this.dirty = true; this.dirty = true;
@ -121,6 +137,10 @@ export class Document {
this.#rows.splice(at, 1); this.#rows.splice(at, 1);
} }
/**
* Convert the array of row objects into one string
* @private
*/
private rowsToString(): string { private rowsToString(): string {
return this.#rows.map((r) => r.toString()).join('\n'); return this.#rows.map((r) => r.toString()).join('\n');
} }

View File

@ -2,6 +2,7 @@ import Ansi, { KeyCommand } from './ansi.ts';
import Buffer from './buffer.ts'; import Buffer from './buffer.ts';
import Document from './document.ts'; import Document from './document.ts';
import { import {
isControl,
ITerminalSize, ITerminalSize,
logToFile, logToFile,
Position, Position,
@ -101,8 +102,12 @@ class Editor {
*/ */
public async processKeyPress(input: string): Promise<boolean> { public async processKeyPress(input: string): Promise<boolean> {
switch (input) { switch (input) {
case KeyCommand.Enter: // ----------------------------------------------------------------------
// TODO // Ctrl-key chords
// ----------------------------------------------------------------------
case ctrlKey('s'):
await this.#document.save();
this.setStatusMessage(`${this.#filename} was saved to disk.`);
break; break;
case ctrlKey('q'): case ctrlKey('q'):
@ -117,10 +122,9 @@ class Editor {
await this.clearScreen(); await this.clearScreen();
return false; return false;
case ctrlKey('s'): // ----------------------------------------------------------------------
await this.#document.save(); // Movement keys
this.setStatusMessage(`${this.#filename} was saved to disk.`); // ----------------------------------------------------------------------
break;
case KeyCommand.Home: case KeyCommand.Home:
this.#cursor.x = 0; this.#cursor.x = 0;
@ -128,20 +132,7 @@ class Editor {
case KeyCommand.End: case KeyCommand.End:
if (this.currentRow !== null) { if (this.currentRow !== null) {
this.#cursor.x = this.currentRow.size - 1; this.#cursor.x = this.currentRow.size;
}
break;
case KeyCommand.Delete:
this.#document.delete(this.#cursor);
break;
case KeyCommand.Backspace:
{
if (this.#cursor.x > 0 || this.#cursor.y > 0) {
this.moveCursor(KeyCommand.ArrowLeft);
this.#document.delete(this.#cursor);
}
} }
break; break;
@ -176,13 +167,40 @@ class Editor {
this.moveCursor(input); this.moveCursor(input);
break; break;
case KeyCommand.Escape: // ----------------------------------------------------------------------
// Text manipulation keys
// ----------------------------------------------------------------------
case KeyCommand.Enter:
this.#document.insertNewline(this.#cursor);
this.#cursor.x = 0;
this.#cursor.y++;
break; break;
default: case KeyCommand.Delete:
this.#document.delete(this.#cursor);
break;
case KeyCommand.Backspace:
{
if (this.#cursor.x > 0 || this.#cursor.y > 0) {
this.moveCursor(KeyCommand.ArrowLeft);
this.#document.delete(this.#cursor);
}
}
break;
// ----------------------------------------------------------------------
// Direct input
// ----------------------------------------------------------------------
default: {
if (!this.shouldFilter(input)) {
this.#document.insert(this.#cursor, input); this.#document.insert(this.#cursor, input);
this.#cursor.x++; this.#cursor.x++;
} }
}
}
if (this.#quitTimes < SCROLL_QUIT_TIMES) { if (this.#quitTimes < SCROLL_QUIT_TIMES) {
this.#quitTimes = SCROLL_QUIT_TIMES; this.#quitTimes = SCROLL_QUIT_TIMES;
@ -192,6 +210,30 @@ class Editor {
return true; return true;
} }
/**
* Filter out any additional unwanted keyboard input
* @param input
* @private
*/
private shouldFilter(input: string): boolean {
const isEscapeSequence = input[0] === KeyCommand.Escape;
const isCtrl = isControl(input);
const shouldFilter = isEscapeSequence || isCtrl;
const whitelist = ['\t'];
if (shouldFilter && !whitelist.includes(input)) {
logToFile({
'msg': `Ignoring input: ${input}`,
isEscapeSequence,
isCtrl,
});
return true;
}
return false;
}
private moveCursor(char: string): void { private moveCursor(char: string): void {
switch (char) { switch (char) {
case KeyCommand.ArrowLeft: case KeyCommand.ArrowLeft:
@ -200,18 +242,18 @@ class Editor {
} else if (this.#cursor.y > 0) { } else if (this.#cursor.y > 0) {
this.#cursor.y--; this.#cursor.y--;
this.#cursor.x = (this.currentRow !== null) this.#cursor.x = (this.currentRow !== null)
? this.currentRow.size - 1 ? this.currentRow.size
: 0; : 0;
} }
break; break;
case KeyCommand.ArrowRight: case KeyCommand.ArrowRight:
if ( if (
this.currentRow !== null && this.#cursor.x < this.currentRow.size - 1 this.currentRow !== null && this.#cursor.x < this.currentRow.size
) { ) {
this.#cursor.x++; this.#cursor.x++;
} else if ( } else if (
this.currentRow !== null && this.currentRow !== null &&
this.#cursor.x === this.currentRow.size - 1 this.#cursor.x === this.currentRow.size
) { ) {
this.#cursor.y++; this.#cursor.y++;
this.#cursor.x = 0; this.#cursor.x = 0;

View File

@ -1,4 +1,4 @@
import { chars, SCROLL_TAB_SIZE } from './mod.ts'; import { arrayInsert, chars, SCROLL_TAB_SIZE } from './mod.ts';
import * as Util from './utils.ts'; import * as Util from './utils.ts';
/** /**
@ -16,8 +16,8 @@ export class Row {
*/ */
render: string[] = []; render: string[] = [];
constructor(s: string = '') { private constructor(s: string | string[] = '') {
this.chars = Util.chars(s); this.chars = Array.isArray(s) ? s : Util.chars(s);
this.render = []; this.render = [];
} }
@ -33,6 +33,18 @@ export class Row {
return this.render.slice(offset).join(''); return this.render.slice(offset).join('');
} }
public static default(): Row {
return new Row();
}
public static from(s: string | string[] | Row): Row {
if (s instanceof Row) {
return s;
}
return new Row(s);
}
public append(s: string): void { public append(s: string): void {
this.chars = this.chars.concat(chars(s)); this.chars = this.chars.concat(chars(s));
this.updateRender(); this.updateRender();
@ -43,12 +55,18 @@ export class Row {
if (at >= this.size) { if (at >= this.size) {
this.chars = this.chars.concat(newSlice); this.chars = this.chars.concat(newSlice);
} else { } else {
const front = this.chars.slice(0, at + 1); this.chars = arrayInsert(this.chars, at + 1, newSlice);
const back = this.chars.slice(at + 1);
this.chars = front.concat(newSlice, back);
} }
} }
public split(at: number): Row {
const newRow = new Row(this.chars.slice(at));
this.chars = this.chars.slice(0, at);
this.updateRender();
return newRow;
}
public delete(at: number): void { public delete(at: number): void {
if (at >= this.size) { if (at >= this.size) {
return; return;

View File

@ -2,8 +2,32 @@
// Misc // Misc
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/**
* Insert a value into an array at the specified index
* @param arr - the array
* @param at - the index to insert at
* @param value - what to add into the array
*/
export function arrayInsert<T>(
arr: Array<T>,
at: number,
value: T | Array<T>,
): Array<T> {
const insert = Array.isArray(value) ? value : [value];
if (at >= arr.length) {
arr.push(...insert);
return arr;
}
return [...arr.slice(0, at), ...insert, ...arr.slice(at)];
}
export const noop = () => {}; export const noop = () => {};
// ----------------------------------------------------------------------------
// Math
// ----------------------------------------------------------------------------
/** /**
* Subtract two numbers, returning a zero if the result is negative * Subtract two numbers, returning a zero if the result is negative
* @param l * @param l