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),
|
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);
|
||||||
|
@ -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:
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user