import Ansi, { KeyCommand } from './ansi.ts'; import Buffer from './buffer.ts'; import Document from './document.ts'; import Row from './row.ts'; import { SCROLL_QUIT_TIMES, SCROLL_VERSION } from './config.ts'; import { ctrlKey, isControl, maxAdd, posSub, readKey, truncate, } from './fns.ts'; import { getRuntime, log, LogLevel } from './runtime.ts'; import { ITerminalSize, Position } from './types.ts'; class Editor { /** * The document being edited * @private */ #document: Document; /** * The output buffer for the terminal * @private */ #buffer: Buffer; /** * The size of the screen in rows/columns * @private */ #screen: ITerminalSize; /** * The current location of the mouse cursor * @private */ #cursor: Position; /** * The current scrolling offset */ #offset: Position; /** * The scrolling offset for the rendered row * @private */ #renderX: number = 0; /** * The name of the currently open file * @private */ #filename: string = ''; /** * A message to display at the bottom of the screen * @private */ #statusMessage: string = ''; /** * Timeout for status messages * @private */ #statusTimeout: number = 0; /** * The number of times required to quit a dirty document * @private */ #quitTimes: number = SCROLL_QUIT_TIMES; 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 = Position.default(); this.#offset = Position.default(); this.#document = Document.default(); } private get numRows(): number { return this.#document.numRows; } private get currentRow(): Row | null { return this.#document.row(this.#cursor.y); } public async open(filename: string): Promise { await this.#document.open(filename); this.#filename = filename; return this; } public async save(): Promise { if (this.#filename === '') { const filename = await this.prompt('Save as: %s (ESC to cancel)'); if (filename === null) { this.setStatusMessage('Save aborted'); return; } this.#filename = filename; } await this.#document.save(this.#filename); this.setStatusMessage(`${this.#filename} was saved to disk.`); } // -------------------------------------------------------------------------- // Command/input mapping // -------------------------------------------------------------------------- /** * Determine what to do based on input * @param input - the decoded chunk of stdin */ public async processKeyPress(input: string): Promise { switch (input) { // ---------------------------------------------------------------------- // 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) { this.setStatusMessage( 'WARNING!!! File has unsaved changes. ' + `Press Ctrl-Q ${this.#quitTimes} more times to quit.`, ); this.#quitTimes--; return true; } await this.clearScreen(); return false; // ---------------------------------------------------------------------- // Movement keys // ---------------------------------------------------------------------- case KeyCommand.Home: this.#cursor.x = 0; break; case KeyCommand.End: if (this.currentRow !== null) { this.#cursor.x = this.currentRow.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); break; // ---------------------------------------------------------------------- // Text manipulation keys // ---------------------------------------------------------------------- case KeyCommand.Enter: this.#document.insertNewline(this.#cursor); this.#cursor.x = 0; this.#cursor.y++; 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; // ---------------------------------------------------------------------- // Direct input // ---------------------------------------------------------------------- default: { if (!this.shouldFilter(input)) { this.#document.insert(this.#cursor, input); this.#cursor.x++; } } } if (this.#quitTimes < SCROLL_QUIT_TIMES) { this.#quitTimes = SCROLL_QUIT_TIMES; this.setStatusMessage(''); } return true; } public async prompt( p: string, callback?: (query: string, char: string) => void, ): Promise { const { term } = await getRuntime(); let res = ''; const maybeCallback = (query: string, char: string) => { if (callback !== undefined) { callback(query, char); } }; outer: while (true) { if (p.includes('%s')) { this.setStatusMessage(p.replace('%s', res)); } else { this.setStatusMessage(`${p}${res}`); } await this.refreshScreen(); for await (const chunk of term.inputLoop()) { const char = readKey(chunk); if (char === null) { continue; } switch (char) { // Remove the last character from the prompt input case KeyCommand.Backspace: case KeyCommand.Delete: res = truncate(res, res.length - 1); maybeCallback(res, char); continue outer; // End the prompt case KeyCommand.Escape: this.setStatusMessage(''); maybeCallback(res, char); return null; // Return the input and end the prompt case KeyCommand.Enter: if (res.length > 0) { this.setStatusMessage(''); maybeCallback(res, char); return res; } break; // Add to the prompt result default: if (!isControl(char)) { res += char; } } maybeCallback(res, char); } } } /** * Find text within the document. This is roughly equivalent to the * `editorFindCallback` function in the kilo tutorial. */ public async find(): Promise { const savedCursor = Position.from(this.#cursor); const savedOffset = Position.from(this.#offset); const query = await this.prompt( 'Search: %s (Use ESC/Arrows/Enter)', (query: string, key: string) => { if (key === KeyCommand.Enter || key === KeyCommand.Escape) { if (key === KeyCommand.Escape) { this.#document.resetFind(); } return null; } if (query !== null && query.length > 0) { const pos = this.#document.find(query, key); if (pos !== null) { // We have a match here this.#cursor = pos; this.scroll(); } else { this.setStatusMessage('Not found'); } } }, ); // Return to document position before search // when you cancel the search (press the escape key) if (query === null) { this.#cursor = Position.from(savedCursor); this.#offset = Position.from(savedOffset); } } /** * 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)) { log({ 'msg': `Ignoring input: ${input}`, isEscapeSequence, isCtrl, }, LogLevel.Debug); return true; } return false; } private moveCursor(char: string): void { switch (char) { case KeyCommand.ArrowLeft: if (this.#cursor.x > 0) { this.#cursor.x--; } else if (this.#cursor.y > 0) { this.#cursor.y--; this.#cursor.x = (this.currentRow !== null) ? this.currentRow.size : 0; } break; case KeyCommand.ArrowRight: if ( this.currentRow !== null && this.#cursor.x < this.currentRow.size ) { this.#cursor.x++; } else if ( this.currentRow !== null && this.#cursor.x === this.currentRow.size ) { this.#cursor.y++; this.#cursor.x = 0; } break; case KeyCommand.ArrowUp: if (this.#cursor.y > 0) { this.#cursor.y--; } break; case KeyCommand.ArrowDown: if (this.#cursor.y < this.numRows) { this.#cursor.y++; } break; } const rowLen = this.currentRow?.size ?? 0; if (this.#cursor.x > rowLen) { this.#cursor.x = rowLen; } } private scroll(): void { this.#renderX = 0; if (this.currentRow !== null) { this.#renderX = this.currentRow.cxToRx(this.#cursor.x); } 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; } } // -------------------------------------------------------------------------- // Terminal Output / Drawing // -------------------------------------------------------------------------- public setStatusMessage(msg: string): void { // TODO: consider some sort of formatting for passed strings this.#statusMessage = msg; this.#statusTimeout = Date.now(); } /** * Clear the screen and write out the buffer */ public async refreshScreen(): Promise { this.scroll(); this.#buffer.append(Ansi.HideCursor); this.#buffer.append(Ansi.ResetCursor); this.drawRows(); this.drawStatusBar(); this.drawMessageBar(); this.#buffer.append( Ansi.moveCursor( this.#cursor.y - this.#offset.y, this.#renderX - this.#offset.x, ), ); this.#buffer.append(Ansi.ShowCursor); await this.#buffer.flush(); } private async clearScreen(): Promise { this.#buffer.append(Ansi.ClearScreen); this.#buffer.append(Ansi.ResetCursor); 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; if (fileRow >= this.numRows) { this.drawPlaceholderRow(fileRow); } else { this.drawFileRow(fileRow); } this.#buffer.appendLine(); } } private drawFileRow(y: number): void { const row = this.#document.row(y); if (row === null) { log(`Trying to draw non-existent row '${y}'`, LogLevel.Warning); return this.drawPlaceholderRow(y); } const len = Math.min( posSub(row.rsize, this.#offset.x), this.#screen.cols, ); 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()) { const message = `Scroll editor -- version ${SCROLL_VERSION}`; const messageLen = (message.length > this.#screen.cols) ? this.#screen.cols : message.length; let padding = Math.trunc((this.#screen.cols - messageLen) / 2); if (padding > 0) { this.#buffer.append('~'); padding -= 1; this.#buffer.append(' '.repeat(padding)); } this.#buffer.append(message, messageLen); } else { 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); while (len < this.#screen.cols) { if (this.#screen.cols - len === rStatus.length) { this.#buffer.append(rStatus); break; } else { this.#buffer.append(' '); len++; } } 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); } } } export default Editor;