Fix some issues with line splitting/merging
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
All checks were successful
timw4mail/scroll/pipeline/head This commit looks good
This commit is contained in:
parent
4436a8a783
commit
4be7be09a7
7
.gitignore
vendored
7
.gitignore
vendored
@ -335,8 +335,13 @@ $RECYCLE.BIN/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/jetbrains+all,vim,node,deno,macos,windows,linux
|
||||
|
||||
# Other editors
|
||||
.nova/
|
||||
.zed/
|
||||
|
||||
## Misc generated files
|
||||
scroll.err
|
||||
scroll*.log
|
||||
docs
|
||||
deno.lock
|
||||
cov_profile/
|
||||
coverage/
|
||||
|
@ -14,6 +14,9 @@ To simplify running, I'm using [Just](https://github.com/casey/just).
|
||||
- Deno: `just deno-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
|
||||
|
||||
- Implementation is based on [Kilo](https://viewsourcecode.org/snaptoken/kilo/)
|
||||
|
19
justfile
19
justfile
@ -6,7 +6,7 @@ default:
|
||||
coverage: bun-test deno-coverage
|
||||
|
||||
# Typescript checking
|
||||
check: deno-check bun-check
|
||||
check: deno-check bun-check tsx-check
|
||||
|
||||
docs:
|
||||
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 coverage
|
||||
rm -rf docs
|
||||
rm -f scroll.log
|
||||
rm -f scroll.err
|
||||
rm -f scroll*.log
|
||||
rm -f test.file
|
||||
rm -f tsconfig.tsbuildinfo
|
||||
|
||||
##########################################################################################
|
||||
@ -53,8 +53,8 @@ bun-run file="":
|
||||
|
||||
# Lint code and check types
|
||||
deno-check:
|
||||
deno lint
|
||||
deno check --unstable-ffi --all -c deno.jsonc ./src/deno/*.ts ./src/common/*.ts
|
||||
deno task deno-lint
|
||||
deno task deno-check
|
||||
|
||||
# Test with deno
|
||||
deno-test:
|
||||
@ -66,15 +66,20 @@ deno-coverage:
|
||||
|
||||
# Run with deno
|
||||
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
|
||||
##########################################################################################
|
||||
|
||||
# Check code with actual Typescript compiler
|
||||
tsx-check:
|
||||
npm run tsx-check
|
||||
|
||||
# Test with tsx (NodeJS)
|
||||
tsx-test:
|
||||
npx tsx --test './src/common/all_test.ts'
|
||||
npm run tsx-test
|
||||
|
||||
# Run with tsx (NodeJS)
|
||||
tsx-run file="":
|
||||
|
10
package.json
10
package.json
@ -4,6 +4,16 @@
|
||||
"bun-types": "^1.0.11"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
|
@ -381,25 +381,18 @@ const DocumentTest = {
|
||||
doc.delete(Position.at(3, 0));
|
||||
assertEquals(doc.row(0).unwrap().toString(), 'fooar');
|
||||
|
||||
// Merge previous row
|
||||
// Merge next row
|
||||
const doc2 = Document.default();
|
||||
doc2.insertNewline(Position.default());
|
||||
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');
|
||||
|
||||
// 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
|
||||
const doc4 = Document.default();
|
||||
doc4.insert(Position.default(), 'foobar');
|
||||
doc4.delete(Position.at(0, 1));
|
||||
assertEquals(doc4.row(0).unwrap().toString(), 'foobar');
|
||||
const doc3 = Document.default();
|
||||
doc3.insert(Position.default(), 'foobar');
|
||||
doc3.delete(Position.at(0, 3));
|
||||
assertEquals(doc3.row(0).unwrap().toString(), 'foobar');
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -4,8 +4,8 @@ export const SCROLL_VERSION = '0.0.1';
|
||||
export const SCROLL_QUIT_TIMES = 3;
|
||||
export const SCROLL_TAB_SIZE = 4;
|
||||
|
||||
export const SCROLL_LOG_FILE = './scroll.log';
|
||||
export const SCROLL_ERR_FILE = './scroll.err';
|
||||
export const SCROLL_LOG_FILE_PREFIX = './scroll';
|
||||
export const SCROLL_LOG_FILE_SUFFIX = '.log';
|
||||
|
||||
export const defaultTerminalSize: ITerminalSize = {
|
||||
rows: 24,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import Row from './row.ts';
|
||||
import { arrayInsert, maxAdd, minSub } from './fns.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';
|
||||
|
||||
export class Document {
|
||||
@ -153,6 +153,8 @@ export class Document {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dirty = true;
|
||||
|
||||
const maybeRow = this.row(at.y);
|
||||
if (maybeRow.isNone()) {
|
||||
return;
|
||||
@ -161,38 +163,29 @@ export class Document {
|
||||
const row = maybeRow.unwrap();
|
||||
|
||||
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',
|
||||
at,
|
||||
mergeNextRow,
|
||||
mergeIntoPrevRow,
|
||||
}, LogLevel.Debug);
|
||||
});
|
||||
|
||||
// If we are at the end of a line, and press 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) {
|
||||
// At the end of a line, pressing delete will merge
|
||||
// the next line into the current on
|
||||
const rowToAppend = this.#rows.at(at.y + 1)!.toString();
|
||||
// the next line into the current one
|
||||
const rowToAppend = this.#rows[at.y + 1].toString();
|
||||
row.append(rowToAppend);
|
||||
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 {
|
||||
row.delete(at.x);
|
||||
}
|
||||
|
||||
row.update(None);
|
||||
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
public row(i: number): Option<Row> {
|
||||
@ -200,7 +193,7 @@ export class Document {
|
||||
return None;
|
||||
}
|
||||
|
||||
return Option.from(this.#rows[i]);
|
||||
return Option.from(this.#rows.at(i));
|
||||
}
|
||||
|
||||
public insertRow(at: number = this.numRows, s: string = ''): void {
|
||||
@ -219,17 +212,15 @@ export class Document {
|
||||
/**
|
||||
* Delete the specified row
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
truncate,
|
||||
} from './fns.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';
|
||||
|
||||
export default class Editor {
|
||||
@ -72,12 +72,16 @@ export default class Editor {
|
||||
this.document = Document.default();
|
||||
}
|
||||
|
||||
private get numRows(): number {
|
||||
protected get numRows(): number {
|
||||
return this.document.numRows;
|
||||
}
|
||||
|
||||
private get currentRow(): Option<Row> {
|
||||
return this.document.row(this.cursor.y);
|
||||
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> {
|
||||
@ -150,11 +154,9 @@ export default class Editor {
|
||||
break;
|
||||
|
||||
case KeyCommand.Backspace:
|
||||
{
|
||||
if (this.cursor.x > 0 || this.cursor.y > 0) {
|
||||
this.moveCursor(KeyCommand.ArrowLeft);
|
||||
this.document.delete(this.cursor);
|
||||
}
|
||||
if (this.cursor.x > 0 || this.cursor.y > 0) {
|
||||
this.moveCursor(KeyCommand.ArrowLeft);
|
||||
this.document.delete(this.cursor);
|
||||
}
|
||||
break;
|
||||
|
||||
@ -317,20 +319,19 @@ export default class Editor {
|
||||
* Filter out any additional unwanted keyboard input
|
||||
*
|
||||
* @param input
|
||||
* @private
|
||||
*/
|
||||
private shouldFilter(input: string): boolean {
|
||||
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)) {
|
||||
log({
|
||||
'msg': `Ignoring input: ${input}`,
|
||||
logDebug('Ignoring input:', {
|
||||
input,
|
||||
isEscapeSequence,
|
||||
isCtrl,
|
||||
}, LogLevel.Debug);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@ -338,22 +339,20 @@ export default class Editor {
|
||||
return false;
|
||||
}
|
||||
|
||||
private moveCursor(char: string): void {
|
||||
protected moveCursor(char: string): void {
|
||||
const screenHeight = this.screen.rows;
|
||||
let { x, y } = this.cursor;
|
||||
const height = this.numRows;
|
||||
let width = (this.document.row(y).isSome())
|
||||
? this.currentRow.unwrap().size
|
||||
: 0;
|
||||
let width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0;
|
||||
|
||||
log({
|
||||
method: 'Editor.moveCursor - start',
|
||||
logDebug('Editor.moveCursor - start', {
|
||||
char,
|
||||
cursor: this.cursor,
|
||||
renderX: this.renderX,
|
||||
screen: this.screen,
|
||||
height,
|
||||
width,
|
||||
}, LogLevel.Debug);
|
||||
});
|
||||
|
||||
switch (char) {
|
||||
case KeyCommand.ArrowUp:
|
||||
@ -371,12 +370,12 @@ export default class Editor {
|
||||
x -= 1;
|
||||
} else if (y > 0) {
|
||||
y -= 1;
|
||||
x = (this.currentRow.isSome()) ? this.currentRow.unwrap().rsize : 0;
|
||||
x = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0;
|
||||
}
|
||||
break;
|
||||
case KeyCommand.ArrowRight:
|
||||
if (
|
||||
this.currentRow.isSome() && x < width
|
||||
this.row(y).isSome() && x < width
|
||||
) {
|
||||
x += 1;
|
||||
} else if (y < height) {
|
||||
@ -398,7 +397,7 @@ export default class Editor {
|
||||
break;
|
||||
}
|
||||
|
||||
width = (this.currentRow.isSome()) ? this.currentRow.unwrap().size : 0;
|
||||
width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0;
|
||||
|
||||
if (x > width) {
|
||||
x = width;
|
||||
@ -406,27 +405,25 @@ export default class Editor {
|
||||
|
||||
this.cursor = Position.at(x, y);
|
||||
|
||||
log({
|
||||
method: 'Editor.moveCursor - end',
|
||||
logDebug('Editor.moveCursor - end', {
|
||||
cursor: this.cursor,
|
||||
renderX: this.renderX,
|
||||
screen: this.screen,
|
||||
height,
|
||||
width,
|
||||
}, LogLevel.Debug);
|
||||
});
|
||||
}
|
||||
|
||||
private scroll(): void {
|
||||
this.renderX = (this.currentRow.isSome())
|
||||
? this.currentRow.unwrap().cxToRx(this.cursor.x)
|
||||
protected scroll(): void {
|
||||
this.renderX = (this.row(this.cursor.y).isSome())
|
||||
? this.document.row(this.cursor.y).unwrap().cxToRx(this.cursor.x)
|
||||
: 0;
|
||||
|
||||
log({
|
||||
method: 'Editor.scroll - start',
|
||||
logDebug('Editor.scroll - start', {
|
||||
cursor: this.cursor,
|
||||
renderX: this.renderX,
|
||||
offset: this.offset,
|
||||
}, LogLevel.Debug);
|
||||
});
|
||||
|
||||
const { y } = this.cursor;
|
||||
const offset = this.offset;
|
||||
@ -445,12 +442,11 @@ export default class Editor {
|
||||
offset.x = this.renderX - width + 1;
|
||||
}
|
||||
|
||||
log({
|
||||
method: 'Editor.scroll - end',
|
||||
logDebug('Editor.scroll - end', {
|
||||
cursor: this.cursor,
|
||||
renderX: this.renderX,
|
||||
offset: this.offset,
|
||||
}, LogLevel.Debug);
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@ -484,14 +480,14 @@ export default class Editor {
|
||||
await this.buffer.flush();
|
||||
}
|
||||
|
||||
private async clearScreen(): Promise<void> {
|
||||
protected async clearScreen(): Promise<void> {
|
||||
this.buffer.append(Ansi.ClearScreen);
|
||||
this.buffer.append(Ansi.ResetCursor);
|
||||
|
||||
await this.buffer.flush();
|
||||
}
|
||||
|
||||
private drawRows(): void {
|
||||
protected drawRows(): void {
|
||||
for (let y = 0; y < this.screen.rows; y++) {
|
||||
this.buffer.append(Ansi.ClearLine);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -522,7 +518,7 @@ export default class Editor {
|
||||
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()) {
|
||||
const message = `Scroll editor -- version ${SCROLL_VERSION}`;
|
||||
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);
|
||||
const name = (this.filename !== '') ? this.filename : '[No Name]';
|
||||
const modified = (this.document.dirty) ? '(modified)' : '';
|
||||
@ -563,7 +559,7 @@ export default class Editor {
|
||||
this.buffer.appendLine(Ansi.ResetFormatting);
|
||||
}
|
||||
|
||||
private drawMessageBar(): void {
|
||||
protected drawMessageBar(): void {
|
||||
this.buffer.append(Ansi.ClearLine);
|
||||
const msgLen = this.statusMessage.length;
|
||||
if (msgLen > 0 && (Date.now() - this.statusTimeout < 5000)) {
|
||||
|
@ -7,8 +7,8 @@ import { IRuntime, ITerminalSize, ITestBase } from './types.ts';
|
||||
import { noop } from './fns.ts';
|
||||
import {
|
||||
defaultTerminalSize,
|
||||
SCROLL_ERR_FILE,
|
||||
SCROLL_LOG_FILE,
|
||||
SCROLL_LOG_FILE_PREFIX,
|
||||
SCROLL_LOG_FILE_SUFFIX,
|
||||
} from './config.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 }) => {
|
||||
const raw = JSON.stringify(s, null, 2);
|
||||
const output = `${level}: ${raw}\n`;
|
||||
const rawS = JSON.stringify(s, null, 2);
|
||||
const rawData = JSON.stringify(data, null, 2);
|
||||
const output = (typeof data !== 'undefined')
|
||||
? `${rawS}\n${rawData}\n\n`
|
||||
: `${rawS}\n`;
|
||||
|
||||
const outputFile = (level === LogLevel.Error)
|
||||
? SCROLL_ERR_FILE
|
||||
: SCROLL_LOG_FILE;
|
||||
const outputFile =
|
||||
`${SCROLL_LOG_FILE_PREFIX}-${level.toLowerCase()}${SCROLL_LOG_FILE_SUFFIX}`;
|
||||
file.appendFile(outputFile, output).then(noop);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Append information to the scroll.err logfile
|
||||
*/
|
||||
export function logError(s: unknown): void {
|
||||
log(s, LogLevel.Error);
|
||||
}
|
||||
export const logDebug = (s: unknown, data?: any) =>
|
||||
log(s, LogLevel.Debug, data);
|
||||
export const logInfo = (s: unknown, data?: any) => log(s, LogLevel.Info, data);
|
||||
export const logNotice = (s: unknown, data?: any) =>
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user