Timothy J. Warren
b5856f063a
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
560 lines
13 KiB
JavaScript
560 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 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<Row> {
|
|
return this.document.row(at);
|
|
}
|
|
|
|
protected get currentRow(): Option<Row> {
|
|
return this.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.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<boolean> {
|
|
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<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);
|
|
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<void> {
|
|
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<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();
|
|
}
|
|
|
|
protected async clearScreen(): Promise<void> {
|
|
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);
|
|
}
|
|
}
|
|
}
|