diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index 4651f6c7f4..3dd2992dac 100644 --- a/addons/xterm-addon-search/src/SearchAddon.ts +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -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 { @@ -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 = new Map(); + private _searchResults: Map = 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. @@ -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 @@ -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; } @@ -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) { @@ -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. @@ -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()) { @@ -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); } /** @@ -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; @@ -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; + } } diff --git a/addons/xterm-addon-search/typings/xterm-addon-search.d.ts b/addons/xterm-addon-search/typings/xterm-addon-search.d.ts index f27aba7834..67ed2985da 100644 --- a/addons/xterm-addon-search/typings/xterm-addon-search.d.ts +++ b/addons/xterm-addon-search/typings/xterm-addon-search.d.ts @@ -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; } /** @@ -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; } } diff --git a/demo/client.ts b/demo/client.ts index 2b62237221..830a44a042 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -92,6 +92,7 @@ const addons: { [T in AddonType]: IDemoAddon} = { const terminalContainer = document.getElementById('terminal-container'); const actionElements = { + find: document.querySelector('#find'), findNext: document.querySelector('#find-next'), findPrevious: document.querySelector('#find-previous') }; @@ -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 }; } diff --git a/demo/index.html b/demo/index.html index d2b8d481c6..b89726de6f 100644 --- a/demo/index.html +++ b/demo/index.html @@ -43,6 +43,7 @@

SearchAddon

+

SerializeAddon

diff --git a/src/browser/Decorations/BufferDecorationRenderer.ts b/src/browser/Decorations/BufferDecorationRenderer.ts index 116c09a58a..22dc73e9b0 100644 --- a/src/browser/Decorations/BufferDecorationRenderer.ts +++ b/src/browser/Decorations/BufferDecorationRenderer.ts @@ -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 { diff --git a/src/browser/Decorations/OverviewRulerRenderer.ts b/src/browser/Decorations/OverviewRulerRenderer.ts index aa09db568e..9cdf99caf5 100644 --- a/src/browser/Decorations/OverviewRulerRenderer.ts +++ b/src/browser/Decorations/OverviewRulerRenderer.ts @@ -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); } } diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 6c7502361c..fba5fc356b 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -32,7 +32,10 @@ export class DecorationService extends Disposable implements IDecorationService if (decoration) { decoration.onDispose(() => { if (decoration) { - this._decorations.splice(this._decorations.indexOf(decoration), 1); + const index = this._decorations.indexOf(decoration); + if (index >= 0) { + this._decorations.splice(this._decorations.indexOf(decoration), 1); + } } }); this._decorations.push(decoration); @@ -70,6 +73,10 @@ class Decoration extends Disposable implements IInternalDecoration { } } public override dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; this._onDispose.fire(); super.dispose(); }