scroll/src/common/editor.ts

539 lines
13 KiB
JavaScript

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<Editor> {
await this.#document.open(filename);
this.#filename = filename;
return this;
}
public async save(): Promise<void> {
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<boolean> {
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<string | null> {
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
*/
public async find(): Promise<void> {
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) {
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<void> {
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<void> {
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.rstring(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;