Implement basic searching

This commit is contained in:
Timothy Warren 2023-11-30 16:14:52 -05:00
parent 32e4030a4a
commit f5599b5192
10 changed files with 185 additions and 8 deletions

View File

@ -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),

View File

@ -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': () => {

View File

@ -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);

View File

@ -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<void> {
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

View File

@ -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();

View File

@ -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),
);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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,
};