From b3bddbb601f6f22b759b57b5518969f669ae38f7 Mon Sep 17 00:00:00 2001 From: "Timothy J. Warren" Date: Tue, 9 Jul 2024 17:02:16 -0400 Subject: [PATCH] Refactor search to work like in hecto, albeit with some bugs with backwards searching --- src/common/all_test.ts | 52 +++++++++++++++++++++------ src/common/document.ts | 68 +++++++++++++++++++---------------- src/common/editor.ts | 60 ++++++++++++++++++++----------- src/common/row.ts | 55 ++++++++++++++++++++++++----- src/common/runtime.ts | 9 +++-- src/common/search.ts | 80 ------------------------------------------ 6 files changed, 171 insertions(+), 153 deletions(-) delete mode 100644 src/common/search.ts diff --git a/src/common/all_test.ts b/src/common/all_test.ts index cfdda8d..019331d 100644 --- a/src/common/all_test.ts +++ b/src/common/all_test.ts @@ -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, }); diff --git a/src/common/document.ts b/src/common/document.ts index 5da15c7..9803f62 100644 --- a/src/common/document.ts +++ b/src/common/document.ts @@ -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 { - 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 { + if (i >= this.numRows) { + return None; + } + return Option.from(this.#rows[i]); } diff --git a/src/common/editor.ts b/src/common/editor.ts index c825c06..e484f55 100644 --- a/src/common/editor.ts +++ b/src/common/editor.ts @@ -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> { 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 { 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); } /** diff --git a/src/common/row.ts b/src/common/row.ts index ddd95f7..811616e 100644 --- a/src/common/row.ts +++ b/src/common/row.ts @@ -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 { - const thisStr = this.toString(); - if (!this.toString().includes(s)) { + public find( + s: string, + at: number = 0, + direction: SearchDirection = SearchDirection.Forward, + ): Option { + 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): 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; + } + + matches.push(match.unwrap()); + const nextPossible = match.unwrap() + strlen(word.unwrap()); + if (nextPossible < this.rsize) { + searchIndex = nextPossible; + } else { + break; + } + } } - for (const ch of this.rchars) { + 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; diff --git a/src/common/runtime.ts b/src/common/runtime.ts index ce2cbf6..24b46e6 100644 --- a/src/common/runtime.ts +++ b/src/common/runtime.ts @@ -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'; diff --git a/src/common/search.ts b/src/common/search.ts deleted file mode 100644 index ee120b1..0000000 --- a/src/common/search.ts +++ /dev/null @@ -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 = 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 { - 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; - } -}