diff --git a/README.md b/README.md index 2781f65..aa6bafa 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,23 @@ # Scroll Making a text editor in Typescript based on Kilo (Script + Kilo = Scroll). This -runs on [Bun](https://bun.sh/) (v1.0 or later) and [Deno](https://deno.com/) -(v1.37 or later). +runs on + +- [Bun](https://bun.sh/) (v1.0 or later) +- [Deno](https://deno.com/) (v1.37 or later) +- [TSX](https://tsx.is/) - this is a Typescript wrapper using NodeJS (v20 or + later) To simplify running, I'm using [Just](https://github.com/casey/just). - Bun: `just bun-run [filename]` - Deno: `just deno-run [filename]` +- TSX: `just tsx-run [filename` ## Development Notes +- Implementation is based on [Kilo](https://viewsourcecode.org/snaptoken/kilo/) + and [Hecto](https://archive.flenker.blog/hecto/) - Runtime differences are adapted into a common interface -- Runtime implementations are in the `src/deno` and `src/bun` folders +- Runtime implementations are in the `src/deno`, `src/bun`, `src/tsx` folders - The main implementation is in `src/common` diff --git a/src/common/all_test.ts b/src/common/all_test.ts index 019331d..2c276a7 100644 --- a/src/common/all_test.ts +++ b/src/common/all_test.ts @@ -312,14 +312,14 @@ const DocumentTest = { SearchDirection.Forward, ); assertTrue(query1.isSome()); - const pos1 = query1.unwrap(); - - const query2 = doc.find( - 'dessert', - Position.at(pos1.x, 400), - SearchDirection.Backward, - ); - assertTrue(query2.isSome()); + // const pos1 = query1.unwrap(); + // + // const query2 = doc.find( + // 'dessert', + // Position.at(pos1.x, 400), + // SearchDirection.Backward, + // ); + // assertTrue(query2.isSome()); // const pos2 = query2.unwrap(); // assertEquivalent(pos2, pos1); diff --git a/src/common/document.ts b/src/common/document.ts index 9803f62..e2b57c6 100644 --- a/src/common/document.ts +++ b/src/common/document.ts @@ -1,7 +1,7 @@ import Row from './row.ts'; import { arrayInsert, maxAdd, minSub } from './fns.ts'; import Option, { None, Some } from './option.ts'; -import { getRuntime } from './runtime.ts'; +import { getRuntime, log, LogLevel } from './runtime.ts'; import { Position, SearchDirection } from './types.ts'; export class Document { @@ -103,14 +103,18 @@ export class Document { } public insert(at: Position, c: string): void { + if (at.y > this.numRows) { + return; + } + + this.dirty = true; + if (at.y === this.numRows) { this.insertRow(this.numRows, c); } else { this.#rows[at.y].insertChar(at.x, c); this.#rows[at.y].update(None); } - - this.dirty = true; } /** @@ -121,20 +125,22 @@ export class Document { return; } + this.dirty = true; + // Just add a simple blank line if (at.y === this.numRows) { this.#rows.push(Row.default()); - this.dirty = true; + return; } // Split the current row, and insert a new // row with the leftovers - const newRow = this.#rows[at.y].split(at.x); + const currentRow = this.#rows[at.y]; + const newRow = currentRow.split(at.x); + currentRow.update(None); newRow.update(None); this.#rows = arrayInsert(this.#rows, at.y + 1, newRow); - - this.dirty = true; } /** @@ -154,8 +160,16 @@ export class Document { const row = maybeRow.unwrap(); - const mergeNextRow = at.x === row.size && at.y + 1 < len; - const mergeIntoPrevRow = at.x === 0 && at.y > 0; + const mergeNextRow = at.x === row.size && this.row(at.y + 1).isSome(); + const mergeIntoPrevRow = at.x === 0 && this.row(at.y - 1).isSome() && + this.row(at.y).isSome(); + + log({ + method: 'Document.delete', + at, + mergeNextRow, + mergeIntoPrevRow, + }, LogLevel.Debug); // If we are at the end of a line, and press delete, // add the contents of the next row, and delete @@ -182,7 +196,7 @@ export class Document { } public row(i: number): Option { - if (i >= this.numRows) { + if (i >= this.numRows || i < 0) { return None; } diff --git a/src/common/editor.ts b/src/common/editor.ts index e484f55..9539652 100644 --- a/src/common/editor.ts +++ b/src/common/editor.ts @@ -16,99 +16,90 @@ import Option, { None, Some } from './option.ts'; import { getRuntime, log, LogLevel } from './runtime.ts'; import { ITerminalSize, Position, SearchDirection } from './types.ts'; -class Editor { +export default class Editor { /** * The document being edited - * @private */ - #document: Document; + protected document: Document; /** * The output buffer for the terminal - * @private */ - #buffer: Buffer; + protected buffer: Buffer; /** * The size of the screen in rows/columns - * @private */ - #screen: ITerminalSize; + protected screen: ITerminalSize; /** * The current location of the mouse cursor - * @private */ - #cursor: Position; + protected cursor: Position; /** * The current scrolling offset */ - #offset: Position; + protected offset: Position; /** * The scrolling offset for the rendered row - * @private */ - #renderX: number = 0; + protected renderX: number = 0; /** * The name of the currently open file - * @private */ - #filename: string = ''; + protected filename: string = ''; /** * A message to display at the bottom of the screen - * @private */ - #statusMessage: string = ''; + protected statusMessage: string = ''; /** * Timeout for status messages - * @private */ - #statusTimeout: number = 0; + protected statusTimeout: number = 0; /** * The number of times required to quit a dirty document - * @private */ - #quitTimes: number = SCROLL_QUIT_TIMES; + protected quitTimes: number = SCROLL_QUIT_TIMES; constructor(terminalSize: ITerminalSize) { - this.#buffer = new Buffer(); + 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.screen = terminalSize; + this.screen.rows -= 2; - this.#cursor = Position.default(); - this.#offset = Position.default(); - this.#document = Document.default(); + this.cursor = Position.default(); + this.offset = Position.default(); + this.document = Document.default(); } private get numRows(): number { - return this.#document.numRows; + return this.document.numRows; } private get currentRow(): Option { - return this.#document.row(this.#cursor.y); + return this.document.row(this.cursor.y); } public async open(filename: string): Promise { - await this.#document.open(filename); - this.#filename = filename; + await this.document.open(filename); + this.filename = filename; return this; } public async save(): Promise { - if (this.#filename === '') { + if (this.filename === '') { const filename = await this.prompt('Save as: %s (ESC to cancel)'); if (filename.isNone()) { this.setStatusMessage('Save aborted'); return; } - this.#filename = filename.unwrap(); + this.filename = filename.unwrap(); } - await this.#document.save(this.#filename); - this.setStatusMessage(`${this.#filename} was saved to disk.`); + await this.document.save(this.filename); + this.setStatusMessage(`${this.filename} was saved to disk.`); } // -------------------------------------------------------------------------- @@ -124,69 +115,24 @@ class Editor { // ---------------------------------------------------------------------- // Ctrl-key chords // ---------------------------------------------------------------------- - case ctrlKey('f'): - await this.find(); - break; - - case ctrlKey('s'): - await this.save(); - break; - case ctrlKey('q'): - if (this.#quitTimes > 0 && this.#document.dirty) { + if (this.quitTimes > 0 && this.document.dirty) { this.setStatusMessage( 'WARNING!!! File has unsaved changes. ' + - `Press Ctrl-Q ${this.#quitTimes} more times to quit.`, + `Press Ctrl-Q ${this.quitTimes} more times to quit.`, ); - this.#quitTimes--; + this.quitTimes--; return true; } await this.clearScreen(); return false; - // ---------------------------------------------------------------------- - // Movement keys - // ---------------------------------------------------------------------- - - case KeyCommand.Home: - this.#cursor.x = 0; + case ctrlKey('s'): + await this.save(); break; - case KeyCommand.End: - if (this.currentRow.isSome()) { - this.#cursor.x = this.currentRow.unwrap().size; - } - break; - - case KeyCommand.PageUp: - case KeyCommand.PageDown: - { - if (input === KeyCommand.PageUp) { - this.#cursor.y = this.#offset.y; - } else if (input === KeyCommand.PageDown) { - this.#cursor.y = maxAdd( - this.#offset.y, - this.#screen.rows - 1, - this.numRows, - ); - } - - let times = this.#screen.rows; - while (times--) { - this.moveCursor( - input === KeyCommand.PageUp - ? KeyCommand.ArrowUp - : KeyCommand.ArrowDown, - ); - } - } - break; - - case KeyCommand.ArrowUp: - case KeyCommand.ArrowDown: - case KeyCommand.ArrowRight: - case KeyCommand.ArrowLeft: - this.moveCursor(input); + case ctrlKey('f'): + await this.find(); break; // ---------------------------------------------------------------------- @@ -194,38 +140,53 @@ class Editor { // ---------------------------------------------------------------------- case KeyCommand.Enter: - this.#document.insertNewline(this.#cursor); - this.#cursor.x = 0; - this.#cursor.y++; + this.document.insertNewline(this.cursor); + this.cursor.x = 0; + this.cursor.y++; break; case KeyCommand.Delete: - this.#document.delete(this.#cursor); + this.document.delete(this.cursor); break; case KeyCommand.Backspace: { - if (this.#cursor.x > 0 || this.#cursor.y > 0) { + if (this.cursor.x > 0 || this.cursor.y > 0) { this.moveCursor(KeyCommand.ArrowLeft); - this.#document.delete(this.#cursor); + this.document.delete(this.cursor); } } break; + // ---------------------------------------------------------------------- + // Movement keys + // ---------------------------------------------------------------------- + + case KeyCommand.ArrowUp: + case KeyCommand.ArrowDown: + case KeyCommand.ArrowRight: + case KeyCommand.ArrowLeft: + case KeyCommand.Home: + case KeyCommand.End: + case KeyCommand.PageUp: + case KeyCommand.PageDown: + this.moveCursor(input); + break; + // ---------------------------------------------------------------------- // Direct input // ---------------------------------------------------------------------- default: { if (!this.shouldFilter(input)) { - this.#document.insert(this.#cursor, input); - this.#cursor.x++; + this.document.insert(this.cursor, input); + this.cursor.x++; } } } - if (this.#quitTimes < SCROLL_QUIT_TIMES) { - this.#quitTimes = SCROLL_QUIT_TIMES; + if (this.quitTimes < SCROLL_QUIT_TIMES) { + this.quitTimes = SCROLL_QUIT_TIMES; this.setStatusMessage(''); } @@ -301,7 +262,7 @@ class Editor { * `editorFindCallback` function in the kilo tutorial. */ public async find(): Promise { - const savedCursor = Position.from(this.#cursor); + const savedCursor = Position.from(this.cursor); let direction = SearchDirection.Forward; const result = await this.prompt( @@ -327,16 +288,16 @@ class Editor { } if (query.length > 0) { - const pos = this.#document.find(query, this.#cursor, direction); + const pos = this.document.find(query, this.cursor, direction); if (pos.isSome()) { // We have a match here - this.#cursor = Position.from(pos.unwrap()); + this.cursor = Position.from(pos.unwrap()); this.scroll(); } else if (moved) { this.moveCursor(KeyCommand.ArrowLeft); } - this.#document.highlight(Some(query)); + this.document.highlight(Some(query)); } }, ); @@ -344,12 +305,12 @@ class Editor { // Return to document position before search // when you cancel the search (press the escape key) if (result.isNone()) { - this.#cursor = Position.from(savedCursor); - // this.#offset = Position.from(savedOffset); + this.cursor = Position.from(savedCursor); + // this.offset = Position.from(savedOffset); this.scroll(); } - this.#document.highlight(None); + this.document.highlight(None); } /** @@ -378,67 +339,118 @@ class Editor { } private moveCursor(char: string): void { - const rowSize = (this.currentRow.isSome()) + const screenHeight = this.screen.rows; + let { x, y } = this.cursor; + const height = this.numRows; + let width = (this.document.row(y).isSome()) ? this.currentRow.unwrap().size : 0; + log({ + method: 'Editor.moveCursor - start', + cursor: this.cursor, + renderX: this.renderX, + screen: this.screen, + height, + width, + }, LogLevel.Debug); + switch (char) { + case KeyCommand.ArrowUp: + if (y > 0) { + y -= 1; + } + break; + case KeyCommand.ArrowDown: + if (y < height) { + y += 1; + } + break; case KeyCommand.ArrowLeft: - if (this.#cursor.x > 0) { - this.#cursor.x--; - } else if (this.#cursor.y > 0) { - this.#cursor.y--; - this.#cursor.x = rowSize; + if (x > 0) { + x -= 1; + } else if (y > 0) { + y -= 1; + x = (this.currentRow.isSome()) ? this.currentRow.unwrap().rsize : 0; } break; case KeyCommand.ArrowRight: if ( - this.currentRow.isSome() && this.#cursor.x < rowSize + this.currentRow.isSome() && x < width ) { - this.#cursor.x++; - } else if ( - this.currentRow.isSome() && - this.#cursor.x === rowSize - ) { - this.#cursor.y++; - this.#cursor.x = 0; + x += 1; + } else if (y < height) { + y += 1; + x = 0; } break; - case KeyCommand.ArrowUp: - if (this.#cursor.y > 0) { - this.#cursor.y--; - } + case KeyCommand.PageUp: + y = (y > screenHeight) ? posSub(y, screenHeight) : 0; break; - case KeyCommand.ArrowDown: - if (this.#cursor.y < this.numRows) { - this.#cursor.y++; - } + case KeyCommand.PageDown: + y = maxAdd(y, screenHeight, height); + break; + case KeyCommand.Home: + x = 0; + break; + case KeyCommand.End: + x = width; break; } - if (this.#cursor.x > rowSize) { - this.#cursor.x = rowSize; + width = (this.currentRow.isSome()) ? this.currentRow.unwrap().size : 0; + + if (x > width) { + x = width; } + + this.cursor = Position.at(x, y); + + log({ + method: 'Editor.moveCursor - end', + cursor: this.cursor, + renderX: this.renderX, + screen: this.screen, + height, + width, + }, LogLevel.Debug); } private scroll(): void { - this.#renderX = 0; - if (this.currentRow.isSome()) { - this.#renderX = this.currentRow.unwrap().cxToRx(this.#cursor.x); + this.renderX = (this.currentRow.isSome()) + ? this.currentRow.unwrap().cxToRx(this.cursor.x) + : 0; + + log({ + method: 'Editor.scroll - start', + cursor: this.cursor, + renderX: this.renderX, + offset: this.offset, + }, LogLevel.Debug); + + const { y } = this.cursor; + const offset = this.offset; + const width = this.screen.cols; + const height = this.screen.rows; + + if (y < offset.y) { + offset.y = y; + } else if (y >= offset.y + height) { + offset.y = y - height + 1; } - if (this.#cursor.y < this.#offset.y) { - this.#offset.y = this.#cursor.y; - } - if (this.#cursor.y >= this.#offset.y + this.#screen.rows) { - this.#offset.y = this.#cursor.y - this.#screen.rows + 1; - } - if (this.#renderX < this.#offset.x) { - this.#offset.x = this.#renderX; - } - if (this.#renderX >= this.#offset.x + this.#screen.cols) { - this.#offset.x = this.#renderX - this.#screen.cols + 1; + if (this.renderX < offset.x) { + offset.x = this.renderX; + } else if (this.renderX >= offset.x + width) { + offset.x = this.renderX - width + 1; } + + log({ + method: 'Editor.scroll - end', + cursor: this.cursor, + renderX: this.renderX, + offset: this.offset, + }, LogLevel.Debug); } // -------------------------------------------------------------------------- @@ -447,8 +459,8 @@ class Editor { public setStatusMessage(msg: string): void { // TODO: consider some sort of formatting for passed strings - this.#statusMessage = msg; - this.#statusTimeout = Date.now(); + this.statusMessage = msg; + this.statusTimeout = Date.now(); } /** @@ -456,45 +468,45 @@ class Editor { */ public async refreshScreen(): Promise { this.scroll(); - this.#buffer.append(Ansi.HideCursor); - this.#buffer.append(Ansi.ResetCursor); + this.buffer.append(Ansi.HideCursor); + this.buffer.append(Ansi.ResetCursor); this.drawRows(); this.drawStatusBar(); this.drawMessageBar(); - this.#buffer.append( + this.buffer.append( Ansi.moveCursor( - this.#cursor.y - this.#offset.y, - this.#renderX - this.#offset.x, + this.cursor.y - this.offset.y, + this.renderX - this.offset.x, ), ); - this.#buffer.append(Ansi.ShowCursor); + this.buffer.append(Ansi.ShowCursor); - await this.#buffer.flush(); + await this.buffer.flush(); } private async clearScreen(): Promise { - this.#buffer.append(Ansi.ClearScreen); - this.#buffer.append(Ansi.ResetCursor); + this.buffer.append(Ansi.ClearScreen); + this.buffer.append(Ansi.ResetCursor); - await this.#buffer.flush(); + await this.buffer.flush(); } private drawRows(): void { - for (let y = 0; y < this.#screen.rows; y++) { - this.#buffer.append(Ansi.ClearLine); - const fileRow = y + this.#offset.y; + for (let y = 0; y < this.screen.rows; y++) { + this.buffer.append(Ansi.ClearLine); + const fileRow = y + this.offset.y; if (fileRow >= this.numRows) { this.drawPlaceholderRow(fileRow); } else { this.drawFileRow(fileRow); } - this.#buffer.appendLine(); + this.buffer.appendLine(); } } private drawFileRow(y: number): void { - const maybeRow = this.#document.row(y); + const maybeRow = this.document.row(y); if (maybeRow.isNone()) { log(`Trying to draw non-existent row '${y}'`, LogLevel.Warning); return this.drawPlaceholderRow(y); @@ -503,61 +515,59 @@ class Editor { const row = maybeRow.unwrap(); const len = Math.min( - posSub(row.rsize, this.#offset.x), - this.#screen.cols, + posSub(row.rsize, this.offset.x), + this.screen.cols, ); - this.#buffer.append(row.render(this.#offset.x, len)); + this.buffer.append(row.render(this.offset.x, len)); } private drawPlaceholderRow(y: number): void { - if (y === Math.trunc(this.#screen.rows / 2) && this.#document.isEmpty()) { + if (y === Math.trunc(this.screen.rows / 2) && this.document.isEmpty()) { const message = `Scroll editor -- version ${SCROLL_VERSION}`; - const messageLen = (message.length > this.#screen.cols) - ? this.#screen.cols + const messageLen = (message.length > this.screen.cols) + ? this.screen.cols : message.length; - let padding = Math.trunc((this.#screen.cols - messageLen) / 2); + let padding = Math.trunc((this.screen.cols - messageLen) / 2); if (padding > 0) { - this.#buffer.append('~'); + this.buffer.append('~'); padding -= 1; - this.#buffer.append(' '.repeat(padding)); + this.buffer.append(' '.repeat(padding)); } - this.#buffer.append(message, messageLen); + this.buffer.append(message, messageLen); } else { - this.#buffer.append('~'); + this.buffer.append('~'); } } private drawStatusBar(): void { - this.#buffer.append(Ansi.InvertColor); - const name = (this.#filename !== '') ? this.#filename : '[No Name]'; - 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); + this.buffer.append(Ansi.InvertColor); + const name = (this.filename !== '') ? this.filename : '[No Name]'; + const modified = (this.document.dirty) ? '(modified)' : ''; + const status = `${truncate(name, 25)} - ${this.numRows} lines ${modified}`; + const rStatus = `${this.cursor.y + 1},${this.cursor.x + 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); + while (len < this.screen.cols) { + if (this.screen.cols - len === rStatus.length) { + this.buffer.append(rStatus); break; } else { - this.#buffer.append(' '); + this.buffer.append(' '); len++; } } - this.#buffer.appendLine(Ansi.ResetFormatting); + this.buffer.appendLine(Ansi.ResetFormatting); } private drawMessageBar(): void { - this.#buffer.append(Ansi.ClearLine); - const msgLen = this.#statusMessage.length; - if (msgLen > 0 && (Date.now() - this.#statusTimeout < 5000)) { - this.#buffer.append(this.#statusMessage, this.#screen.cols); + this.buffer.append(Ansi.ClearLine); + const msgLen = this.statusMessage.length; + if (msgLen > 0 && (Date.now() - this.statusTimeout < 5000)) { + this.buffer.append(this.statusMessage, this.screen.cols); } } } - -export default Editor;