scroll/src/common/editor.ts

570 lines
13 KiB
JavaScript
Raw Normal View History

import Ansi, { KeyCommand } from './ansi.ts';
2023-11-08 18:07:34 -05:00
import Buffer from './buffer.ts';
2023-11-21 15:14:08 -05:00
import Document from './document.ts';
2023-11-29 16:09:58 -05:00
import Row from './row.ts';
import { SCROLL_QUIT_TIMES, SCROLL_VERSION } from './config.ts';
import {
2023-11-29 16:09:58 -05:00
ctrlKey,
2023-11-22 17:09:41 -05:00
isControl,
2023-11-29 16:09:58 -05:00
maxAdd,
posSub,
readKey,
2023-11-29 16:09:58 -05:00
truncate,
} from './fns.ts';
import Option, { None, Some } from './option.ts';
import { getRuntime, logDebug, logWarning } from './runtime.ts';
import { ITerminalSize, Position, SearchDirection } from './types.ts';
2023-11-08 11:11:19 -05:00
2024-07-10 12:11:27 -04:00
export default class Editor {
/**
* The document being edited
*/
2024-07-10 12:11:27 -04:00
protected document: Document;
/**
* The output buffer for the terminal
*/
2024-07-10 12:11:27 -04:00
protected buffer: Buffer;
/**
* The size of the screen in rows/columns
*/
2024-07-10 12:11:27 -04:00
protected screen: ITerminalSize;
/**
* The current location of the mouse cursor
*/
2024-07-10 12:11:27 -04:00
protected cursor: Position;
2023-11-20 11:12:22 -05:00
/**
* The current scrolling offset
*/
2024-07-10 12:11:27 -04:00
protected offset: Position;
/**
* The scrolling offset for the rendered row
*/
2024-07-10 12:11:27 -04:00
protected renderX: number = 0;
2023-11-20 15:39:27 -05:00
/**
* The name of the currently open file
*/
2024-07-10 12:11:27 -04:00
protected filename: string = '';
2023-11-21 11:35:56 -05:00
/**
* A message to display at the bottom of the screen
*/
2024-07-10 12:11:27 -04:00
protected statusMessage: string = '';
2023-11-21 11:35:56 -05:00
/**
* Timeout for status messages
*/
2024-07-10 12:11:27 -04:00
protected statusTimeout: number = 0;
/**
* The number of times required to quit a dirty document
*/
2024-07-10 12:11:27 -04:00
protected quitTimes: number = SCROLL_QUIT_TIMES;
2023-11-20 14:21:42 -05:00
constructor(terminalSize: ITerminalSize) {
2024-07-10 12:11:27 -04:00
this.buffer = new Buffer();
2023-11-21 15:14:08 -05:00
// Subtract two rows from the terminal size
// for displaying the status bar
// and message bar
2024-07-10 12:11:27 -04:00
this.screen = terminalSize;
this.screen.rows -= 2;
2023-11-21 15:14:08 -05:00
2024-07-10 12:11:27 -04:00
this.cursor = Position.default();
this.offset = Position.default();
this.document = Document.default();
}
protected get numRows(): number {
2024-07-10 12:11:27 -04:00
return this.document.numRows;
2023-11-21 15:14:08 -05:00
}
protected row(at: number): Option<Row> {
return this.document.row(at);
}
protected get currentRow(): Option<Row> {
return this.row(this.cursor.y);
}
public async open(filename: string): Promise<Editor> {
2024-07-10 12:11:27 -04:00
await this.document.open(filename);
this.filename = filename;
return this;
2023-11-08 11:11:19 -05:00
}
public async save(): Promise<void> {
2024-07-10 12:11:27 -04:00
if (this.filename === '') {
const filename = await this.prompt('Save as: %s (ESC to cancel)');
if (filename.isNone()) {
this.setStatusMessage('Save aborted');
return;
}
2024-07-10 12:11:27 -04:00
this.filename = filename.unwrap();
}
2024-07-10 12:11:27 -04:00
await this.document.save(this.filename);
this.setStatusMessage(`${this.filename} was saved to disk.`);
}
2023-11-10 08:36:18 -05:00
// --------------------------------------------------------------------------
// Command/input mapping
// --------------------------------------------------------------------------
2023-11-08 11:11:19 -05:00
/**
* Determine what to do based on input
* @param input - the decoded chunk of stdin
*/
2023-11-21 15:14:08 -05:00
public async processKeyPress(input: string): Promise<boolean> {
switch (input) {
// ----------------------------------------------------------------------
// Ctrl-key chords
// ----------------------------------------------------------------------
case ctrlKey('q'):
2024-07-10 12:11:27 -04:00
if (this.quitTimes > 0 && this.document.dirty) {
this.setStatusMessage(
'WARNING!!! File has unsaved changes. ' +
2024-07-10 12:11:27 -04:00
`Press Ctrl-Q ${this.quitTimes} more times to quit.`,
);
2024-07-10 12:11:27 -04:00
this.quitTimes--;
return true;
}
await this.clearScreen();
return false;
2024-07-10 12:11:27 -04:00
case ctrlKey('s'):
await this.save();
break;
2024-07-10 12:11:27 -04:00
case ctrlKey('f'):
await this.find();
break;
// ----------------------------------------------------------------------
// Text manipulation keys
// ----------------------------------------------------------------------
case KeyCommand.Enter:
2024-07-10 12:11:27 -04:00
this.document.insertNewline(this.cursor);
this.cursor.x = 0;
this.cursor.y++;
break;
case KeyCommand.Delete:
2024-07-10 12:11:27 -04:00
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;
2024-07-10 12:11:27 -04:00
// ----------------------------------------------------------------------
// 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)) {
2024-07-10 12:11:27 -04:00
this.document.insert(this.cursor, input);
this.cursor.x++;
}
}
}
2024-07-10 12:11:27 -04:00
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<Option<string>> {
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);
2024-07-05 16:16:05 -04:00
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);
}
}
}
2023-11-30 16:14:52 -05:00
/**
* Find text within the document. This is roughly equivalent to the
* `editorFindCallback` function in the kilo tutorial.
2023-11-30 16:14:52 -05:00
*/
public async find(): Promise<void> {
2024-07-10 12:11:27 -04:00
const savedCursor = Position.from(this.cursor);
let direction = SearchDirection.Forward;
const result = await this.prompt(
2024-02-28 15:44:57 -05:00
'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) {
2024-07-10 12:11:27 -04:00
const pos = this.document.find(query, this.cursor, direction);
if (pos.isSome()) {
// We have a match here
2024-07-10 12:11:27 -04:00
this.cursor = Position.from(pos.unwrap());
this.scroll();
} else if (moved) {
this.moveCursor(KeyCommand.ArrowLeft);
}
2024-07-10 12:11:27 -04:00
this.document.highlight(Some(query));
}
},
);
// Return to document position before search
// when you cancel the search (press the escape key)
if (result.isNone()) {
2024-07-10 12:11:27 -04:00
this.cursor = Position.from(savedCursor);
// this.offset = Position.from(savedOffset);
this.scroll();
}
2024-07-10 12:11:27 -04:00
this.document.highlight(None);
2023-11-30 16:14:52 -05:00
}
2023-11-22 17:09:41 -05:00
/**
* Filter out any additional unwanted keyboard input
2024-07-02 16:27:18 -04:00
*
2023-11-22 17:09:41 -05:00
* @param input
*/
protected shouldFilter(input: string): boolean {
2023-11-22 17:09:41 -05:00
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,
2023-11-22 17:09:41 -05:00
isEscapeSequence,
isCtrl,
});
2023-11-22 17:09:41 -05:00
return true;
}
return false;
}
protected moveCursor(char: string): void {
2024-07-10 12:11:27 -04:00
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,
2024-07-10 12:11:27 -04:00
cursor: this.cursor,
renderX: this.renderX,
screen: this.screen,
height,
width,
});
2024-07-10 12:11:27 -04:00
2023-11-10 08:36:18 -05:00
switch (char) {
2024-07-10 12:11:27 -04:00
case KeyCommand.ArrowUp:
if (y > 0) {
y -= 1;
}
break;
case KeyCommand.ArrowDown:
if (y < height) {
y += 1;
}
break;
case KeyCommand.ArrowLeft:
2024-07-10 12:11:27 -04:00
if (x > 0) {
x -= 1;
} else if (y > 0) {
y -= 1;
x = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0;
}
2023-11-10 08:36:18 -05:00
break;
case KeyCommand.ArrowRight:
2023-11-20 14:21:42 -05:00
if (
this.row(y).isSome() && x < width
2023-11-20 14:21:42 -05:00
) {
2024-07-10 12:11:27 -04:00
x += 1;
} else if (y < height) {
y += 1;
x = 0;
}
2023-11-10 08:36:18 -05:00
break;
2024-07-10 12:11:27 -04:00
case KeyCommand.PageUp:
y = (y > screenHeight) ? posSub(y, screenHeight) : 0;
2023-11-10 08:36:18 -05:00
break;
2024-07-10 12:11:27 -04:00
case KeyCommand.PageDown:
y = maxAdd(y, screenHeight, height);
break;
case KeyCommand.Home:
x = 0;
break;
case KeyCommand.End:
x = width;
2023-11-10 08:36:18 -05:00
break;
2023-11-08 11:11:19 -05:00
}
2023-11-20 14:21:42 -05:00
width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0;
2024-07-10 12:11:27 -04:00
if (x > width) {
x = width;
2023-11-20 14:21:42 -05:00
}
2024-07-10 12:11:27 -04:00
this.cursor = Position.at(x, y);
logDebug('Editor.moveCursor - end', {
2024-07-10 12:11:27 -04:00
cursor: this.cursor,
renderX: this.renderX,
screen: this.screen,
height,
width,
});
2023-11-08 11:11:19 -05:00
}
protected scroll(): void {
this.renderX = (this.row(this.cursor.y).isSome())
? this.document.row(this.cursor.y).unwrap().cxToRx(this.cursor.x)
2024-07-10 12:11:27 -04:00
: 0;
logDebug('Editor.scroll - start', {
2024-07-10 12:11:27 -04:00
cursor: this.cursor,
renderX: this.renderX,
offset: this.offset,
});
2024-07-10 12:11:27 -04:00
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;
2023-11-20 11:12:22 -05:00
}
2024-07-10 12:11:27 -04:00
if (this.renderX < offset.x) {
offset.x = this.renderX;
} else if (this.renderX >= offset.x + width) {
offset.x = this.renderX - width + 1;
2023-11-20 14:21:42 -05:00
}
2024-07-10 12:11:27 -04:00
logDebug('Editor.scroll - end', {
2024-07-10 12:11:27 -04:00
cursor: this.cursor,
renderX: this.renderX,
offset: this.offset,
});
2023-11-20 11:12:22 -05:00
}
2023-11-10 08:36:18 -05:00
// --------------------------------------------------------------------------
2023-11-09 12:05:30 -05:00
// Terminal Output / Drawing
2023-11-10 08:36:18 -05:00
// --------------------------------------------------------------------------
2023-11-09 12:05:30 -05:00
2023-11-21 11:35:56 -05:00
public setStatusMessage(msg: string): void {
// TODO: consider some sort of formatting for passed strings
2024-07-10 12:11:27 -04:00
this.statusMessage = msg;
this.statusTimeout = Date.now();
2023-11-21 11:35:56 -05:00
}
2023-11-08 11:11:19 -05:00
/**
* Clear the screen and write out the buffer
*/
public async refreshScreen(): Promise<void> {
2023-11-20 11:12:22 -05:00
this.scroll();
2024-07-10 12:11:27 -04:00
this.buffer.append(Ansi.HideCursor);
this.buffer.append(Ansi.ResetCursor);
this.drawRows();
2023-11-20 15:39:27 -05:00
this.drawStatusBar();
2023-11-21 11:35:56 -05:00
this.drawMessageBar();
2024-07-10 12:11:27 -04:00
this.buffer.append(
2023-11-20 14:21:42 -05:00
Ansi.moveCursor(
2024-07-10 12:11:27 -04:00
this.cursor.y - this.offset.y,
this.renderX - this.offset.x,
2023-11-20 14:21:42 -05:00
),
2023-11-10 08:36:18 -05:00
);
2024-07-10 12:11:27 -04:00
this.buffer.append(Ansi.ShowCursor);
2024-07-10 12:11:27 -04:00
await this.buffer.flush();
}
2023-11-08 11:11:19 -05:00
protected async clearScreen(): Promise<void> {
2024-07-10 12:11:27 -04:00
this.buffer.append(Ansi.ClearScreen);
this.buffer.append(Ansi.ResetCursor);
2023-11-09 12:32:41 -05:00
2024-07-10 12:11:27 -04:00
await this.buffer.flush();
}
protected drawRows(): void {
2024-07-10 12:11:27 -04:00
for (let y = 0; y < this.screen.rows; y++) {
this.buffer.append(Ansi.ClearLine);
const fileRow = y + this.offset.y;
2023-11-21 15:14:08 -05:00
if (fileRow >= this.numRows) {
this.drawPlaceholderRow(fileRow);
2023-11-13 14:46:04 -05:00
} else {
2023-11-21 15:14:08 -05:00
this.drawFileRow(fileRow);
2023-11-20 14:21:42 -05:00
}
2024-07-10 12:11:27 -04:00
this.buffer.appendLine();
2023-11-13 14:46:04 -05:00
}
}
protected drawFileRow(y: number): void {
2024-07-10 12:11:27 -04:00
const maybeRow = this.document.row(y);
if (maybeRow.isNone()) {
logWarning(`Trying to draw non-existent row '${y}'`);
2023-11-16 16:00:03 -05:00
return this.drawPlaceholderRow(y);
}
const row = maybeRow.unwrap();
2023-11-20 14:21:42 -05:00
const len = Math.min(
2024-07-10 12:11:27 -04:00
posSub(row.rsize, this.offset.x),
this.screen.cols,
2023-11-20 14:21:42 -05:00
);
2024-07-10 12:11:27 -04:00
this.buffer.append(row.render(this.offset.x, len));
2023-11-13 14:46:04 -05:00
}
protected drawPlaceholderRow(y: number): void {
2024-07-10 12:11:27 -04:00
if (y === Math.trunc(this.screen.rows / 2) && this.document.isEmpty()) {
2023-11-21 16:06:29 -05:00
const message = `Scroll editor -- version ${SCROLL_VERSION}`;
2024-07-10 12:11:27 -04:00
const messageLen = (message.length > this.screen.cols)
? this.screen.cols
2023-11-13 14:46:04 -05:00
: message.length;
2024-07-10 12:11:27 -04:00
let padding = Math.trunc((this.screen.cols - messageLen) / 2);
2023-11-13 14:46:04 -05:00
if (padding > 0) {
2024-07-10 12:11:27 -04:00
this.buffer.append('~');
2023-11-13 14:46:04 -05:00
padding -= 1;
2023-11-09 12:05:30 -05:00
2024-07-10 12:11:27 -04:00
this.buffer.append(' '.repeat(padding));
2023-11-09 12:05:30 -05:00
}
2023-11-13 14:46:04 -05:00
2024-07-10 12:11:27 -04:00
this.buffer.append(message, messageLen);
2023-11-13 14:46:04 -05:00
} else {
2024-07-10 12:11:27 -04:00
this.buffer.append('~');
2023-11-13 14:46:04 -05:00
}
}
2023-11-20 15:39:27 -05:00
protected drawStatusBar(): void {
2024-07-10 12:11:27 -04:00
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);
2023-11-20 15:39:27 -05:00
break;
} else {
2024-07-10 12:11:27 -04:00
this.buffer.append(' ');
2023-11-20 15:39:27 -05:00
len++;
}
}
2024-07-10 12:11:27 -04:00
this.buffer.appendLine(Ansi.ResetFormatting);
2023-11-21 11:35:56 -05:00
}
protected drawMessageBar(): void {
2024-07-10 12:11:27 -04:00
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);
2023-11-21 11:35:56 -05:00
}
2023-11-20 15:39:27 -05:00
}
}