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, some, strlen, truncate, } from './fns.ts'; import { log, LogLevel } from './runtime.ts'; import { ITerminalSize, Position } from './types.ts'; export enum EditorMode { Normal = 'Normal', Find = 'Find', Save = 'Save', Prompt = 'Prompt', } class Prompt { private constructor( public basePrompt: string, private callback: (query: string, char: string) => void, public key: string = '', public answer: string = '', ) {} public static from( basePrompt: string, callback: (query: string, char: string) => void = ( query: string, key: string, ) => { log('Default prompt callback', LogError.Warning); }, ): Prompt { return new Prompt(basePrompt, callback); } public backspace(): void { this.answer = truncate(this.answer, strlen(this.answer) - 1); } public append(char: string): void { this.answer += char; } public cb(): void { this.callback(this.answer, this.key); } public render(): string { if (this.basePrompt.includes('%s')) { return this.basePrompt.replace('%s', this.answer); } else { return `${this.basePrompt}${this.answer}`; } } } class Editor { /** * How to handle the stdin stream * @private */ public mode: EditorMode = EditorMode.Normal; /** * The document being edited * @private */ public document: Document; /** * The output buffer for the terminal * @private */ protected buffer: Buffer; /** * The size of the screen in rows/columns * @private */ protected screen: ITerminalSize; /** * The current location of the mouse cursor * @private */ protected cursor: Position; /** * The current scrolling offset */ protected offset: Position; /** * The scrolling offset for the rendered row * @private */ protected renderX: number = 0; /** * The name of the currently open file * @private */ protected filename: string = ''; /** * Current input prompt state */ public _prompt: Prompt | null = null; /** * A message to display at the bottom of the screen * @private */ public statusMessage: string = ''; /** * Timeout for status messages * @private */ protected statusTimeout: number = 0; /** * The number of times required to quit a dirty document * @private */ protected 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 !== '') { await this.document.save(this.filename); this.setStatusMessage(`${this.filename} was saved to disk.`); return; } this.prompt('Save as: %s (ESC to cancel)', (name: string, key: string) => { if (key === KeyCommand.Enter) { this.mode = EditorMode.Normal; this.filename = name; return this.save(); } if (name === null || key === KeyCommand.Escape) { this.mode = EditorMode.Normal; this.setStatusMessage('Save aborted'); } }); } // -------------------------------------------------------------------------- // Command/input mapping // -------------------------------------------------------------------------- /** * Determine what to do based on input * @param input - the decoded chunk of stdin */ public async processKeyPress(input: string): Promise { switch (this.mode) { case EditorMode.Find: log(this, LogLevel.Debug); // this._prompt = Prompt.from('Search: %s (Use ESC/Arrows/Enter)'); this.find(); this.processPromptKeyPress(input); // this.find(); return true; case EditorMode.Save: log(this, LogLevel.Debug); this.save(); this.processPromptKeyPress(input); // this.save(); return true; case EditorMode.Prompt: return this.processPromptKeyPress(input); case EditorMode.Normal: // fall through default: return this.processNormalKeyPress(input); } await this.refreshScreen(); } public prompt( p: string, callback: (query: string, char: string) => void, ): string | null { if (this._prompt === null) { this._prompt = Prompt.from(p, callback); } switch (this._prompt.key) { // End the prompt case KeyCommand.Escape: this.mode = EditorMode.Normal; this.setStatusMessage(''); return null; // Return the input and end the prompt case KeyCommand.Enter: this.mode = EditorMode.Normal; if (this._prompt.answer.length > 0) { this.setStatusMessage(''); return this._prompt.answer; } break; default: // Nothing to do here } return this._prompt.answer ?? null; } /** * Find text within the document. This is roughly equivalent to the * `editorFindCallback` function in the kilo tutorial. */ public find(): void { const savedCursor = Position.from(this.cursor); const savedOffset = Position.from(this.offset); this.prompt( 'Search: %s (Use ESC/Arrows/Enter)', (query: string, key: string) => { if (key === KeyCommand.Enter || key === KeyCommand.Escape) { this.mode = EditorMode.Normal; if (key === KeyCommand.Escape) { this.document.resetFind(); // 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); } } return null; } if (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'); } } }, ); } /** * 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(); } public 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); } } // -------------------------------------------------------------------------- // Terminal input parsing // -------------------------------------------------------------------------- private async processNormalKeyPress(input: string): Promise { switch (input) { // ---------------------------------------------------------------------- // Ctrl-key chords // ---------------------------------------------------------------------- case ctrlKey('f'): this.mode = EditorMode.Find; // break; return this.processKeyPress(input); case ctrlKey('s'): this.mode = EditorMode.Save; return this.processKeyPress(input); // 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; } private async processPromptKeyPress(char: string): Promise { log(char, LogLevel.Debug); log(this, LogLevel.Debug); if (this._prompt === null) { log('Prompt should not be null here', LogLevel.Warning); this.mode = EditorMode.Normal; return true; } this.setStatusMessage(this._prompt.render()); await this.refreshScreen(); this._prompt.key = char; switch (char) { // Remove the last character from the prompt input case KeyCommand.Backspace: case KeyCommand.Delete: this._prompt.backspace(); this._prompt.cb(); break; // End the prompt case KeyCommand.Escape: this.mode = EditorMode.Normal; this.setStatusMessage(''); this._prompt.cb(); break; // Return the input and end the prompt case KeyCommand.Enter: this.mode = EditorMode.Normal; if (this._prompt.answer.length > 0) { this.setStatusMessage(''); this._prompt.cb(); } break; // Add to the prompt result default: if (!isControl(char)) { this._prompt.append(char); } } // this.setStatusMessage(this._prompt.render()); // await this.refreshScreen(); return true; } } export default Editor;