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 { defaultTerminalSize, SCROLL_TAB_SIZE } from './config.ts';
import { getTestRunner } from './runtime.ts';
import { SearchDirection } from './types.ts';
const {
assertEquals,
@ -302,6 +303,27 @@ const DocumentTest = {
await doc.save('test.file');
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': () => {
const doc = Document.default();
doc.insertRow(undefined, 'foobar');
@ -500,12 +522,27 @@ const RowTest = {
},
'.find': () => {
const normalRow = Row.from('For whom the bell tolls');
assertEquivalent(normalRow.find('who'), Some(4));
assertEquals(normalRow.find('foo'), None);
assertEquivalent(
normalRow.find('who', 0, SearchDirection.Forward),
Some(4),
);
assertEquals(normalRow.find('foo', 0, SearchDirection.Forward), None);
const emojiRow = Row.from('😺😸😹');
assertEquivalent(emojiRow.find('😹'), Some(2));
assertEquals(emojiRow.find('🤰🏼'), None);
assertEquivalent(emojiRow.find('😹', 0, SearchDirection.Forward), Some(2));
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': () => {
// Each 'character' is two bytes
@ -545,12 +582,6 @@ const RowTest = {
},
};
// ----------------------------------------------------------------------------
const SearchTest = {
// @TODO implement Search tests
};
// ----------------------------------------------------------------------------
// Test Suite Setup
// ----------------------------------------------------------------------------
@ -566,5 +597,4 @@ testSuite({
Option: OptionTest,
Position: PositionTest,
Row: RowTest,
Search: SearchTest,
});

View File

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

View File

@ -14,7 +14,7 @@ import {
} from './fns.ts';
import Option, { None, Some } from './option.ts';
import { getRuntime, log, LogLevel } from './runtime.ts';
import { ITerminalSize, Position } from './types.ts';
import { ITerminalSize, Position, SearchDirection } from './types.ts';
class Editor {
/**
@ -234,14 +234,14 @@ class Editor {
public async prompt(
p: string,
callback?: (query: string, char: string) => void,
callback?: (char: string, query: string) => void,
): Promise<Option<string>> {
const { term } = await getRuntime();
let res = '';
const maybeCallback = (query: string, char: string) => {
const maybeCallback = (char: string, query: string) => {
if (callback !== undefined) {
callback(query, char);
callback(char, query);
}
};
@ -253,6 +253,7 @@ class Editor {
}
await this.refreshScreen();
for await (const chunk of term.inputLoop()) {
const char = readKey(chunk);
if (chunk.length === 0 || char.length === 0) {
@ -262,13 +263,13 @@ class Editor {
switch (char) {
// Remove the last character from the prompt input
case KeyCommand.Backspace:
case KeyCommand.Delete:
res = truncate(res, res.length - 1);
maybeCallback(res, char);
continue outer;
// End the prompt
case KeyCommand.Escape:
res = '';
this.setStatusMessage('');
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> {
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)',
(q: string, key: string) => {
if (key === KeyCommand.Enter || key === KeyCommand.Escape) {
if (key === KeyCommand.Escape) {
this.#document.resetFind();
}
return null;
(key: string, query: string) => {
let moved = false;
switch (key) {
case KeyCommand.ArrowRight:
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) {
const pos = this.#document.find(q, key);
if (query.length > 0) {
const pos = this.#document.find(query, this.#cursor, direction);
if (pos.isSome()) {
// We have a match here
this.#cursor = pos.unwrap();
this.#cursor = Position.from(pos.unwrap());
this.scroll();
} else {
this.setStatusMessage('Not found');
} else if (moved) {
this.moveCursor(KeyCommand.ArrowLeft);
}
this.#document.highlight(Some(query));
}
},
);
// Return to document position before search
// when you cancel the search (press the escape key)
if (query === null) {
if (result.isNone()) {
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 { highlightToColor, HighlightType } from './highlight.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
@ -96,14 +97,20 @@ export class Row {
* Search the current row for the specified string, and return
* the 'character' index of the start of that match
*/
public find(s: string, offset: number = 0): Option<number> {
const thisStr = this.toString();
if (!this.toString().includes(s)) {
public find(
s: string,
at: number = 0,
direction: SearchDirection = SearchDirection.Forward,
): Option<number> {
if (at > this.size) {
return None;
}
const thisStr = this.chars.join('');
// 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
if (byteIndex < 0) {
@ -191,7 +198,7 @@ export class Row {
// the JS string index, as a 'character' can consist
// of multiple JS string indicies
return this.chars.slice(0, charIndex).reduce(
(prev, current) => prev += current.length,
(prev, current) => prev + current.length,
0,
);
}
@ -212,18 +219,50 @@ export class Row {
public highlight(word: Option<string>): void {
const highlighting = [];
// let searchIndex = 0;
let searchIndex = 0;
const matches = [];
// Find matches for the current search
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)) {
highlighting.push(HighlightType.Number);
} else {
highlighting.push(HighlightType.None);
}
i += 1;
}
this.hl = highlighting;

View File

@ -2,9 +2,14 @@
* Functions/Methods that depend on the current runtime to function
*/
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 { 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';

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