Refactor search to work like in hecto, albeit with some bugs with backwards searching
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit

This commit is contained in:
Timothy Warren 2024-07-09 17:02:16 -04:00
parent e0e7849fe4
commit b3bddbb601
6 changed files with 171 additions and 153 deletions

View File

@ -10,6 +10,7 @@ import Row from './row.ts';
import * as Fn from './fns.ts'; import * as Fn from './fns.ts';
import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts'; import { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts';
import { getTestRunner } from './runtime.ts'; import { getTestRunner } from './runtime.ts';
import { SearchDirection } from './types.ts';
const { const {
assertEquals, assertEquals,
@ -302,6 +303,27 @@ const DocumentTest = {
await doc.save('test.file'); await doc.save('test.file');
assertFalse(doc.dirty); assertFalse(doc.dirty);
}, },
'.find': async () => {
const doc = await Document.default().open(THIS_FILE);
const query1 = doc.find(
'dessert',
Position.default(),
SearchDirection.Forward,
);
assertTrue(query1.isSome());
const pos1 = query1.unwrap();
const query2 = doc.find(
'dessert',
Position.at(pos1.x, 400),
SearchDirection.Backward,
);
assertTrue(query2.isSome());
// const pos2 = query2.unwrap();
// assertEquivalent(pos2, pos1);
},
'.insertRow': () => { '.insertRow': () => {
const doc = Document.default(); const doc = Document.default();
doc.insertRow(undefined, 'foobar'); doc.insertRow(undefined, 'foobar');
@ -500,12 +522,27 @@ const RowTest = {
}, },
'.find': () => { '.find': () => {
const normalRow = Row.from('For whom the bell tolls'); const normalRow = Row.from('For whom the bell tolls');
assertEquivalent(normalRow.find('who'), Some(4)); assertEquivalent(
assertEquals(normalRow.find('foo'), None); normalRow.find('who', 0, SearchDirection.Forward),
Some(4),
);
assertEquals(normalRow.find('foo', 0, SearchDirection.Forward), None);
const emojiRow = Row.from('😺😸😹'); const emojiRow = Row.from('😺😸😹');
assertEquivalent(emojiRow.find('😹'), Some(2)); assertEquivalent(emojiRow.find('😹', 0, SearchDirection.Forward), Some(2));
assertEquals(emojiRow.find('🤰🏼'), None); assertEquals(emojiRow.find('🤰🏼', 10, SearchDirection.Forward), None);
},
'.find backwards': () => {
const normalRow = Row.from('For whom the bell tolls');
assertEquivalent(
normalRow.find('who', 23, SearchDirection.Backward),
Some(4),
);
assertEquals(normalRow.find('foo', 10, SearchDirection.Backward), None);
const emojiRow = Row.from('😺😸😹');
assertEquivalent(emojiRow.find('😸', 2, SearchDirection.Backward), Some(1));
assertEquals(emojiRow.find('🤰🏼', 10, SearchDirection.Backward), None);
}, },
'.byteIndexToCharIndex': () => { '.byteIndexToCharIndex': () => {
// Each 'character' is two bytes // Each 'character' is two bytes
@ -545,12 +582,6 @@ const RowTest = {
}, },
}; };
// ----------------------------------------------------------------------------
const SearchTest = {
// @TODO implement Search tests
};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Test Suite Setup // Test Suite Setup
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -566,5 +597,4 @@ testSuite({
Option: OptionTest, Option: OptionTest,
Position: PositionTest, Position: PositionTest,
Row: RowTest, Row: RowTest,
Search: SearchTest,
}); });

View File

@ -1,14 +1,11 @@
import Row from './row.ts'; import Row from './row.ts';
import { arrayInsert, strlen } from './fns.ts'; import { arrayInsert, maxAdd, minSub } from './fns.ts';
import { HighlightType } from './highlight.ts';
import Option, { None, Some } from './option.ts'; import Option, { None, Some } from './option.ts';
import { getRuntime } from './runtime.ts'; import { getRuntime } from './runtime.ts';
import { Position } from './types.ts'; import { Position, SearchDirection } from './types.ts';
import { Search } from './search.ts';
export class Document { export class Document {
#rows: Row[]; #rows: Row[];
#search: Search;
/** /**
* Has the document been modified? * Has the document been modified?
@ -17,7 +14,6 @@ export class Document {
private constructor() { private constructor() {
this.#rows = []; this.#rows = [];
this.#search = new Search();
this.dirty = false; this.dirty = false;
} }
@ -26,10 +22,7 @@ export class Document {
} }
public static default(): Document { public static default(): Document {
const self = new Document(); return new Document();
self.#search.parent = Some(self);
return self;
} }
public isEmpty(): boolean { public isEmpty(): boolean {
@ -67,37 +60,46 @@ export class Document {
this.dirty = false; this.dirty = false;
} }
public resetFind(): void {
this.#search = new Search();
this.#search.parent = Some(this);
}
public find( public find(
q: string, q: string,
key: string, at: Position,
direction: SearchDirection = SearchDirection.Forward,
): Option<Position> { ): Option<Position> {
const possible = this.#search.search(q, key); if (at.y >= this.numRows) {
if (possible.isSome()) { return None;
const potential = possible.unwrap(); }
// Update highlight of search match const position = Position.from(at);
const row = this.#rows[potential.y];
// Okay, we have to take the Javascript string index (potential.x), convert const start = (direction === SearchDirection.Forward) ? at.y : 0;
// it to the Row 'character' index, and then convert that to the Row render index const end = (direction === SearchDirection.Forward)
// so that the highlighted color starts in the right place. ? this.numRows
const start = row.cxToRx(row.byteIndexToCharIndex(potential.x)); : maxAdd(at.y, 1, this.numRows);
// Just to be safe with unicode searches, take the number of 'characters' for (let y = start; y < end; y++) {
// as the search query length, not the JS string length. if (this.row(position.y).isNone()) {
const end = start + strlen(q); return None;
}
for (let i = start; i < end; i++) { const maybeMatch = this.#rows[y].find(q, position.x, direction);
row.hl[i] = HighlightType.Match; if (maybeMatch.isSome()) {
position.x = maybeMatch.unwrap();
return Some(position);
}
if (direction === SearchDirection.Forward) {
position.y = maxAdd(position.y, 1, this.numRows - 1);
position.x = 0;
} else {
position.y = minSub(position.y, 1, 0);
console.assert(position.y < this.numRows);
position.x = this.#rows[position.y].size - 1;
} }
} }
return possible; return None;
} }
public insert(at: Position, c: string): void { public insert(at: Position, c: string): void {
@ -180,6 +182,10 @@ export class Document {
} }
public row(i: number): Option<Row> { public row(i: number): Option<Row> {
if (i >= this.numRows) {
return None;
}
return Option.from(this.#rows[i]); return Option.from(this.#rows[i]);
} }

View File

@ -14,7 +14,7 @@ import {
} 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, log, LogLevel } from './runtime.ts';
import { ITerminalSize, Position } from './types.ts'; import { ITerminalSize, Position, SearchDirection } from './types.ts';
class Editor { class Editor {
/** /**
@ -234,14 +234,14 @@ class Editor {
public async prompt( public async prompt(
p: string, p: string,
callback?: (query: string, char: string) => void, callback?: (char: string, query: string) => void,
): Promise<Option<string>> { ): Promise<Option<string>> {
const { term } = await getRuntime(); const { term } = await getRuntime();
let res = ''; let res = '';
const maybeCallback = (query: string, char: string) => { const maybeCallback = (char: string, query: string) => {
if (callback !== undefined) { if (callback !== undefined) {
callback(query, char); callback(char, query);
} }
}; };
@ -253,6 +253,7 @@ class Editor {
} }
await this.refreshScreen(); await this.refreshScreen();
for await (const chunk of term.inputLoop()) { for await (const chunk of term.inputLoop()) {
const char = readKey(chunk); const char = readKey(chunk);
if (chunk.length === 0 || char.length === 0) { if (chunk.length === 0 || char.length === 0) {
@ -262,13 +263,13 @@ class Editor {
switch (char) { switch (char) {
// Remove the last character from the prompt input // Remove the last character from the prompt input
case KeyCommand.Backspace: case KeyCommand.Backspace:
case KeyCommand.Delete:
res = truncate(res, res.length - 1); res = truncate(res, res.length - 1);
maybeCallback(res, char); maybeCallback(res, char);
continue outer; continue outer;
// End the prompt // End the prompt
case KeyCommand.Escape: case KeyCommand.Escape:
res = '';
this.setStatusMessage(''); this.setStatusMessage('');
maybeCallback(res, char); maybeCallback(res, char);
@ -290,7 +291,7 @@ class Editor {
} }
} }
maybeCallback(res, char); maybeCallback(char, res);
} }
} }
} }
@ -301,37 +302,54 @@ class Editor {
*/ */
public async find(): Promise<void> { public async find(): Promise<void> {
const savedCursor = Position.from(this.#cursor); const savedCursor = Position.from(this.#cursor);
const savedOffset = Position.from(this.#offset); let direction = SearchDirection.Forward;
const query = await this.prompt( const result = await this.prompt(
'Search: %s (Use ESC/Arrows/Enter)', 'Search: %s (Use ESC/Arrows/Enter)',
(q: string, key: string) => { (key: string, query: string) => {
if (key === KeyCommand.Enter || key === KeyCommand.Escape) { let moved = false;
if (key === KeyCommand.Escape) {
this.#document.resetFind(); switch (key) {
} case KeyCommand.ArrowRight:
return null; case KeyCommand.ArrowDown:
direction = SearchDirection.Forward;
this.moveCursor(KeyCommand.ArrowRight);
moved = true;
break;
case KeyCommand.ArrowLeft:
case KeyCommand.ArrowUp:
direction = SearchDirection.Backward;
break;
default:
direction = SearchDirection.Forward;
} }
if (q.length > 0) { if (query.length > 0) {
const pos = this.#document.find(q, key); const pos = this.#document.find(query, this.#cursor, direction);
if (pos.isSome()) { if (pos.isSome()) {
// We have a match here // We have a match here
this.#cursor = pos.unwrap(); this.#cursor = Position.from(pos.unwrap());
this.scroll(); this.scroll();
} else { } else if (moved) {
this.setStatusMessage('Not found'); this.moveCursor(KeyCommand.ArrowLeft);
} }
this.#document.highlight(Some(query));
} }
}, },
); );
// Return to document position before search // Return to document position before search
// when you cancel the search (press the escape key) // when you cancel the search (press the escape key)
if (query === null) { if (result.isNone()) {
this.#cursor = Position.from(savedCursor); this.#cursor = Position.from(savedCursor);
this.#offset = Position.from(savedOffset); // this.#offset = Position.from(savedOffset);
this.scroll();
} }
this.#document.highlight(None);
} }
/** /**

View File

@ -4,6 +4,7 @@ import { SCROLL_TAB_SIZE } from './config.ts';
import { arrayInsert, isAsciiDigit, strChars, strlen } from './fns.ts'; import { arrayInsert, isAsciiDigit, strChars, strlen } from './fns.ts';
import { highlightToColor, HighlightType } from './highlight.ts'; import { highlightToColor, HighlightType } from './highlight.ts';
import Option, { None, Some } from './option.ts'; import Option, { None, Some } from './option.ts';
import { SearchDirection } from './types.ts';
/** /**
* One row of text in the current document. In order to handle * One row of text in the current document. In order to handle
@ -96,14 +97,20 @@ export class Row {
* Search the current row for the specified string, and return * Search the current row for the specified string, and return
* the 'character' index of the start of that match * the 'character' index of the start of that match
*/ */
public find(s: string, offset: number = 0): Option<number> { public find(
const thisStr = this.toString(); s: string,
if (!this.toString().includes(s)) { at: number = 0,
direction: SearchDirection = SearchDirection.Forward,
): Option<number> {
if (at > this.size) {
return None; return None;
} }
const thisStr = this.chars.join('');
// Look for the search query `s`, starting from the 'character' `offset` // Look for the search query `s`, starting from the 'character' `offset`
const byteIndex = thisStr.indexOf(s, this.charIndexToByteIndex(offset)); const byteIndex = (direction === SearchDirection.Forward)
? thisStr.indexOf(s, this.charIndexToByteIndex(at))
: thisStr.lastIndexOf(s, this.charIndexToByteIndex(at));
// No match after the specified offset // No match after the specified offset
if (byteIndex < 0) { if (byteIndex < 0) {
@ -191,7 +198,7 @@ export class Row {
// the JS string index, as a 'character' can consist // the JS string index, as a 'character' can consist
// of multiple JS string indicies // of multiple JS string indicies
return this.chars.slice(0, charIndex).reduce( return this.chars.slice(0, charIndex).reduce(
(prev, current) => prev += current.length, (prev, current) => prev + current.length,
0, 0,
); );
} }
@ -212,18 +219,50 @@ export class Row {
public highlight(word: Option<string>): void { public highlight(word: Option<string>): void {
const highlighting = []; const highlighting = [];
// let searchIndex = 0; let searchIndex = 0;
const matches = [];
// Find matches for the current search
if (word.isSome()) { if (word.isSome()) {
// const searchMatch = this.find(word.unwrap(), searchIndex); while (true) {
const match = this.find(word.unwrap(), searchIndex);
if (match.isNone()) {
break;
} }
for (const ch of this.rchars) { matches.push(match.unwrap());
const nextPossible = match.unwrap() + strlen(word.unwrap());
if (nextPossible < this.rsize) {
searchIndex = nextPossible;
} else {
break;
}
}
}
let i = 0;
for (; i < this.rsize;) {
// Highlight search matches
if (word.isSome()) {
if (matches.includes(i)) {
for (const _ in strChars(word.unwrap())) {
i += 1;
highlighting.push(HighlightType.Match);
}
continue;
}
}
// Highlight other syntax types
const ch = this.rchars[i];
if (isAsciiDigit(ch)) { if (isAsciiDigit(ch)) {
highlighting.push(HighlightType.Number); highlighting.push(HighlightType.Number);
} else { } else {
highlighting.push(HighlightType.None); highlighting.push(HighlightType.None);
} }
i += 1;
} }
this.hl = highlighting; this.hl = highlighting;

View File

@ -2,9 +2,14 @@
* Functions/Methods that depend on the current runtime to function * Functions/Methods that depend on the current runtime to function
*/ */
import process from 'node:process'; import process from 'node:process';
import { IRuntime, ITestBase } from './types.ts'; import Ansi from './ansi.ts';
import { IRuntime, ITerminalSize, ITestBase } from './types.ts';
import { noop } from './fns.ts'; import { noop } from './fns.ts';
import { SCROLL_ERR_FILE, SCROLL_LOG_FILE } from './config.ts'; import {
defaultTerminalSize,
SCROLL_ERR_FILE,
SCROLL_LOG_FILE,
} from './config.ts';
export type { IFileIO, IRuntime, ITerminal } from './types.ts'; export type { IFileIO, IRuntime, ITerminal } from './types.ts';

View File

@ -1,80 +0,0 @@
import Document from './document.ts';
import { KeyCommand } from './ansi.ts';
import Option, { None } from './option.ts';
import { Position } from './types.ts';
enum SearchDirection {
Forward = 1,
Backward = -1,
}
export class Search {
private lastMatch: number = -1;
private current: number = -1;
private direction: SearchDirection = SearchDirection.Forward;
public parent: Option<Document> = None;
private parseInput(key: string) {
switch (key) {
case KeyCommand.ArrowRight:
case KeyCommand.ArrowDown:
this.direction = SearchDirection.Forward;
break;
case KeyCommand.ArrowLeft:
case KeyCommand.ArrowUp:
this.direction = SearchDirection.Backward;
break;
default:
this.lastMatch = -1;
this.direction = SearchDirection.Forward;
}
if (this.lastMatch === -1) {
this.direction = SearchDirection.Forward;
}
this.current = this.lastMatch;
}
private getNextRow(rowCount: number): number {
this.current += this.direction;
if (this.current === -1) {
this.current = rowCount - 1;
} else if (this.current === rowCount) {
this.current = 0;
}
return this.current;
}
public search(q: string, key: string): Option<Position> {
if (this.parent.isNone()) {
return None;
}
const parent = this.parent.unwrap();
this.parseInput(key);
let i = 0;
for (; i < parent.numRows; i++) {
const current = this.getNextRow(parent.numRows);
const row = parent.row(current);
if (row.isNone()) {
continue;
}
const possible = row.unwrap().find(q);
if (possible.isSome()) {
this.lastMatch = current;
return possible.map((p: number) => Position.at(p, current));
}
}
return None;
}
}