Polish text editing functionality
This commit is contained in:
parent
2babbf5c68
commit
ddb5eb783e
@ -60,6 +60,7 @@ testSuite({
|
||||
testKeyMap(['\x1b[1~', '\x1b[7~', '\x1b[H', '\x1bOH'], KeyCommand.Home),
|
||||
'readKey End': () =>
|
||||
testKeyMap(['\x1b[4~', '\x1b[8~', '\x1b[F', '\x1bOF'], KeyCommand.End),
|
||||
'readKey Enter': () => testKeyMap(['\n', '\r', '\v'], KeyCommand.Enter),
|
||||
},
|
||||
Buffer: {
|
||||
'Buffer exists': () => {
|
||||
@ -107,9 +108,9 @@ testSuite({
|
||||
assertTrue(doc.isEmpty());
|
||||
assertEquals(doc.row(0), null);
|
||||
},
|
||||
'Document.appendRow': () => {
|
||||
'Document.insertRow': () => {
|
||||
const doc = Document.empty();
|
||||
doc.appendRow('foobar');
|
||||
doc.insertRow(undefined, 'foobar');
|
||||
assertEquals(doc.numRows, 1);
|
||||
assertFalse(doc.isEmpty());
|
||||
assertInstanceOf(doc.row(0), Row);
|
||||
@ -163,12 +164,43 @@ testSuite({
|
||||
},
|
||||
},
|
||||
Row: {
|
||||
'new Row': () => {
|
||||
const row = new Row();
|
||||
'Row.default': () => {
|
||||
const row = Row.default();
|
||||
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': {
|
||||
'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': () => {
|
||||
assertExists(Util.noop);
|
||||
assertEquals(Util.noop(), undefined);
|
||||
|
@ -17,10 +17,10 @@ export enum KeyCommand {
|
||||
PageUp = ANSI_PREFIX + '5~',
|
||||
PageDown = ANSI_PREFIX + '6~',
|
||||
Enter = '\r',
|
||||
Escape = '\x1b',
|
||||
Backspace = '\x7f',
|
||||
|
||||
// These keys have several possible escape sequences
|
||||
Escape = 'EscapeKey',
|
||||
Backspace = 'BackspaceKey',
|
||||
Home = 'LineHome',
|
||||
End = 'LineEnd',
|
||||
}
|
||||
@ -71,12 +71,14 @@ export function readKey(parsed: string): string {
|
||||
case '\x1b[F':
|
||||
return KeyCommand.End;
|
||||
|
||||
case '\n':
|
||||
case '\v':
|
||||
return KeyCommand.Enter;
|
||||
|
||||
case ctrlKey('l'):
|
||||
case '\x1b':
|
||||
return KeyCommand.Escape;
|
||||
|
||||
case ctrlKey('h'):
|
||||
case '\x7f':
|
||||
return KeyCommand.Backspace;
|
||||
|
||||
default:
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Row from './row.ts';
|
||||
import { getRuntime } from './runtime.ts';
|
||||
import { Position } from './mod.ts';
|
||||
import { arrayInsert, Position } from './mod.ts';
|
||||
|
||||
export class Document {
|
||||
#filename: string | null;
|
||||
@ -35,7 +35,7 @@ export class Document {
|
||||
|
||||
const rawFile = await file.openFile(filename);
|
||||
rawFile.split(/\r?\n/)
|
||||
.forEach((row) => this.appendRow(row));
|
||||
.forEach((row) => this.insertRow(this.numRows, row));
|
||||
|
||||
this.#filename = filename;
|
||||
this.dirty = false;
|
||||
@ -57,7 +57,7 @@ export class Document {
|
||||
|
||||
public insert(at: Position, c: string): void {
|
||||
if (at.y === this.numRows) {
|
||||
this.appendRow(c);
|
||||
this.insertRow(this.numRows, c);
|
||||
} else {
|
||||
this.#rows[at.y].insertChar(at.x, c);
|
||||
this.#rows[at.y].updateRender();
|
||||
@ -66,6 +66,23 @@ export class Document {
|
||||
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 {
|
||||
const len = this.numRows;
|
||||
if (at.y >= len) {
|
||||
@ -73,7 +90,7 @@ export class Document {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// If we are at the end of a line, and press delete,
|
||||
@ -104,9 +121,8 @@ export class Document {
|
||||
return this.#rows[i] ?? null;
|
||||
}
|
||||
|
||||
public appendRow(s: string): void {
|
||||
const at = this.numRows;
|
||||
this.#rows[at] = new Row(s);
|
||||
public insertRow(at: number = this.numRows, s: string = ''): void {
|
||||
this.#rows = arrayInsert(this.#rows, at, Row.from(s));
|
||||
this.#rows[at].updateRender();
|
||||
|
||||
this.dirty = true;
|
||||
@ -121,6 +137,10 @@ export class Document {
|
||||
this.#rows.splice(at, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the array of row objects into one string
|
||||
* @private
|
||||
*/
|
||||
private rowsToString(): string {
|
||||
return this.#rows.map((r) => r.toString()).join('\n');
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import Ansi, { KeyCommand } from './ansi.ts';
|
||||
import Buffer from './buffer.ts';
|
||||
import Document from './document.ts';
|
||||
import {
|
||||
isControl,
|
||||
ITerminalSize,
|
||||
logToFile,
|
||||
Position,
|
||||
@ -101,8 +102,12 @@ class Editor {
|
||||
*/
|
||||
public async processKeyPress(input: string): Promise<boolean> {
|
||||
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;
|
||||
|
||||
case ctrlKey('q'):
|
||||
@ -117,10 +122,9 @@ class Editor {
|
||||
await this.clearScreen();
|
||||
return false;
|
||||
|
||||
case ctrlKey('s'):
|
||||
await this.#document.save();
|
||||
this.setStatusMessage(`${this.#filename} was saved to disk.`);
|
||||
break;
|
||||
// ----------------------------------------------------------------------
|
||||
// Movement keys
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
case KeyCommand.Home:
|
||||
this.#cursor.x = 0;
|
||||
@ -128,20 +132,7 @@ class Editor {
|
||||
|
||||
case KeyCommand.End:
|
||||
if (this.currentRow !== null) {
|
||||
this.#cursor.x = this.currentRow.size - 1;
|
||||
}
|
||||
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);
|
||||
}
|
||||
this.#cursor.x = this.currentRow.size;
|
||||
}
|
||||
break;
|
||||
|
||||
@ -176,12 +167,39 @@ class Editor {
|
||||
this.moveCursor(input);
|
||||
break;
|
||||
|
||||
case KeyCommand.Escape:
|
||||
// ----------------------------------------------------------------------
|
||||
// Text manipulation keys
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
case KeyCommand.Enter:
|
||||
this.#document.insertNewline(this.#cursor);
|
||||
this.#cursor.x = 0;
|
||||
this.#cursor.y++;
|
||||
break;
|
||||
|
||||
default:
|
||||
this.#document.insert(this.#cursor, input);
|
||||
this.#cursor.x++;
|
||||
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.#cursor.x++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#quitTimes < SCROLL_QUIT_TIMES) {
|
||||
@ -192,6 +210,30 @@ class Editor {
|
||||
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 {
|
||||
switch (char) {
|
||||
case KeyCommand.ArrowLeft:
|
||||
@ -200,18 +242,18 @@ class Editor {
|
||||
} else if (this.#cursor.y > 0) {
|
||||
this.#cursor.y--;
|
||||
this.#cursor.x = (this.currentRow !== null)
|
||||
? this.currentRow.size - 1
|
||||
? this.currentRow.size
|
||||
: 0;
|
||||
}
|
||||
break;
|
||||
case KeyCommand.ArrowRight:
|
||||
if (
|
||||
this.currentRow !== null && this.#cursor.x < this.currentRow.size - 1
|
||||
this.currentRow !== null && this.#cursor.x < this.currentRow.size
|
||||
) {
|
||||
this.#cursor.x++;
|
||||
} else if (
|
||||
this.currentRow !== null &&
|
||||
this.#cursor.x === this.currentRow.size - 1
|
||||
this.#cursor.x === this.currentRow.size
|
||||
) {
|
||||
this.#cursor.y++;
|
||||
this.#cursor.x = 0;
|
||||
|
@ -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';
|
||||
|
||||
/**
|
||||
@ -16,8 +16,8 @@ export class Row {
|
||||
*/
|
||||
render: string[] = [];
|
||||
|
||||
constructor(s: string = '') {
|
||||
this.chars = Util.chars(s);
|
||||
private constructor(s: string | string[] = '') {
|
||||
this.chars = Array.isArray(s) ? s : Util.chars(s);
|
||||
this.render = [];
|
||||
}
|
||||
|
||||
@ -33,6 +33,18 @@ export class Row {
|
||||
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 {
|
||||
this.chars = this.chars.concat(chars(s));
|
||||
this.updateRender();
|
||||
@ -43,12 +55,18 @@ export class Row {
|
||||
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);
|
||||
this.chars = arrayInsert(this.chars, at + 1, newSlice);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (at >= this.size) {
|
||||
return;
|
||||
|
@ -2,8 +2,32 @@
|
||||
// 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 = () => {};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Math
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subtract two numbers, returning a zero if the result is negative
|
||||
* @param l
|
||||
|
Loading…
Reference in New Issue
Block a user