diff --git a/src/bun/test_base.ts b/src/bun/test_base.ts index 8fc1efb..312acde 100644 --- a/src/bun/test_base.ts +++ b/src/bun/test_base.ts @@ -24,6 +24,7 @@ const BunTestBase: ITestBase = { expect(actual).toBeInstanceOf(expectedType), assertNotEquals: (actual: unknown, expected: unknown) => expect(actual).not.toBe(expected), + assertNull: (actual: unknown) => expect(actual).toBeNull(), assertStrictEquals: (actual: unknown, expected: unknown) => expect(actual).toBe(expected), assertTrue: (actual: boolean) => expect(actual).toBe(true), diff --git a/src/common/all_test.ts b/src/common/all_test.ts index c8d137e..e797dcb 100644 --- a/src/common/all_test.ts +++ b/src/common/all_test.ts @@ -3,7 +3,7 @@ import Document from './document.ts'; import Editor from './editor.ts'; import Row from './row.ts'; import { Ansi, KeyCommand } from './ansi.ts'; -import { defaultTerminalSize } from './config.ts'; +import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts'; import { getTestRunner } from './runtime.ts'; import { Position } from './types.ts'; import * as Util from './fns.ts'; @@ -13,6 +13,7 @@ const { assertExists, assertInstanceOf, assertNotEquals, + assertNull, assertFalse, assertTrue, testSuite, @@ -154,6 +155,71 @@ testSuite({ // From 'chars' assertEquals(Row.from(['😺', '😸', '😹']).toString(), '😺😸😹'); }, + '.append': () => { + const row = Row.from('foo'); + row.append('bar'); + assertEquals(row.toString(), 'foobar'); + }, + '.delete': () => { + const row = Row.from('foof'); + row.delete(3); + assertEquals(row.toString(), 'foo'); + + row.delete(4); + assertEquals(row.toString(), 'foo'); + }, + '.split': () => { + // When you split a row, it's from the cursor position + // (Kind of like if the string were one-indexed) + const row = Row.from('foobar'); + const row2 = Row.from('bar'); + assertEquals(row.split(3).toString(), row2.toString()); + }, + '.find': () => { + const normalRow = Row.from('For whom the bell tolls'); + assertEquals(normalRow.find('who'), 4); + assertNull(normalRow.find('foo')); + + const emojiRow = Row.from('😺😸😹'); + assertEquals(emojiRow.find('😹'), 2); + assertNull(emojiRow.find('🀰🏼')); + }, + '.byteIndexToCharIndex': () => { + // Each 'character' is two bytes + const row = Row.from('πŸ˜ΊπŸ˜ΈπŸ˜ΉπŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'); + assertEquals(row.byteIndexToCharIndex(4), 2); + assertEquals(row.byteIndexToCharIndex(2), 1); + assertEquals(row.byteIndexToCharIndex(0), 0); + + // Return count on nonsense index + assertEquals(Util.strlen(row.toString()), 10); + assertEquals(row.byteIndexToCharIndex(72), 10); + + const row2 = Row.from('foobar'); + assertEquals(row2.byteIndexToCharIndex(2), 2); + }, + '.charIndexToByteIndex': () => { + // Each 'character' is two bytes + const row = Row.from('πŸ˜ΊπŸ˜ΈπŸ˜ΉπŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦'); + assertEquals(row.charIndexToByteIndex(2), 4); + assertEquals(row.charIndexToByteIndex(1), 2); + assertEquals(row.charIndexToByteIndex(0), 0); + }, + '.cxToRx, .rxToCx': () => { + const row = Row.from('foo\tbar\tbaz'); + row.updateRender(); + assertNotEquals(row.chars, row.render); + assertNotEquals(row.size, row.rsize); + assertEquals(row.size, 11); + assertEquals(row.rsize, row.size + (SCROLL_TAB_SIZE * 2) - 2); + + const cx = 11; + const aRx = row.cxToRx(cx); + const rx = 11; + const aCx = row.rxToCx(aRx); + assertEquals(aCx, cx); + assertEquals(aRx, rx); + }, }, 'fns': { 'arrayInsert() strings': () => { diff --git a/src/common/document.ts b/src/common/document.ts index 9024e64..f81a41d 100644 --- a/src/common/document.ts +++ b/src/common/document.ts @@ -5,7 +5,11 @@ import { Position } from './types.ts'; export class Document { #rows: Row[]; - dirty: boolean; + + /** + * Has the document been modified? + */ + public dirty: boolean; private constructor() { this.#rows = []; @@ -49,6 +53,21 @@ export class Document { this.dirty = false; } + public find( + q: string, + offset: Position = Position.default(), + ): Position | null { + let i = offset.y; + for (; i < this.numRows; i++) { + const possible = this.#rows[i].find(q, offset.x); + if (possible !== null) { + return Position.at(possible, i); + } + } + + return null; + } + public insert(at: Position, c: string): void { if (at.y === this.numRows) { this.insertRow(this.numRows, c); diff --git a/src/common/editor.ts b/src/common/editor.ts index 188ce9f..17692d4 100644 --- a/src/common/editor.ts +++ b/src/common/editor.ts @@ -123,6 +123,10 @@ class Editor { // ---------------------------------------------------------------------- // Ctrl-key chords // ---------------------------------------------------------------------- + case ctrlKey('f'): + await this.find(); + break; + case ctrlKey('s'): await this.save(); break; @@ -270,6 +274,21 @@ class Editor { } } + /** + * Find text within the document + */ + public async find(): Promise { + const res = await this.prompt('Search: %s (ESC to cancel)'); + if (res !== null && res.length > 0) { + const pos = this.#document.find(res); + if (pos !== null) { + this.#cursor = pos; + } else { + this.setStatusMessage('Not found'); + } + } + } + /** * Filter out any additional unwanted keyboard input * @param input diff --git a/src/common/main.ts b/src/common/main.ts index 71dc986..b37f604 100644 --- a/src/common/main.ts +++ b/src/common/main.ts @@ -34,7 +34,9 @@ export async function main() { } } - editor.setStatusMessage('HELP: Ctrl-S = save | Ctrl-Q = quit'); + editor.setStatusMessage( + 'HELP: Ctrl-S = save | Ctrl-Q = quit | Ctrl-F = find', + ); // Clear the screen await editor.refreshScreen(); diff --git a/src/common/row.ts b/src/common/row.ts index 6bf9aaa..d2934cb 100644 --- a/src/common/row.ts +++ b/src/common/row.ts @@ -75,6 +75,25 @@ export class Row { this.chars.splice(at, 1); } + public find(s: string, offset: number = 0): number | null { + const thisStr = this.toString(); + if (!this.toString().includes(s)) { + return null; + } + + const byteCount = thisStr.indexOf(s, this.charIndexToByteIndex(offset)); + + // In many cases, the string length will + // equal the number of characters. So + // searching is fairly easy + if (thisStr.length === this.chars.length) { + return byteCount; + } + + // Emoji/Extended Unicode-friendly search + return this.byteIndexToCharIndex(byteCount); + } + public cxToRx(cx: number): number { let rx = 0; let j = 0; @@ -88,12 +107,57 @@ export class Row { return rx; } + public rxToCx(rx: number): number { + let curRx = 0; + let cx = 0; + for (; cx < this.size; cx++) { + if (this.chars[cx] === '\t') { + curRx += (SCROLL_TAB_SIZE - 1) - (curRx % SCROLL_TAB_SIZE); + } + curRx++; + + if (curRx > rx) { + return cx; + } + } + + return cx; + } + + public byteIndexToCharIndex(byteIndex: number): number { + if (this.toString().length === this.chars.length) { + return byteIndex; + } + + let n = 0; + let byteCount = 0; + for (; n < this.chars.length; n++) { + byteCount += this.chars[n].length; + if (byteCount > byteIndex) { + return n; + } + } + + return this.chars.length; + } + + public charIndexToByteIndex(charIndex: number): number { + if (charIndex === 0 || this.toString().length === this.chars.length) { + return charIndex; + } + + return this.chars.slice(0, charIndex).reduce( + (prev, current) => prev += current.length, + 0, + ); + } + public toString(): string { return this.chars.join(''); } public updateRender(): void { - const newString = this.chars.join('').replace( + const newString = this.chars.join('').replaceAll( '\t', ' '.repeat(SCROLL_TAB_SIZE), ); diff --git a/src/common/termios.ts b/src/common/termios.ts index 45c5840..b8788cc 100644 --- a/src/common/termios.ts +++ b/src/common/termios.ts @@ -59,9 +59,6 @@ class Termios { cleanup() { if (!this.#cleaned) { - this.#ptr = null; - this.#cookedTermios = new Uint8Array(0); - this.#termios = new Uint8Array(0); this.#ffi.close(); this.#cleaned = true; diff --git a/src/common/types.ts b/src/common/types.ts index 85ad8bc..ff538d7 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -166,6 +166,7 @@ export interface ITestBase { assertFalse(actual: boolean): void; assertInstanceOf(actual: unknown, expectedType: any): void; assertNotEquals(actual: unknown, expected: unknown): void; + assertNull(actual: unknown): void; assertStrictEquals(actual: unknown, expected: unknown): void; assertTrue(actual: boolean): void; testSuite(testObj: any): void; diff --git a/src/deno/mod.ts b/src/deno/mod.ts index 2ae59b4..c5a48f8 100644 --- a/src/deno/mod.ts +++ b/src/deno/mod.ts @@ -5,6 +5,8 @@ import { IRuntime, RunTimeType } from '../common/runtime.ts'; import DenoTerminalIO from './terminal_io.ts'; import DenoFileIO from './file_io.ts'; +import * as node_process from 'node:process'; + const DenoRuntime: IRuntime = { name: RunTimeType.Deno, file: DenoFileIO, @@ -13,8 +15,9 @@ const DenoRuntime: IRuntime = { globalThis.addEventListener(eventName, handler), onExit: (cb: () => void): void => { globalThis.addEventListener('onbeforeunload', cb); + globalThis.onbeforeunload = cb; }, - exit: (code?: number) => Deno.exit(code), + exit: (code?: number) => node_process.exit(code), }; export default DenoRuntime; diff --git a/src/deno/test_base.ts b/src/deno/test_base.ts index b704638..0ebaeac 100644 --- a/src/deno/test_base.ts +++ b/src/deno/test_base.ts @@ -34,6 +34,11 @@ const DenoTestBase: ITestBase = { throw new AssertionError(`actual: "${actual}" expected to be false"`); } }, + assertNull(actual: boolean): void { + if (actual !== null) { + throw new AssertionError(`actual: "${actual}" expected to be null"`); + } + }, testSuite, };