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 Option, { None, Some } from './option.ts'; import { getRuntime, logDebug, logWarning } from './runtime/mod.ts'; import { ITerminalSize, Position, SearchDirection } from './types.ts'; export default class Editor { /** * The document being edited */ protected document: Document; /** * The output buffer for the terminal */ protected buffer: Buffer; /** * The size of the screen in rows/columns */ protected screen: ITerminalSize; /** * The current location of the mouse cursor */ protected cursor: Position; /** * The current scrolling offset */ protected offset: Position; /** * The scrolling offset for the rendered row */ protected renderX: number = 0; /** * The name of the currently open file */ protected filename: string = ''; /** * A message to display at the bottom of the screen */ protected statusMessage: string = ''; /** * Timeout for status messages */ protected statusTimeout: number = 0; /** * The number of times required to quit a dirty document */ 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(); } protected get numRows(): number { return this.document.numRows; } protected row(at: number): Option { return this.document.row(at); } protected get currentRow(): Option { return this.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.isNone()) { this.setStatusMessage('Save aborted'); return; } this.filename = filename.unwrap(); } 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('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; case ctrlKey('s'): await this.save(); break; case ctrlKey('f'): await this.find(); 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; // ---------------------------------------------------------------------- // 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++; } } } if (this.quitTimes < SCROLL_QUIT_TIMES) { this.quitTimes = SCROLL_QUIT_TIMES; this.setStatusMessage(''); } return true; } public async prompt( p: string, callback?: (char: string, query: string) => void, ): Promise> { const { term } = await getRuntime(); let res = ''; const maybeCallback = (char: string, query: string) => { if (callback !== undefined) { callback(char, query); } }; 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 (chunk.length === 0 || char.length === 0) { continue; } switch (char) { // Remove the last character from the prompt input case KeyCommand.Backspace: res = truncate(res, res.length - 1); maybeCallback(res, char); continue outer; // End the prompt case KeyCommand.Escape: res = ''; this.setStatusMessage(''); maybeCallback(res, char); return None; // Return the input and end the prompt case KeyCommand.Enter: if (res.length > 0) { this.setStatusMessage(''); maybeCallback(res, char); return Some(res); } break; // Add to the prompt result default: if (!isControl(char)) { res += char; } } maybeCallback(char, res); } } } /** * 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); let direction = SearchDirection.Forward; const result = await this.prompt( 'Search: %s (Use ESC/Arrows/Enter)', (key: string, query: string) => { let moved = false; switch (key) { case KeyCommand.ArrowRight: case KeyCommand.ArrowDown: direction = SearchDirection.Forward; this.moveCursor(KeyCommand.ArrowRight); moved = true; break; case KeyCommand.ArrowLeft: case KeyCommand.ArrowUp: direction = SearchDirection.Backward; break; default: direction = SearchDirection.Forward; } if (query.length > 0) { const pos = this.document.find(query, this.cursor, direction); if (pos.isSome()) { // We have a match here this.cursor = Position.from(pos.unwrap()); this.scroll(); } else if (moved) { this.moveCursor(KeyCommand.ArrowLeft); } this.document.highlight(Some(query)); } }, ); // 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.scroll(); } this.document.highlight(None); } /** * Filter out any additional unwanted keyboard input * * @param input */ protected 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)) { logDebug('Ignoring input:', { input, isEscapeSequence, isCtrl, }); return true; } return false; } protected moveCursor(char: string): void { const screenHeight = this.screen.rows; let { x, y } = this.cursor; const height = this.numRows; let width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0; logDebug('Editor.moveCursor - start', { char, cursor: this.cursor, renderX: this.renderX, screen: this.screen, height, width, }); switch (char) { case KeyCommand.ArrowUp: if (y > 0) { y -= 1; } break; case KeyCommand.ArrowDown: if (y < height) { y += 1; } break; case KeyCommand.ArrowLeft: if (x > 0) { x -= 1; } else if (y > 0) { y -= 1; x = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0; } break; case KeyCommand.ArrowRight: if ( this.row(y).isSome() && x < width ) { x += 1; } else if (y < height) { y += 1; x = 0; } break; case KeyCommand.PageUp: y = (y > screenHeight) ? posSub(y, screenHeight) : 0; break; case KeyCommand.PageDown: y = maxAdd(y, screenHeight, height); break; case KeyCommand.Home: x = 0; break; case KeyCommand.End: x = width; break; } width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0; if (x > width) { x = width; } this.cursor = Position.at(x, y); logDebug('Editor.moveCursor - end', { cursor: this.cursor, renderX: this.renderX, screen: this.screen, height, width, }); } protected scroll(): void { this.renderX = (this.row(this.cursor.y).isSome()) ? this.document.row(this.cursor.y).unwrap().cxToRx(this.cursor.x) : 0; 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.renderX < offset.x) { offset.x = this.renderX; } else if (this.renderX >= offset.x + width) { offset.x = this.renderX - width + 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(); } protected async clearScreen(): Promise { this.buffer.append(Ansi.ClearScreen); this.buffer.append(Ansi.ResetCursor); await this.buffer.flush(); } protected 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(); } } protected drawFileRow(y: number): void { const maybeRow = this.document.row(y); if (maybeRow.isNone()) { logWarning(`Trying to draw non-existent row '${y}'`); return this.drawPlaceholderRow(y); } const row = maybeRow.unwrap(); const len = Math.min( posSub(row.rsize, this.offset.x), this.screen.cols, ); this.buffer.append(row.render(this.offset.x, len)); } protected 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('~'); } } protected 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, 25)} - ${this.numRows} lines ${modified}`; const rStatus = `${this.document.fileType} | ${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); break; } else { this.buffer.append(' '); len++; } } this.buffer.appendLine(Ansi.ResetFormatting); } protected 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); } } }