Mostly refactor prompt input (search/file saving) to use input loop shared with normal functionality
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
This commit is contained in:
parent
d405880ce8
commit
9afeed41cd
@ -1,16 +1,18 @@
|
|||||||
|
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
|
||||||
|
|
||||||
import { IFileIO } from '../common/runtime.ts';
|
import { IFileIO } from '../common/runtime.ts';
|
||||||
import { appendFile } from 'node:fs/promises';
|
import { appendFile } from 'node:fs/promises';
|
||||||
|
|
||||||
const BunFileIO: IFileIO = {
|
const BunFileIO: IFileIO = {
|
||||||
openFile: async (path: string): Promise<string> => {
|
openFile: async (path: string): Promise<string> => {
|
||||||
const file = await Bun.file(path);
|
const file = await globalThis.Bun.file(path);
|
||||||
return await file.text();
|
return await file.text();
|
||||||
},
|
},
|
||||||
appendFile: async function (path: string, contents: string): Promise<void> {
|
appendFile: async function (path: string, contents: string): Promise<void> {
|
||||||
return await appendFile(path, contents);
|
return await appendFile(path, contents);
|
||||||
},
|
},
|
||||||
saveFile: async function (path: string, contents: string): Promise<void> {
|
saveFile: async function (path: string, contents: string): Promise<void> {
|
||||||
await Bun.write(path, contents);
|
await globalThis.Bun.write(path, contents);
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
|
||||||
/**
|
/**
|
||||||
* The main entrypoint when using Bun as the runtime
|
* The main entrypoint when using Bun as the runtime
|
||||||
*/
|
*/
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
|
||||||
/**
|
/**
|
||||||
* Wrap the runtime-specific hook into stdin
|
* Wrap the runtime-specific hook into stdin
|
||||||
*/
|
*/
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
if (!('Bun' in globalThis)) throw new Error('This module requires Bun');
|
||||||
/**
|
/**
|
||||||
* Adapt the bun test interface to the shared testing interface
|
* Adapt the bun test interface to the shared testing interface
|
||||||
*/
|
*/
|
||||||
|
@ -8,108 +8,170 @@ import {
|
|||||||
ctrlKey,
|
ctrlKey,
|
||||||
isControl,
|
isControl,
|
||||||
maxAdd,
|
maxAdd,
|
||||||
none,
|
|
||||||
posSub,
|
posSub,
|
||||||
readKey,
|
|
||||||
some,
|
some,
|
||||||
|
strlen,
|
||||||
truncate,
|
truncate,
|
||||||
} from './fns.ts';
|
} from './fns.ts';
|
||||||
import { getRuntime, log, LogLevel } from './runtime.ts';
|
import { log, LogLevel } from './runtime.ts';
|
||||||
import { ITerminalSize, Position } from './types.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 {
|
class Editor {
|
||||||
|
/**
|
||||||
|
* How to handle the stdin stream
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
public mode: EditorMode = EditorMode.Normal;
|
||||||
/**
|
/**
|
||||||
* The document being edited
|
* The document being edited
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#document: Document;
|
public document: Document;
|
||||||
/**
|
/**
|
||||||
* The output buffer for the terminal
|
* The output buffer for the terminal
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#buffer: Buffer;
|
protected buffer: Buffer;
|
||||||
/**
|
/**
|
||||||
* The size of the screen in rows/columns
|
* The size of the screen in rows/columns
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#screen: ITerminalSize;
|
protected screen: ITerminalSize;
|
||||||
/**
|
/**
|
||||||
* The current location of the mouse cursor
|
* The current location of the mouse cursor
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#cursor: Position;
|
protected cursor: Position;
|
||||||
/**
|
/**
|
||||||
* The current scrolling offset
|
* The current scrolling offset
|
||||||
*/
|
*/
|
||||||
#offset: Position;
|
protected offset: Position;
|
||||||
/**
|
/**
|
||||||
* The scrolling offset for the rendered row
|
* The scrolling offset for the rendered row
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#renderX: number = 0;
|
protected renderX: number = 0;
|
||||||
/**
|
/**
|
||||||
* The name of the currently open file
|
* The name of the currently open file
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#filename: string = '';
|
protected filename: string = '';
|
||||||
|
/**
|
||||||
|
* Current input prompt state
|
||||||
|
*/
|
||||||
|
public _prompt: Prompt | null = null;
|
||||||
/**
|
/**
|
||||||
* A message to display at the bottom of the screen
|
* A message to display at the bottom of the screen
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#statusMessage: string = '';
|
public statusMessage: string = '';
|
||||||
/**
|
/**
|
||||||
* Timeout for status messages
|
* Timeout for status messages
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#statusTimeout: number = 0;
|
protected statusTimeout: number = 0;
|
||||||
/**
|
/**
|
||||||
* The number of times required to quit a dirty document
|
* The number of times required to quit a dirty document
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
#quitTimes: number = SCROLL_QUIT_TIMES;
|
protected quitTimes: number = SCROLL_QUIT_TIMES;
|
||||||
|
|
||||||
constructor(terminalSize: ITerminalSize) {
|
constructor(terminalSize: ITerminalSize) {
|
||||||
this.#buffer = new Buffer();
|
this.buffer = new Buffer();
|
||||||
|
|
||||||
// Subtract two rows from the terminal size
|
// Subtract two rows from the terminal size
|
||||||
// for displaying the status bar
|
// for displaying the status bar
|
||||||
// and message bar
|
// and message bar
|
||||||
this.#screen = terminalSize;
|
this.screen = terminalSize;
|
||||||
this.#screen.rows -= 2;
|
this.screen.rows -= 2;
|
||||||
|
|
||||||
this.#cursor = Position.default();
|
this.cursor = Position.default();
|
||||||
this.#offset = Position.default();
|
this.offset = Position.default();
|
||||||
this.#document = Document.default();
|
this.document = Document.default();
|
||||||
}
|
}
|
||||||
|
|
||||||
private get numRows(): number {
|
private get numRows(): number {
|
||||||
return this.#document.numRows;
|
return this.document.numRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get currentRow(): Row | null {
|
private get currentRow(): Row | null {
|
||||||
return this.#document.row(this.#cursor.y);
|
return this.document.row(this.cursor.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async open(filename: string): Promise<Editor> {
|
public async open(filename: string): Promise<Editor> {
|
||||||
await this.#document.open(filename);
|
await this.document.open(filename);
|
||||||
this.#filename = filename;
|
this.filename = filename;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
public async save(): Promise<void> {
|
||||||
if (this.#filename === '') {
|
if (this.filename !== '') {
|
||||||
const filename = await this.prompt('Save as: %s (ESC to cancel)');
|
await this.document.save(this.filename);
|
||||||
if (filename === null) {
|
this.setStatusMessage(`${this.filename} was saved to disk.`);
|
||||||
this.setStatusMessage('Save aborted');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#filename = filename;
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.#document.save(this.#filename);
|
if (name === null || key === KeyCommand.Escape) {
|
||||||
this.setStatusMessage(`${this.#filename} was saved to disk.`);
|
this.mode = EditorMode.Normal;
|
||||||
|
this.setStatusMessage('Save aborted');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@ -121,204 +183,96 @@ class Editor {
|
|||||||
* @param input - the decoded chunk of stdin
|
* @param input - the decoded chunk of stdin
|
||||||
*/
|
*/
|
||||||
public async processKeyPress(input: string): Promise<boolean> {
|
public async processKeyPress(input: string): Promise<boolean> {
|
||||||
switch (input) {
|
switch (this.mode) {
|
||||||
// ----------------------------------------------------------------------
|
case EditorMode.Find:
|
||||||
// Ctrl-key chords
|
log(this, LogLevel.Debug);
|
||||||
// ----------------------------------------------------------------------
|
// this._prompt = Prompt.from('Search: %s (Use ESC/Arrows/Enter)');
|
||||||
case ctrlKey('f'):
|
this.find();
|
||||||
await this.find();
|
this.processPromptKeyPress(input);
|
||||||
break;
|
// this.find();
|
||||||
|
|
||||||
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;
|
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('');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
case EditorMode.Save:
|
||||||
|
log(this, LogLevel.Debug);
|
||||||
|
this.save();
|
||||||
|
this.processPromptKeyPress(input);
|
||||||
|
// this.save();
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
|
|
||||||
public async prompt(
|
case EditorMode.Prompt:
|
||||||
p: string,
|
return this.processPromptKeyPress(input);
|
||||||
callback?: (query: string, char: string) => void,
|
|
||||||
): Promise<string | null> {
|
|
||||||
const { term } = await getRuntime();
|
|
||||||
|
|
||||||
let res = '';
|
case EditorMode.Normal: // fall through
|
||||||
const maybeCallback = (query: string, char: string) => {
|
default:
|
||||||
if (callback !== undefined) {
|
return this.processNormalKeyPress(input);
|
||||||
callback(query, char);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
outer: while (true) {
|
|
||||||
if (p.includes('%s')) {
|
|
||||||
this.setStatusMessage(p.replace('%s', res));
|
|
||||||
} else {
|
|
||||||
this.setStatusMessage(`${p}${res}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.refreshScreen();
|
await this.refreshScreen();
|
||||||
for await (const chunk of term.inputLoop()) {
|
|
||||||
const char = readKey(chunk);
|
|
||||||
if (none(char)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (char) {
|
public prompt(
|
||||||
// Remove the last character from the prompt input
|
p: string,
|
||||||
case KeyCommand.Backspace:
|
callback: (query: string, char: string) => void,
|
||||||
case KeyCommand.Delete:
|
): string | null {
|
||||||
res = truncate(res, res.length - 1);
|
if (this._prompt === null) {
|
||||||
maybeCallback(res, char);
|
this._prompt = Prompt.from(p, callback);
|
||||||
continue outer;
|
}
|
||||||
|
|
||||||
|
switch (this._prompt.key) {
|
||||||
// End the prompt
|
// End the prompt
|
||||||
case KeyCommand.Escape:
|
case KeyCommand.Escape:
|
||||||
this.setStatusMessage('');
|
this.mode = EditorMode.Normal;
|
||||||
maybeCallback(res, char);
|
|
||||||
|
|
||||||
|
this.setStatusMessage('');
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Return the input and end the prompt
|
// Return the input and end the prompt
|
||||||
case KeyCommand.Enter:
|
case KeyCommand.Enter:
|
||||||
if (res.length > 0) {
|
this.mode = EditorMode.Normal;
|
||||||
|
|
||||||
|
if (this._prompt.answer.length > 0) {
|
||||||
this.setStatusMessage('');
|
this.setStatusMessage('');
|
||||||
maybeCallback(res, char);
|
return this._prompt.answer;
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Add to the prompt result
|
|
||||||
default:
|
default:
|
||||||
if (!isControl(char)) {
|
// Nothing to do here
|
||||||
res += char;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeCallback(res, char);
|
return this._prompt.answer ?? null;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find text within the document. This is roughly equivalent to the
|
* Find text within the document. This is roughly equivalent to the
|
||||||
* `editorFindCallback` function in the kilo tutorial.
|
* `editorFindCallback` function in the kilo tutorial.
|
||||||
*/
|
*/
|
||||||
public async find(): Promise<void> {
|
public find(): void {
|
||||||
const savedCursor = Position.from(this.#cursor);
|
const savedCursor = Position.from(this.cursor);
|
||||||
const savedOffset = Position.from(this.#offset);
|
const savedOffset = Position.from(this.offset);
|
||||||
|
|
||||||
const query = await this.prompt(
|
this.prompt(
|
||||||
'Search: %s (Use ESC/Arrows/Enter)',
|
'Search: %s (Use ESC/Arrows/Enter)',
|
||||||
(query: string, key: string) => {
|
(query: string, key: string) => {
|
||||||
if (key === KeyCommand.Enter || key === KeyCommand.Escape) {
|
if (key === KeyCommand.Enter || key === KeyCommand.Escape) {
|
||||||
|
this.mode = EditorMode.Normal;
|
||||||
if (key === KeyCommand.Escape) {
|
if (key === KeyCommand.Escape) {
|
||||||
this.#document.resetFind();
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (some(query) && query.length > 0) {
|
if (query.length > 0) {
|
||||||
const pos = this.#document.find(query, key);
|
const pos = this.document.find(query, key);
|
||||||
if (pos !== null) {
|
if (pos !== null) {
|
||||||
// We have a match here
|
// We have a match here
|
||||||
this.#cursor = pos;
|
this.cursor = pos;
|
||||||
this.scroll();
|
this.scroll();
|
||||||
} else {
|
} else {
|
||||||
this.setStatusMessage('Not found');
|
this.setStatusMessage('Not found');
|
||||||
@ -326,13 +280,6 @@ class Editor {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -362,63 +309,61 @@ class Editor {
|
|||||||
private moveCursor(char: string): void {
|
private moveCursor(char: string): void {
|
||||||
switch (char) {
|
switch (char) {
|
||||||
case KeyCommand.ArrowLeft:
|
case KeyCommand.ArrowLeft:
|
||||||
if (this.#cursor.x > 0) {
|
if (this.cursor.x > 0) {
|
||||||
this.#cursor.x--;
|
this.cursor.x--;
|
||||||
} else if (this.#cursor.y > 0) {
|
} else if (this.cursor.y > 0) {
|
||||||
this.#cursor.y--;
|
this.cursor.y--;
|
||||||
this.#cursor.x = (this.currentRow !== null)
|
this.cursor.x = (this.currentRow !== null) ? this.currentRow.size : 0;
|
||||||
? this.currentRow.size
|
|
||||||
: 0;
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case KeyCommand.ArrowRight:
|
case KeyCommand.ArrowRight:
|
||||||
if (
|
if (
|
||||||
this.currentRow !== null && this.#cursor.x < this.currentRow.size
|
this.currentRow !== null && this.cursor.x < this.currentRow.size
|
||||||
) {
|
) {
|
||||||
this.#cursor.x++;
|
this.cursor.x++;
|
||||||
} else if (
|
} else if (
|
||||||
this.currentRow !== null &&
|
this.currentRow !== null &&
|
||||||
this.#cursor.x === this.currentRow.size
|
this.cursor.x === this.currentRow.size
|
||||||
) {
|
) {
|
||||||
this.#cursor.y++;
|
this.cursor.y++;
|
||||||
this.#cursor.x = 0;
|
this.cursor.x = 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case KeyCommand.ArrowUp:
|
case KeyCommand.ArrowUp:
|
||||||
if (this.#cursor.y > 0) {
|
if (this.cursor.y > 0) {
|
||||||
this.#cursor.y--;
|
this.cursor.y--;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case KeyCommand.ArrowDown:
|
case KeyCommand.ArrowDown:
|
||||||
if (this.#cursor.y < this.numRows) {
|
if (this.cursor.y < this.numRows) {
|
||||||
this.#cursor.y++;
|
this.cursor.y++;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowLen = this.currentRow?.size ?? 0;
|
const rowLen = this.currentRow?.size ?? 0;
|
||||||
if (this.#cursor.x > rowLen) {
|
if (this.cursor.x > rowLen) {
|
||||||
this.#cursor.x = rowLen;
|
this.cursor.x = rowLen;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private scroll(): void {
|
private scroll(): void {
|
||||||
this.#renderX = 0;
|
this.renderX = 0;
|
||||||
if (this.currentRow !== null) {
|
if (this.currentRow !== null) {
|
||||||
this.#renderX = this.currentRow.cxToRx(this.#cursor.x);
|
this.renderX = this.currentRow.cxToRx(this.cursor.x);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.#cursor.y < this.#offset.y) {
|
if (this.cursor.y < this.offset.y) {
|
||||||
this.#offset.y = this.#cursor.y;
|
this.offset.y = this.cursor.y;
|
||||||
}
|
}
|
||||||
if (this.#cursor.y >= this.#offset.y + this.#screen.rows) {
|
if (this.cursor.y >= this.offset.y + this.screen.rows) {
|
||||||
this.#offset.y = this.#cursor.y - this.#screen.rows + 1;
|
this.offset.y = this.cursor.y - this.screen.rows + 1;
|
||||||
}
|
}
|
||||||
if (this.#renderX < this.#offset.x) {
|
if (this.renderX < this.offset.x) {
|
||||||
this.#offset.x = this.#renderX;
|
this.offset.x = this.renderX;
|
||||||
}
|
}
|
||||||
if (this.#renderX >= this.#offset.x + this.#screen.cols) {
|
if (this.renderX >= this.offset.x + this.screen.cols) {
|
||||||
this.#offset.x = this.#renderX - this.#screen.cols + 1;
|
this.offset.x = this.renderX - this.screen.cols + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -428,8 +373,8 @@ class Editor {
|
|||||||
|
|
||||||
public setStatusMessage(msg: string): void {
|
public setStatusMessage(msg: string): void {
|
||||||
// TODO: consider some sort of formatting for passed strings
|
// TODO: consider some sort of formatting for passed strings
|
||||||
this.#statusMessage = msg;
|
this.statusMessage = msg;
|
||||||
this.#statusTimeout = Date.now();
|
this.statusTimeout = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -437,106 +382,281 @@ class Editor {
|
|||||||
*/
|
*/
|
||||||
public async refreshScreen(): Promise<void> {
|
public async refreshScreen(): Promise<void> {
|
||||||
this.scroll();
|
this.scroll();
|
||||||
this.#buffer.append(Ansi.HideCursor);
|
this.buffer.append(Ansi.HideCursor);
|
||||||
this.#buffer.append(Ansi.ResetCursor);
|
this.buffer.append(Ansi.ResetCursor);
|
||||||
this.drawRows();
|
this.drawRows();
|
||||||
this.drawStatusBar();
|
this.drawStatusBar();
|
||||||
this.drawMessageBar();
|
this.drawMessageBar();
|
||||||
this.#buffer.append(
|
this.buffer.append(
|
||||||
Ansi.moveCursor(
|
Ansi.moveCursor(
|
||||||
this.#cursor.y - this.#offset.y,
|
this.cursor.y - this.offset.y,
|
||||||
this.#renderX - this.#offset.x,
|
this.renderX - this.offset.x,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.#buffer.append(Ansi.ShowCursor);
|
this.buffer.append(Ansi.ShowCursor);
|
||||||
|
|
||||||
await this.#buffer.flush();
|
await this.buffer.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearScreen(): Promise<void> {
|
public async clearScreen(): Promise<void> {
|
||||||
this.#buffer.append(Ansi.ClearScreen);
|
this.buffer.append(Ansi.ClearScreen);
|
||||||
this.#buffer.append(Ansi.ResetCursor);
|
this.buffer.append(Ansi.ResetCursor);
|
||||||
|
|
||||||
await this.#buffer.flush();
|
await this.buffer.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawRows(): void {
|
private drawRows(): void {
|
||||||
for (let y = 0; y < this.#screen.rows; y++) {
|
for (let y = 0; y < this.screen.rows; y++) {
|
||||||
this.#buffer.append(Ansi.ClearLine);
|
this.buffer.append(Ansi.ClearLine);
|
||||||
const fileRow = y + this.#offset.y;
|
const fileRow = y + this.offset.y;
|
||||||
if (fileRow >= this.numRows) {
|
if (fileRow >= this.numRows) {
|
||||||
this.drawPlaceholderRow(fileRow);
|
this.drawPlaceholderRow(fileRow);
|
||||||
} else {
|
} else {
|
||||||
this.drawFileRow(fileRow);
|
this.drawFileRow(fileRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#buffer.appendLine();
|
this.buffer.appendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawFileRow(y: number): void {
|
private drawFileRow(y: number): void {
|
||||||
const row = this.#document.row(y);
|
const row = this.document.row(y);
|
||||||
if (row === null) {
|
if (row === null) {
|
||||||
log(`Trying to draw non-existent row '${y}'`, LogLevel.Warning);
|
log(`Trying to draw non-existent row '${y}'`, LogLevel.Warning);
|
||||||
return this.drawPlaceholderRow(y);
|
return this.drawPlaceholderRow(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
const len = Math.min(
|
const len = Math.min(
|
||||||
posSub(row.rsize, this.#offset.x),
|
posSub(row.rsize, this.offset.x),
|
||||||
this.#screen.cols,
|
this.screen.cols,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.#buffer.append(row.render(this.#offset.x, len));
|
this.buffer.append(row.render(this.offset.x, len));
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawPlaceholderRow(y: number): void {
|
private drawPlaceholderRow(y: number): void {
|
||||||
if (y === Math.trunc(this.#screen.rows / 2) && this.#document.isEmpty()) {
|
if (y === Math.trunc(this.screen.rows / 2) && this.document.isEmpty()) {
|
||||||
const message = `Scroll editor -- version ${SCROLL_VERSION}`;
|
const message = `Scroll editor -- version ${SCROLL_VERSION}`;
|
||||||
const messageLen = (message.length > this.#screen.cols)
|
const messageLen = (message.length > this.screen.cols)
|
||||||
? this.#screen.cols
|
? this.screen.cols
|
||||||
: message.length;
|
: message.length;
|
||||||
let padding = Math.trunc((this.#screen.cols - messageLen) / 2);
|
let padding = Math.trunc((this.screen.cols - messageLen) / 2);
|
||||||
if (padding > 0) {
|
if (padding > 0) {
|
||||||
this.#buffer.append('~');
|
this.buffer.append('~');
|
||||||
padding -= 1;
|
padding -= 1;
|
||||||
|
|
||||||
this.#buffer.append(' '.repeat(padding));
|
this.buffer.append(' '.repeat(padding));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#buffer.append(message, messageLen);
|
this.buffer.append(message, messageLen);
|
||||||
} else {
|
} else {
|
||||||
this.#buffer.append('~');
|
this.buffer.append('~');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawStatusBar(): void {
|
private drawStatusBar(): void {
|
||||||
this.#buffer.append(Ansi.InvertColor);
|
this.buffer.append(Ansi.InvertColor);
|
||||||
const name = (this.#filename !== '') ? this.#filename : '[No Name]';
|
const name = (this.filename !== '') ? this.filename : '[No Name]';
|
||||||
const modified = (this.#document.dirty) ? '(modified)' : '';
|
const modified = (this.document.dirty) ? '(modified)' : '';
|
||||||
const status = `${truncate(name, 20)} - ${this.numRows} lines ${modified}`;
|
const status = `${truncate(name, 20)} - ${this.numRows} lines ${modified}`;
|
||||||
const rStatus = `${this.#cursor.y + 1}/${this.numRows}`;
|
const rStatus = `${this.cursor.y + 1}/${this.numRows}`;
|
||||||
let len = Math.min(status.length, this.#screen.cols);
|
let len = Math.min(status.length, this.screen.cols);
|
||||||
this.#buffer.append(status, len);
|
this.buffer.append(status, len);
|
||||||
|
|
||||||
while (len < this.#screen.cols) {
|
while (len < this.screen.cols) {
|
||||||
if (this.#screen.cols - len === rStatus.length) {
|
if (this.screen.cols - len === rStatus.length) {
|
||||||
this.#buffer.append(rStatus);
|
this.buffer.append(rStatus);
|
||||||
break;
|
break;
|
||||||
} else {
|
} else {
|
||||||
this.#buffer.append(' ');
|
this.buffer.append(' ');
|
||||||
len++;
|
len++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.#buffer.appendLine(Ansi.ResetFormatting);
|
this.buffer.appendLine(Ansi.ResetFormatting);
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawMessageBar(): void {
|
private drawMessageBar(): void {
|
||||||
this.#buffer.append(Ansi.ClearLine);
|
this.buffer.append(Ansi.ClearLine);
|
||||||
const msgLen = this.#statusMessage.length;
|
const msgLen = this.statusMessage.length;
|
||||||
if (msgLen > 0 && (Date.now() - this.#statusTimeout < 5000)) {
|
if (msgLen > 0 && (Date.now() - this.statusTimeout < 5000)) {
|
||||||
this.#buffer.append(this.#statusMessage, this.#screen.cols);
|
this.buffer.append(this.statusMessage, this.screen.cols);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Terminal input parsing
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private async processNormalKeyPress(input: string): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
export default Editor;
|
||||||
|
@ -51,9 +51,6 @@ export async function main() {
|
|||||||
if (!shouldLoop) {
|
if (!shouldLoop) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render output
|
|
||||||
await editor.refreshScreen();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ let scrollRuntime: IRuntime | null = null;
|
|||||||
|
|
||||||
export function log(s: unknown, level: LogLevel = LogLevel.Notice): void {
|
export function log(s: unknown, level: LogLevel = LogLevel.Notice): void {
|
||||||
getRuntime().then(({ file }) => {
|
getRuntime().then(({ file }) => {
|
||||||
const raw = typeof s === 'string' ? s : JSON.stringify(s, null, 2);
|
const raw = JSON.stringify(s, null, 2);
|
||||||
const output = `${level}: ${raw}\n`;
|
const output = `${level}: ${raw}\n`;
|
||||||
|
|
||||||
const outputFile = (level === LogLevel.Error)
|
const outputFile = (level === LogLevel.Error)
|
||||||
@ -85,15 +85,18 @@ export async function getRuntime(): Promise<IRuntime> {
|
|||||||
const pkg = await import(path);
|
const pkg = await import(path);
|
||||||
if ('default' in pkg) {
|
if ('default' in pkg) {
|
||||||
scrollRuntime = pkg.default;
|
scrollRuntime = pkg.default;
|
||||||
|
}
|
||||||
|
|
||||||
if (scrollRuntime !== null) {
|
if (scrollRuntime !== null) {
|
||||||
return Promise.resolve(scrollRuntime);
|
return Promise.resolve(scrollRuntime);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject('Missing default import');
|
return Promise.reject('Missing default import');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(scrollRuntime);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the common test interface object
|
* Get the common test interface object
|
||||||
*/
|
*/
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
if (!('Deno' in globalThis)) {
|
||||||
|
throw new Error('This module requires Deno to run');
|
||||||
|
}
|
||||||
|
|
||||||
import { IFileIO } from '../common/runtime.ts';
|
import { IFileIO } from '../common/runtime.ts';
|
||||||
|
|
||||||
const DenoFileIO: IFileIO = {
|
const DenoFileIO: IFileIO = {
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
if (!('Deno' in globalThis)) {
|
||||||
|
throw new Error('This module requires Deno to run');
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* The main entrypoint when using Deno as the runtime
|
* The main entrypoint when using Deno as the runtime
|
||||||
*/
|
*/
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
if (!('Deno' in globalThis)) {
|
||||||
|
throw new Error('This module requires Deno to run');
|
||||||
|
}
|
||||||
import { readKey } from '../common/fns.ts';
|
import { readKey } from '../common/fns.ts';
|
||||||
import { ITerminal, ITerminalSize } from '../common/types.ts';
|
import { ITerminal, ITerminalSize } from '../common/types.ts';
|
||||||
|
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
if (!('Deno' in globalThis)) {
|
||||||
|
throw new Error('This module requires Deno to run');
|
||||||
|
}
|
||||||
import { ITestBase } from '../common/types.ts';
|
import { ITestBase } from '../common/types.ts';
|
||||||
import { stdAssert } from './deps.ts';
|
import { stdAssert } from './deps.ts';
|
||||||
const {
|
const {
|
||||||
|
Loading…
Reference in New Issue
Block a user