Implement basic searching
This commit is contained in:
parent
32e4030a4a
commit
f5599b5192
@ -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),
|
||||
|
@ -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': () => {
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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),
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user