From 95c979066a5d7cb059b464e52bda8b30c57a3e92 Mon Sep 17 00:00:00 2001 From: "Timothy J. Warren" Date: Tue, 21 Nov 2023 15:14:08 -0500 Subject: [PATCH] Add text insertion --- src/bun/file_io.ts | 4 ++ src/common/all_test.ts | 86 +++++++++++++++++++----------------------- src/common/ansi.ts | 12 ++++++ src/common/document.ts | 78 +++++++++++++++++++------------------- src/common/editor.ts | 78 ++++++++++++++++++++++++++------------ src/common/main.ts | 11 +++--- src/common/mod.ts | 2 + src/common/row.ts | 65 +++++++++++++++++++++++++++++++ src/common/runtime.ts | 1 + src/common/types.ts | 19 ++++++++-- src/deno/ffi.ts | 2 +- src/deno/file_io.ts | 3 ++ 12 files changed, 238 insertions(+), 123 deletions(-) create mode 100644 src/common/row.ts diff --git a/src/bun/file_io.ts b/src/bun/file_io.ts index 2954de1..d1e7ae8 100644 --- a/src/bun/file_io.ts +++ b/src/bun/file_io.ts @@ -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 { + await Bun.write(path, contents); + return; + }, }; export default BunFileIO; diff --git a/src/common/all_test.ts b/src/common/all_test.ts index eb37155..11108d2 100644 --- a/src/common/all_test.ts +++ b/src/common/all_test.ts @@ -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); +}); diff --git a/src/common/ansi.ts b/src/common/ansi.ts index 3a4ba13..6860ca6 100644 --- a/src/common/ansi.ts +++ b/src/common/ansi.ts @@ -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; } diff --git a/src/common/document.ts b/src/common/document.ts index c8aaa51..4710260 100644 --- a/src/common/document.ts +++ b/src/common/document.ts @@ -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; diff --git a/src/common/editor.ts b/src/common/editor.ts index 379a143..157a28d 100644 --- a/src/common/editor.ts +++ b/src/common/editor.ts @@ -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 { 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; diff --git a/src/common/main.ts b/src/common/main.ts index bf7a3d8..9afc4e2 100644 --- a/src/common/main.ts +++ b/src/common/main.ts @@ -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; } diff --git a/src/common/mod.ts b/src/common/mod.ts index 3619bbd..648d6cb 100644 --- a/src/common/mod.ts +++ b/src/common/mod.ts @@ -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'; diff --git a/src/common/row.ts b/src/common/row.ts new file mode 100644 index 0000000..3ac252d --- /dev/null +++ b/src/common/row.ts @@ -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; diff --git a/src/common/runtime.ts b/src/common/runtime.ts index 0875e2a..b06dcc1 100644 --- a/src/common/runtime.ts +++ b/src/common/runtime.ts @@ -84,6 +84,7 @@ export interface IFileIO { openFileSync(path: string): string; appendFile(path: string, contents: string): Promise; appendFileSync(path: string, contents: string): void; + saveFile(path: string, contents: string): Promise; } /** diff --git a/src/common/types.ts b/src/common/types.ts index fcc3c76..9bfa800 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -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); + } } // ---------------------------------------------------------------------------- diff --git a/src/deno/ffi.ts b/src/deno/ffi.ts index ab3012c..01c8786 100644 --- a/src/deno/ffi.ts +++ b/src/deno/ffi.ts @@ -39,7 +39,7 @@ const DenoFFI: IFFI = { tcsetattr, cfmakeraw, getPointer: Deno.UnsafePointer.of, - close: () => {}, + close: cStdLib.close, }; export default DenoFFI; diff --git a/src/deno/file_io.ts b/src/deno/file_io.ts index 13a8727..f424e91 100644 --- a/src/deno/file_io.ts +++ b/src/deno/file_io.ts @@ -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 { + return await Deno.writeTextFile(path, contents); + }, }; export default DenoFileIO;