Skip to content

Commit

Permalink
Merge pull request #3698 from meganrogge/findAll
Browse files Browse the repository at this point in the history
Show all matches when searching
  • Loading branch information
Tyriar authored Mar 22, 2022
2 parents 31c11c7 + 43a014e commit dddde8e
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 19 deletions.
217 changes: 203 additions & 14 deletions addons/xterm-addon-search/src/SearchAddon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
* @license MIT
*/

import { Terminal, IBufferLine, IDisposable, ITerminalAddon, ISelectionPosition } from 'xterm';
import { Terminal, IDisposable, ITerminalAddon, ISelectionPosition, IDecoration } from 'xterm';

export interface ISearchOptions {
regex?: boolean;
wholeWord?: boolean;
caseSensitive?: boolean;
incremental?: boolean;
decorations?: ISearchDecorationOptions;
}

interface ISearchDecorationOptions {
matchColor: string;
selectedColor: string;
}

export interface ISearchPosition {
Expand Down Expand Up @@ -40,7 +46,14 @@ const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs

export class SearchAddon implements ITerminalAddon {
private _terminal: Terminal | undefined;

private _dataChanged: boolean = false;
private _cachedSearchTerm: string | undefined;
private _selectedDecoration: IDecoration | undefined;
private _resultDecorations: Map<number, IDecoration[]> = new Map<number, IDecoration[]>();
private _searchResults: Map<string, ISearchResult> = new Map();
private _onDataDisposable: IDisposable | undefined;
private _lastSearchOptions: ISearchOptions | undefined;
private _highlightTimeout: number | undefined;
/**
* translateBufferLineToStringWithWrap is a fairly expensive call.
* We memoize the calls into an array that has a time based ttl.
Expand All @@ -53,9 +66,41 @@ export class SearchAddon implements ITerminalAddon {

public activate(terminal: Terminal): void {
this._terminal = terminal;
this._onDataDisposable = this._terminal.onData(() => {
this._dataChanged = true;
if (this._highlightTimeout) {
window.clearTimeout(this._highlightTimeout);
}
this._highlightTimeout = setTimeout(() => {
if (this._lastSearchOptions?.decorations && this._cachedSearchTerm && this._resultDecorations.size > 0 && this._lastSearchOptions) {
this._highlightAllMatches(this._cachedSearchTerm, this._lastSearchOptions);
}
}, 200);
});
}

public dispose(): void { }
public dispose(): void {
this.clearDecorations();
this._onDataDisposable?.dispose();
}

public clearDecorations(): void {
this._selectedDecoration?.dispose();
this._terminal?.clearSelection();
this._searchResults.clear();
this._disposeDecorations();
this._cachedSearchTerm = undefined;
this._dataChanged = true;
}

private _disposeDecorations(): void {
this._resultDecorations.forEach(decorations => {
for (const d of decorations) {
d.dispose();
}
});
this._resultDecorations.clear();
}

/**
* Find the next instance of the term, then scroll to and select it. If it
Expand All @@ -68,9 +113,90 @@ export class SearchAddon implements ITerminalAddon {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
this._lastSearchOptions = searchOptions;
const findNextResult = this._findNextAndSelect(term, searchOptions);
if (searchOptions?.decorations) {
this._highlightAllMatches(term, searchOptions);
}
return findNextResult;
}

private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
if (!term || term.length === 0) {
this._terminal.clearSelection();
this.clearDecorations();
return;
}
searchOptions = searchOptions || {};
if (term === this._cachedSearchTerm && !this._dataChanged) {
return;
}
// new search, clear out the old decorations
this._disposeDecorations();
this._searchResults.clear();
let result = this._find(term, 0, 0, searchOptions);
while (result && !this._searchResults.get(`${result.row}-${result.col}`)) {
this._searchResults.set(`${result.row}-${result.col}`, result);
result = this._find(term, result.row, result.col + 1, searchOptions);
}
this._searchResults.forEach(result => {
const resultDecoration = this._createResultDecoration(result, searchOptions.decorations!);
if (resultDecoration) {
const decorationsForLine = this._resultDecorations.get(resultDecoration.marker.line) || [];
decorationsForLine.push(resultDecoration);
this._resultDecorations.set(resultDecoration.marker.line, decorationsForLine);
}
});
if (this._dataChanged) {
this._dataChanged = false;
}
if (this._searchResults.size > 0) {
this._cachedSearchTerm = term;
}
}

private _find(term: string, startRow?: number, startCol?: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
this.clearDecorations();
return undefined;
}
let result: ISearchResult | undefined = undefined;
startCol = startCol || 0;
startRow = startRow ?? 0;

this._initLinesCache();

const searchPosition: ISearchPosition = {
startRow,
startCol
};

// Search startRow
result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {

for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
// If the current line is wrapped line, increase index of column to ignore the previous scan
// Otherwise, reset beginning column index to zero with set new unwrapped line index
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
return result;
}

private _findNextAndSelect(term: string, searchOptions?: ISearchOptions): boolean {
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
this.clearDecorations();
return false;
}

Expand All @@ -95,7 +221,6 @@ export class SearchAddon implements ITerminalAddon {

// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);

// Search from startRow + 1 to end
if (!result) {

Expand Down Expand Up @@ -130,9 +255,8 @@ export class SearchAddon implements ITerminalAddon {
}

// Set selection and scroll if a result was found
return this._selectResult(result);
return this._selectResult(result, searchOptions?.decorations);
}

/**
* Find the previous instance of the term, then scroll to and select it. If it
* doesn't exist, do nothing.
Expand All @@ -144,16 +268,30 @@ export class SearchAddon implements ITerminalAddon {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
this._lastSearchOptions = searchOptions;
const findPreviousResult = this._findAndSelectPrevious(term, searchOptions);
if (searchOptions?.decorations) {
this._highlightAllMatches(term, searchOptions);
}
return findPreviousResult;
}

if (!term || term.length === 0) {
this._terminal.clearSelection();
private _findAndSelectPrevious(term: string, searchOptions?: ISearchOptions): boolean {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
let result: ISearchResult | undefined;
if (!this._terminal || !term || term.length === 0) {
result = undefined;
this._terminal?.clearSelection();
this.clearDecorations();
return false;
}

const isReverseSearch = true;
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows;
let startCol = this._terminal.cols;
let result: ISearchResult | undefined;
const isReverseSearch = true;

const incremental = searchOptions ? searchOptions.incremental : false;
let currentSelection: ISelectionPosition | undefined;
if (this._terminal.hasSelection()) {
Expand Down Expand Up @@ -211,7 +349,7 @@ export class SearchAddon implements ITerminalAddon {
if (!result && currentSelection) return true;

// Set selection and scroll if a result was found
return this._selectResult(result);
return this._selectResult(result, searchOptions?.decorations);
}

/**
Expand Down Expand Up @@ -446,15 +584,25 @@ export class SearchAddon implements ITerminalAddon {
/**
* Selects and scrolls to a result.
* @param result The result to select.
* @return Whethera result was selected.
* @return Whether a result was selected.
*/
private _selectResult(result: ISearchResult | undefined): boolean {
private _selectResult(result: ISearchResult | undefined, decorations?: ISearchDecorationOptions): boolean {
const terminal = this._terminal!;
this._selectedDecoration?.dispose();
if (!result) {
terminal.clearSelection();
return false;
}
terminal.select(result.col, result.row, result.size);
if (decorations?.selectedColor) {
const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row);
if (marker) {
this._selectedDecoration = terminal.registerDecoration({ marker, overviewRulerOptions: { color: decorations.selectedColor } });
this._selectedDecoration?.onRender((e) => this._applyStyles(e, decorations.selectedColor, result));
this._selectedDecoration?.onDispose(() => marker.dispose());
}
}

// If it is not in the viewport then we scroll else it just gets selected
if (result.row >= (terminal.buffer.active.viewportY + terminal.rows) || result.row < terminal.buffer.active.viewportY) {
let scroll = result.row - terminal.buffer.active.viewportY;
Expand All @@ -463,4 +611,45 @@ export class SearchAddon implements ITerminalAddon {
}
return true;
}

/**
* Applies styles to the decoration when it is rendered
* @param element the decoration's element
* @param color the color to apply
* @param result the search result associated with the decoration
* @returns
*/
private _applyStyles(element: HTMLElement, color: string, result: ISearchResult): void {
if (element.clientWidth <= 0) {
return;
}
if (!element.classList.contains('xterm-find-result-decoration')) {
element.classList.add('xterm-find-result-decoration');
element.style.left = `${element.clientWidth * result.col}px`;
element.style.width = `${element.clientWidth * result.term.length}px`;
element.style.backgroundColor = color;
element.style.opacity = '0.6';
}
}

/**
* Creates a decoration for the result and applies styles
* @param result the search result for which to create the decoration
* @param color the color to use for the decoration
* @returns the {@link IDecoration} or undefined if the marker has already been disposed of
*/
private _createResultDecoration(result: ISearchResult, decorations: ISearchDecorationOptions): IDecoration | undefined {
const terminal = this._terminal!;
const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row);
if (!marker || !decorations?.matchColor) {
return undefined;
}
const findResultDecoration = terminal.registerDecoration(
{ marker,
overviewRulerOptions: this._resultDecorations.get(marker.line) && !this._dataChanged ? undefined : { color: decorations.matchColor, position: 'center' }
});
findResultDecoration?.onRender((e) => this._applyStyles(e, decorations.matchColor, result));
findResultDecoration?.onDispose(() => marker.dispose());
return findResultDecoration;
}
}
26 changes: 26 additions & 0 deletions addons/xterm-addon-search/typings/xterm-addon-search.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ declare module 'xterm-addon-search' {
* `findNext`, not `findPrevious`.
*/
incremental?: boolean;

/**
* When set, will highlight all instances of the word on search and show
* them in the overview ruler if it's enabled.
*/
decorations?: ISearchDecorationOptions;
}

/**
* Options for showing decorations when searching.
*/
interface ISearchDecorationOptions {
/**
* The color of a match.
*/
matchColor: string;

/**
* The color for the currently selected match.
*/
selectedColor: string;
}

/**
Expand Down Expand Up @@ -64,5 +85,10 @@ declare module 'xterm-addon-search' {
* @param searchOptions The options for the search.
*/
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean;

/**
* Clears the decorations and selection
*/
public clearDecorations(): void;
}
}
4 changes: 3 additions & 1 deletion demo/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const addons: { [T in AddonType]: IDemoAddon<T>} = {

const terminalContainer = document.getElementById('terminal-container');
const actionElements = {
find: <HTMLInputElement>document.querySelector('#find'),
findNext: <HTMLInputElement>document.querySelector('#find-next'),
findPrevious: <HTMLInputElement>document.querySelector('#find-previous')
};
Expand All @@ -107,7 +108,8 @@ function getSearchOptions(e: KeyboardEvent): ISearchOptions {
regex: (document.getElementById('regex') as HTMLInputElement).checked,
wholeWord: (document.getElementById('whole-word') as HTMLInputElement).checked,
caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked,
incremental: e.key !== `Enter`
incremental: e.key !== `Enter`,
decorations: (document.getElementById('highlight-all-matches') as HTMLInputElement).checked ? { matchColor: '#555753', selectedColor: '#ef2929' } : undefined
};
}

Expand Down
1 change: 1 addition & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ <h4>SearchAddon</h4>
<label><input type="checkbox" id="regex"/>Use regex</label>
<label><input type="checkbox" id="case-sensitive"/>Case sensitive</label>
<label><input type="checkbox" id="whole-word"/>Whole word</label>
<label><input type="checkbox" id="highlight-all-matches"/>Highlight All Matches</label>
</div>
<h4>SerializeAddon</h4>
<div>
Expand Down
2 changes: 1 addition & 1 deletion src/browser/Decorations/BufferDecorationRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export class BufferDecorationRenderer extends Disposable {

private _refreshStyle(decoration: IInternalDecoration, element: HTMLElement): void {
const line = decoration.marker.line - this._bufferService.buffers.active.ydisp;
if (line < 0 || line > this._bufferService.rows) {
if (line < 0 || line >= this._bufferService.rows) {
// outside of viewport
element.style.display = 'none';
} else {
Expand Down
4 changes: 2 additions & 2 deletions src/browser/Decorations/OverviewRulerRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,12 @@ export class OverviewRulerRenderer extends Disposable {
}
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
for (const decoration of this._decorationService.decorations) {
if (decoration.options.overviewRulerOptions!.position !== 'full') {
if (decoration.options.overviewRulerOptions && decoration.options.overviewRulerOptions.position !== 'full') {
this._renderDecoration(decoration, updateAnchor);
}
}
for (const decoration of this._decorationService.decorations) {
if (decoration.options.overviewRulerOptions!.position === 'full') {
if (decoration.options.overviewRulerOptions && decoration.options.overviewRulerOptions.position === 'full') {
this._renderDecoration(decoration, updateAnchor);
}
}
Expand Down
Loading

0 comments on commit dddde8e

Please sign in to comment.