Fix some issues with line splitting/merging
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good

This commit is contained in:
Timothy Warren 2024-07-10 16:12:39 -04:00
parent 4436a8a783
commit 4be7be09a7
9 changed files with 117 additions and 102 deletions

7
.gitignore vendored
View File

@ -335,8 +335,13 @@ $RECYCLE.BIN/
# End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,vim,node,deno,macos,windows,linux # End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,vim,node,deno,macos,windows,linux
# Other editors
.nova/
.zed/
## Misc generated files ## Misc generated files
scroll.err scroll*.log
docs
deno.lock deno.lock
cov_profile/ cov_profile/
coverage/ coverage/

View File

@ -14,6 +14,9 @@ To simplify running, I'm using [Just](https://github.com/casey/just).
- Deno: `just deno-run [filename]` - Deno: `just deno-run [filename]`
- TSX: `just tsx-run [filename` - TSX: `just tsx-run [filename`
Deno is generally used for dev tools, but each runtime should be functionally
equivalent running the text editor.
## Development Notes ## Development Notes
- Implementation is based on [Kilo](https://viewsourcecode.org/snaptoken/kilo/) - Implementation is based on [Kilo](https://viewsourcecode.org/snaptoken/kilo/)

View File

@ -6,7 +6,7 @@ default:
coverage: bun-test deno-coverage coverage: bun-test deno-coverage
# Typescript checking # Typescript checking
check: deno-check bun-check check: deno-check bun-check tsx-check
docs: docs:
deno doc --html --unstable-ffi --name="Scroll" ./src/scroll.ts ./src/common/*.ts ./src/deno/mod.ts ./src/bun/mod.ts deno doc --html --unstable-ffi --name="Scroll" ./src/scroll.ts ./src/common/*.ts ./src/deno/mod.ts ./src/bun/mod.ts
@ -27,8 +27,8 @@ clean:
rm -rf .deno-cover rm -rf .deno-cover
rm -rf coverage rm -rf coverage
rm -rf docs rm -rf docs
rm -f scroll.log rm -f scroll*.log
rm -f scroll.err rm -f test.file
rm -f tsconfig.tsbuildinfo rm -f tsconfig.tsbuildinfo
########################################################################################## ##########################################################################################
@ -53,8 +53,8 @@ bun-run file="":
# Lint code and check types # Lint code and check types
deno-check: deno-check:
deno lint deno task deno-lint
deno check --unstable-ffi --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts deno task deno-check
# Test with deno # Test with deno
deno-test: deno-test:
@ -66,15 +66,20 @@ deno-coverage:
# Run with deno # Run with deno
deno-run file="": deno-run file="":
deno run --allow-all --allow-ffi --deny-hrtime --unstable-ffi ./src/scroll.ts {{file}} deno task deno-run {{file}}
#deno run --allow-all --allow-ffi --deny-hrtime --unstable-ffi ./src/scroll.ts {{file}}
########################################################################################## ##########################################################################################
# tsx(Node JS)-specific commands # tsx(Node JS)-specific commands
########################################################################################## ##########################################################################################
# Check code with actual Typescript compiler
tsx-check:
npm run tsx-check
# Test with tsx (NodeJS) # Test with tsx (NodeJS)
tsx-test: tsx-test:
npx tsx --test './src/common/all_test.ts' npm run tsx-test
# Run with tsx (NodeJS) # Run with tsx (NodeJS)
tsx-run file="": tsx-run file="":

View File

@ -4,6 +4,16 @@
"bun-types": "^1.0.11" "bun-types": "^1.0.11"
}, },
"scripts": { "scripts": {
"bun-check": "bunx tsc",
"bun-coverage": "bun test --coverage",
"bun-test": "bun test",
"deno-lint": "deno lint",
"deno-check": "deno check --unstable-ffi --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts ./src/tsx/*.ts",
"deno-coverage": "./coverage.sh",
"deno-run": "deno run --allow-all --allow-ffi --deny-hrtime --unstable-ffi ./src/scroll.ts",
"deno-test": "deno test --allow-all --unstable-ffi",
"tsx-check": "npx tsc",
"tsx-test": "npx tsx --test './src/common/all_test.ts'"
}, },
"type": "module" "type": "module"
} }

View File

@ -381,25 +381,18 @@ const DocumentTest = {
doc.delete(Position.at(3, 0)); doc.delete(Position.at(3, 0));
assertEquals(doc.row(0).unwrap().toString(), 'fooar'); assertEquals(doc.row(0).unwrap().toString(), 'fooar');
// Merge previous row // Merge next row
const doc2 = Document.default(); const doc2 = Document.default();
doc2.insertNewline(Position.default()); doc2.insertNewline(Position.default());
doc2.insert(Position.at(0, 1), 'foobar'); doc2.insert(Position.at(0, 1), 'foobar');
doc2.delete(Position.at(0, 1)); doc2.delete(Position.at(0, 0));
assertEquals(doc2.row(0).unwrap().toString(), 'foobar'); assertEquals(doc2.row(0).unwrap().toString(), 'foobar');
// Merge next row
const doc3 = Document.default();
doc3.insertNewline(Position.default());
doc3.insert(Position.at(0, 1), 'foobar');
doc3.delete(Position.at(0, 0));
assertEquals(doc3.row(0).unwrap().toString(), 'foobar');
// Invalid delete location // Invalid delete location
const doc4 = Document.default(); const doc3 = Document.default();
doc4.insert(Position.default(), 'foobar'); doc3.insert(Position.default(), 'foobar');
doc4.delete(Position.at(0, 1)); doc3.delete(Position.at(0, 3));
assertEquals(doc4.row(0).unwrap().toString(), 'foobar'); assertEquals(doc3.row(0).unwrap().toString(), 'foobar');
}, },
}; };

View File

@ -4,8 +4,8 @@ export const SCROLL_VERSION = '0.0.1';
export const SCROLL_QUIT_TIMES = 3; export const SCROLL_QUIT_TIMES = 3;
export const SCROLL_TAB_SIZE = 4; export const SCROLL_TAB_SIZE = 4;
export const SCROLL_LOG_FILE = './scroll.log'; export const SCROLL_LOG_FILE_PREFIX = './scroll';
export const SCROLL_ERR_FILE = './scroll.err'; export const SCROLL_LOG_FILE_SUFFIX = '.log';
export const defaultTerminalSize: ITerminalSize = { export const defaultTerminalSize: ITerminalSize = {
rows: 24, rows: 24,

View File

@ -1,7 +1,7 @@
import Row from './row.ts'; import Row from './row.ts';
import { arrayInsert, maxAdd, minSub } from './fns.ts'; import { arrayInsert, maxAdd, minSub } from './fns.ts';
import Option, { None, Some } from './option.ts'; import Option, { None, Some } from './option.ts';
import { getRuntime, log, LogLevel } from './runtime.ts'; import { getRuntime, logDebug } from './runtime.ts';
import { Position, SearchDirection } from './types.ts'; import { Position, SearchDirection } from './types.ts';
export class Document { export class Document {
@ -153,6 +153,8 @@ export class Document {
return; return;
} }
this.dirty = true;
const maybeRow = this.row(at.y); const maybeRow = this.row(at.y);
if (maybeRow.isNone()) { if (maybeRow.isNone()) {
return; return;
@ -161,38 +163,29 @@ export class Document {
const row = maybeRow.unwrap(); const row = maybeRow.unwrap();
const mergeNextRow = at.x === row.size && this.row(at.y + 1).isSome(); const mergeNextRow = at.x === row.size && this.row(at.y + 1).isSome();
const mergeIntoPrevRow = at.x === 0 && this.row(at.y - 1).isSome() &&
this.row(at.y).isSome();
log({ logDebug('Document.delete', {
method: 'Document.delete', method: 'Document.delete',
at, at,
mergeNextRow, mergeNextRow,
mergeIntoPrevRow, });
}, LogLevel.Debug);
// If we are at the end of a line, and press delete, // If we are at the end of a line, and press delete,
// add the contents of the next row, and delete // add the contents of the next row, and delete
// the merged row object // the merged row object (This also works for pressing
// backspace at the beginning of a line: the cursor is
// moved to the end of the previous line)
if (mergeNextRow) { if (mergeNextRow) {
// At the end of a line, pressing delete will merge // At the end of a line, pressing delete will merge
// the next line into the current on // the next line into the current one
const rowToAppend = this.#rows.at(at.y + 1)!.toString(); const rowToAppend = this.#rows[at.y + 1].toString();
row.append(rowToAppend); row.append(rowToAppend);
this.deleteRow(at.y + 1); this.deleteRow(at.y + 1);
} else if (mergeIntoPrevRow) {
// At the beginning of a line, merge the current line
// into the previous Row
const rowToAppend = row.toString();
this.#rows[at.y - 1].append(rowToAppend);
this.deleteRow(at.y);
} else { } else {
row.delete(at.x); row.delete(at.x);
} }
row.update(None); row.update(None);
this.dirty = true;
} }
public row(i: number): Option<Row> { public row(i: number): Option<Row> {
@ -200,7 +193,7 @@ export class Document {
return None; return None;
} }
return Option.from(this.#rows[i]); return Option.from(this.#rows.at(i));
} }
public insertRow(at: number = this.numRows, s: string = ''): void { public insertRow(at: number = this.numRows, s: string = ''): void {
@ -219,17 +212,15 @@ export class Document {
/** /**
* Delete the specified row * Delete the specified row
* @param at - the index of the row to delete * @param at - the index of the row to delete
* @private
*/ */
private deleteRow(at: number): void { protected deleteRow(at: number): void {
this.#rows.splice(at, 1); this.#rows.splice(at, 1);
} }
/** /**
* Convert the array of row objects into one string * Convert the array of row objects into one string
* @private
*/ */
private rowsToString(): string { protected rowsToString(): string {
return this.#rows.map((r) => r.toString()).join('\n'); return this.#rows.map((r) => r.toString()).join('\n');
} }
} }

View File

@ -13,7 +13,7 @@ import {
truncate, truncate,
} from './fns.ts'; } from './fns.ts';
import Option, { None, Some } from './option.ts'; import Option, { None, Some } from './option.ts';
import { getRuntime, log, LogLevel } from './runtime.ts'; import { getRuntime, logDebug, logWarning } from './runtime.ts';
import { ITerminalSize, Position, SearchDirection } from './types.ts'; import { ITerminalSize, Position, SearchDirection } from './types.ts';
export default class Editor { export default class Editor {
@ -72,12 +72,16 @@ export default class Editor {
this.document = Document.default(); this.document = Document.default();
} }
private get numRows(): number { protected get numRows(): number {
return this.document.numRows; return this.document.numRows;
} }
private get currentRow(): Option<Row> { protected row(at: number): Option<Row> {
return this.document.row(this.cursor.y); return this.document.row(at);
}
protected get currentRow(): Option<Row> {
return this.row(this.cursor.y);
} }
public async open(filename: string): Promise<Editor> { public async open(filename: string): Promise<Editor> {
@ -150,11 +154,9 @@ export default class Editor {
break; break;
case KeyCommand.Backspace: case KeyCommand.Backspace:
{ if (this.cursor.x > 0 || this.cursor.y > 0) {
if (this.cursor.x > 0 || this.cursor.y > 0) { this.moveCursor(KeyCommand.ArrowLeft);
this.moveCursor(KeyCommand.ArrowLeft); this.document.delete(this.cursor);
this.document.delete(this.cursor);
}
} }
break; break;
@ -317,20 +319,19 @@ export default class Editor {
* Filter out any additional unwanted keyboard input * Filter out any additional unwanted keyboard input
* *
* @param input * @param input
* @private
*/ */
private shouldFilter(input: string): boolean { protected shouldFilter(input: string): boolean {
const isEscapeSequence = input[0] === KeyCommand.Escape; const isEscapeSequence = input[0] === KeyCommand.Escape;
const isCtrl = isControl(input); const isCtrl = isControl(input);
const shouldFilter = isEscapeSequence || isCtrl; const shouldFilter = isEscapeSequence || isCtrl;
const whitelist = ['\t']; const whitelist = ['\t'];
if (shouldFilter && !whitelist.includes(input)) { if (shouldFilter && !whitelist.includes(input)) {
log({ logDebug('Ignoring input:', {
'msg': `Ignoring input: ${input}`, input,
isEscapeSequence, isEscapeSequence,
isCtrl, isCtrl,
}, LogLevel.Debug); });
return true; return true;
} }
@ -338,22 +339,20 @@ export default class Editor {
return false; return false;
} }
private moveCursor(char: string): void { protected moveCursor(char: string): void {
const screenHeight = this.screen.rows; const screenHeight = this.screen.rows;
let { x, y } = this.cursor; let { x, y } = this.cursor;
const height = this.numRows; const height = this.numRows;
let width = (this.document.row(y).isSome()) let width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0;
? this.currentRow.unwrap().size
: 0;
log({ logDebug('Editor.moveCursor - start', {
method: 'Editor.moveCursor - start', char,
cursor: this.cursor, cursor: this.cursor,
renderX: this.renderX, renderX: this.renderX,
screen: this.screen, screen: this.screen,
height, height,
width, width,
}, LogLevel.Debug); });
switch (char) { switch (char) {
case KeyCommand.ArrowUp: case KeyCommand.ArrowUp:
@ -371,12 +370,12 @@ export default class Editor {
x -= 1; x -= 1;
} else if (y > 0) { } else if (y > 0) {
y -= 1; y -= 1;
x = (this.currentRow.isSome()) ? this.currentRow.unwrap().rsize : 0; x = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0;
} }
break; break;
case KeyCommand.ArrowRight: case KeyCommand.ArrowRight:
if ( if (
this.currentRow.isSome() && x < width this.row(y).isSome() && x < width
) { ) {
x += 1; x += 1;
} else if (y < height) { } else if (y < height) {
@ -398,7 +397,7 @@ export default class Editor {
break; break;
} }
width = (this.currentRow.isSome()) ? this.currentRow.unwrap().size : 0; width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0;
if (x > width) { if (x > width) {
x = width; x = width;
@ -406,27 +405,25 @@ export default class Editor {
this.cursor = Position.at(x, y); this.cursor = Position.at(x, y);
log({ logDebug('Editor.moveCursor - end', {
method: 'Editor.moveCursor - end',
cursor: this.cursor, cursor: this.cursor,
renderX: this.renderX, renderX: this.renderX,
screen: this.screen, screen: this.screen,
height, height,
width, width,
}, LogLevel.Debug); });
} }
private scroll(): void { protected scroll(): void {
this.renderX = (this.currentRow.isSome()) this.renderX = (this.row(this.cursor.y).isSome())
? this.currentRow.unwrap().cxToRx(this.cursor.x) ? this.document.row(this.cursor.y).unwrap().cxToRx(this.cursor.x)
: 0; : 0;
log({ logDebug('Editor.scroll - start', {
method: 'Editor.scroll - start',
cursor: this.cursor, cursor: this.cursor,
renderX: this.renderX, renderX: this.renderX,
offset: this.offset, offset: this.offset,
}, LogLevel.Debug); });
const { y } = this.cursor; const { y } = this.cursor;
const offset = this.offset; const offset = this.offset;
@ -445,12 +442,11 @@ export default class Editor {
offset.x = this.renderX - width + 1; offset.x = this.renderX - width + 1;
} }
log({ logDebug('Editor.scroll - end', {
method: 'Editor.scroll - end',
cursor: this.cursor, cursor: this.cursor,
renderX: this.renderX, renderX: this.renderX,
offset: this.offset, offset: this.offset,
}, LogLevel.Debug); });
} }
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -484,14 +480,14 @@ export default class Editor {
await this.buffer.flush(); await this.buffer.flush();
} }
private async clearScreen(): Promise<void> { protected 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 { protected 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;
@ -505,10 +501,10 @@ export default class Editor {
} }
} }
private drawFileRow(y: number): void { protected drawFileRow(y: number): void {
const maybeRow = this.document.row(y); const maybeRow = this.document.row(y);
if (maybeRow.isNone()) { if (maybeRow.isNone()) {
log(`Trying to draw non-existent row '${y}'`, LogLevel.Warning); logWarning(`Trying to draw non-existent row '${y}'`);
return this.drawPlaceholderRow(y); return this.drawPlaceholderRow(y);
} }
@ -522,7 +518,7 @@ export default class Editor {
this.buffer.append(row.render(this.offset.x, len)); this.buffer.append(row.render(this.offset.x, len));
} }
private drawPlaceholderRow(y: number): void { protected 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)
@ -542,7 +538,7 @@ export default class Editor {
} }
} }
private drawStatusBar(): void { protected 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)' : '';
@ -563,7 +559,7 @@ export default class Editor {
this.buffer.appendLine(Ansi.ResetFormatting); this.buffer.appendLine(Ansi.ResetFormatting);
} }
private drawMessageBar(): void { protected 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)) {

View File

@ -7,8 +7,8 @@ import { IRuntime, ITerminalSize, ITestBase } from './types.ts';
import { noop } from './fns.ts'; import { noop } from './fns.ts';
import { import {
defaultTerminalSize, defaultTerminalSize,
SCROLL_ERR_FILE, SCROLL_LOG_FILE_PREFIX,
SCROLL_LOG_FILE, SCROLL_LOG_FILE_SUFFIX,
} from './config.ts'; } from './config.ts';
export type { IFileIO, IRuntime, ITerminal } from './types.ts'; export type { IFileIO, IRuntime, ITerminal } from './types.ts';
@ -79,24 +79,36 @@ async function _getTerminalSizeFromAnsi(): Promise<ITerminalSize> {
}; };
} }
export function log(s: unknown, level: LogLevel = LogLevel.Notice): void { /**
* Basic logging -
*/
export function log(
s: unknown,
level: LogLevel = LogLevel.Notice,
data?: any,
): void {
getRuntime().then(({ file }) => { getRuntime().then(({ file }) => {
const raw = JSON.stringify(s, null, 2); const rawS = JSON.stringify(s, null, 2);
const output = `${level}: ${raw}\n`; const rawData = JSON.stringify(data, null, 2);
const output = (typeof data !== 'undefined')
? `${rawS}\n${rawData}\n\n`
: `${rawS}\n`;
const outputFile = (level === LogLevel.Error) const outputFile =
? SCROLL_ERR_FILE `${SCROLL_LOG_FILE_PREFIX}-${level.toLowerCase()}${SCROLL_LOG_FILE_SUFFIX}`;
: SCROLL_LOG_FILE;
file.appendFile(outputFile, output).then(noop); file.appendFile(outputFile, output).then(noop);
}); });
} }
/** export const logDebug = (s: unknown, data?: any) =>
* Append information to the scroll.err logfile log(s, LogLevel.Debug, data);
*/ export const logInfo = (s: unknown, data?: any) => log(s, LogLevel.Info, data);
export function logError(s: unknown): void { export const logNotice = (s: unknown, data?: any) =>
log(s, LogLevel.Error); log(s, LogLevel.Notice, data);
} export const logWarning = (s: unknown, data?: any) =>
log(s, LogLevel.Warning, data);
export const logError = (s: unknown, data?: any) =>
log(s, LogLevel.Warning, data);
/** /**
* Kill program, displaying an error message * Kill program, displaying an error message