From ddb5eb783e5ca12b12447adc4a41f5b21362ce8f Mon Sep 17 00:00:00 2001 From: "Timothy J. Warren" Date: Wed, 22 Nov 2023 17:09:41 -0500 Subject: [PATCH] Polish text editing functionality --- src/common/all_test.ts | 40 ++++++++++++++++-- src/common/ansi.ts | 10 +++-- src/common/document.ts | 34 ++++++++++++--- src/common/editor.ts | 96 ++++++++++++++++++++++++++++++------------ src/common/row.ts | 30 ++++++++++--- src/common/utils.ts | 24 +++++++++++ 6 files changed, 186 insertions(+), 48 deletions(-) diff --git a/src/common/all_test.ts b/src/common/all_test.ts index 1aaab5c..3be6288 100644 --- a/src/common/all_test.ts +++ b/src/common/all_test.ts @@ -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); diff --git a/src/common/ansi.ts b/src/common/ansi.ts index c6486fa..d29d31b 100644 --- a/src/common/ansi.ts +++ b/src/common/ansi.ts @@ -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: diff --git a/src/common/document.ts b/src/common/document.ts index f1b053a..511269e 100644 --- a/src/common/document.ts +++ b/src/common/document.ts @@ -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'); } diff --git a/src/common/editor.ts b/src/common/editor.ts index 3a941f5..2a18424 100644 --- a/src/common/editor.ts +++ b/src/common/editor.ts @@ -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 { 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; diff --git a/src/common/row.ts b/src/common/row.ts index 1670ca6..2a1333c 100644 --- a/src/common/row.ts +++ b/src/common/row.ts @@ -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; diff --git a/src/common/utils.ts b/src/common/utils.ts index da7c1f4..ce02b63 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -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( + arr: Array, + at: number, + value: T | Array, +): Array { + 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