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
Some checks failed
timw4mail/scroll/pipeline/head There was a failure building this commit
This commit is contained in:
parent
e0e7849fe4
commit
b3bddbb601
@ -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,
|
||||
});
|
||||
|
@ -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]);
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user