diff --git a/addons/xterm-addon-ligatures/package.json b/addons/xterm-addon-ligatures/package.json index f3c3aeff..7e5e5e90 100644 --- a/addons/xterm-addon-ligatures/package.json +++ b/addons/xterm-addon-ligatures/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-ligatures", - "version": "0.5.2", + "version": "0.5.3", "description": "Add support for programming ligatures to xterm.js", "author": { "name": "The xterm.js authors", diff --git a/addons/xterm-addon-ligatures/yarn.lock b/addons/xterm-addon-ligatures/yarn.lock index 64117742..6ba3eccb 100644 --- a/addons/xterm-addon-ligatures/yarn.lock +++ b/addons/xterm-addon-ligatures/yarn.lock @@ -141,9 +141,9 @@ lru-cache@^6.0.0: yallist "^4.0.0" minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp@0.5.5: version "0.5.5" diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index 4651f6c7..d2ee05b4 100644 --- a/addons/xterm-addon-search/src/SearchAddon.ts +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -3,13 +3,24 @@ * @license MIT */ -import { Terminal, IBufferLine, IDisposable, ITerminalAddon, ISelectionPosition } from 'xterm'; +import { Terminal, IDisposable, ITerminalAddon, ISelectionPosition, IDecoration } from 'xterm'; +import { EventEmitter } from 'common/EventEmitter'; export interface ISearchOptions { regex?: boolean; wholeWord?: boolean; caseSensitive?: boolean; incremental?: boolean; + decorations?: ISearchDecorationOptions; +} + +interface ISearchDecorationOptions { + matchBackground?: string; + matchBorder?: string; + matchOverviewRuler: string; + activeMatchBackground?: string; + activeMatchBorder?: string; + activeMatchColorOverviewRuler: string; } export interface ISearchPosition { @@ -40,7 +51,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 | undefined; + private _searchResults: Map | undefined; + 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. @@ -51,11 +69,46 @@ export class SearchAddon implements ITerminalAddon { private _cursorMoveListener: IDisposable | undefined; private _resizeListener: IDisposable | undefined; + private _resultIndex: number | undefined; + + private readonly _onDidChangeResults = new EventEmitter<{resultIndex: number, resultCount: number} | undefined>(); + public readonly onDidChangeResults = this._onDidChangeResults.event; + public activate(terminal: Terminal): void { this._terminal = terminal; + this._onDataDisposable = this._terminal.onData(() => { + this._dataChanged = true; + if (this._highlightTimeout) { + window.clearTimeout(this._highlightTimeout); + } + if (this._cachedSearchTerm && this._lastSearchOptions?.decorations) { + this._highlightTimeout = setTimeout(() => { + this.findPrevious(this._cachedSearchTerm!, { ...this._lastSearchOptions, incremental: true }); + }, 200); + } + }); } - public dispose(): void { } + public dispose(): void { + this.clearDecorations(); + this._onDataDisposable?.dispose(); + } + + public clearDecorations(): void { + this._selectedDecoration?.dispose(); + this._searchResults?.clear(); + this._resultDecorations?.forEach(decorations => { + for (const d of decorations) { + d.dispose(); + } + }); + this._resultDecorations?.clear(); + this._cachedSearchTerm = undefined; + this._searchResults = undefined; + this._resultDecorations = undefined; + this._dataChanged = true; + this._resultIndex = undefined; + } /** * Find the next instance of the term, then scroll to and select it. If it @@ -68,12 +121,111 @@ export class SearchAddon implements ITerminalAddon { if (!this._terminal) { throw new Error('Cannot use addon until it has been loaded'); } + this._lastSearchOptions = searchOptions; + if (searchOptions?.decorations) { + this._highlightAllMatches(term, searchOptions); + } + const next = this._findNextAndSelect(term, searchOptions); + if (searchOptions?.decorations) { + if (next && this._resultIndex !== undefined && this._searchResults?.size) { + this._onDidChangeResults.fire({ resultIndex: this._resultIndex, resultCount: this._searchResults.size }); + } else { + this._onDidChangeResults.fire(undefined); + } + } + return next; + } + 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.clearDecorations(); + this._searchResults = new Map(); + this._resultDecorations = new Map(); + const resultDecorations = this._resultDecorations; + 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.col + result.term.length >= this._terminal.cols ? result.row + 1 : result.row, + result.col + result.term.length >= this._terminal.cols ? 0 : result.col + 1, + searchOptions + ); + } + this._searchResults.forEach(result => { + const resultDecoration = this._createResultDecoration(result, searchOptions.decorations!); + if (resultDecoration) { + const decorationsForLine = resultDecorations.get(resultDecoration.marker.line) || []; + decorationsForLine.push(resultDecoration); + 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; + } + if (startCol > this._terminal.cols) { + throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`); + } + + let result: ISearchResult | undefined = undefined; + + 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; } + let startCol = 0; let startRow = 0; let currentSelection: ISelectionPosition | undefined; @@ -95,7 +247,6 @@ export class SearchAddon implements ITerminalAddon { // Search startRow let result = this._findInLine(term, searchPosition, searchOptions); - // Search from startRow + 1 to end if (!result) { @@ -129,10 +280,20 @@ export class SearchAddon implements ITerminalAddon { result = this._findInLine(term, searchPosition, searchOptions); } - // Set selection and scroll if a result was found - return this._selectResult(result); - } + if (this._searchResults) { + if (this._resultIndex === undefined) { + this._resultIndex = 0; + } else { + this._resultIndex++; + if (this._resultIndex >= this._searchResults.size) { + this._resultIndex = 0; + } + } + } + // Set selection and scroll if a result was found + 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 +305,37 @@ export class SearchAddon implements ITerminalAddon { if (!this._terminal) { throw new Error('Cannot use addon until it has been loaded'); } + this._lastSearchOptions = searchOptions; + if (searchOptions?.decorations) { + this._highlightAllMatches(term, searchOptions); + } + const previous = this._findPreviousAndSelect(term, searchOptions); + if (searchOptions?.decorations) { + if (previous && this._resultIndex !== undefined && this._searchResults?.size) { + this._onDidChangeResults.fire({ resultIndex: this._resultIndex, resultCount: this._searchResults.size }); + } else { + this._onDidChangeResults.fire(undefined); + } + } + return previous; + } - if (!term || term.length === 0) { - this._terminal.clearSelection(); + private _findPreviousAndSelect(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()) { @@ -207,11 +389,22 @@ export class SearchAddon implements ITerminalAddon { } } + if (this._searchResults) { + if (this._resultIndex === undefined) { + this._resultIndex = this._searchResults?.size - 1; + } else { + this._resultIndex--; + if (this._resultIndex === -1) { + this._resultIndex = this._searchResults?.size - 1; + } + } + } + // If there is only one result, return true. 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 +639,32 @@ 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?.activeMatchColorOverviewRuler) { + const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row); + if (marker) { + this._selectedDecoration = terminal.registerDecoration({ + marker, + x: result.col, + width: result.size, + overviewRulerOptions: { + color: decorations.activeMatchColorOverviewRuler + } + }); + this._selectedDecoration?.onRender((e) => this._applyStyles(e, decorations.activeMatchBackground, decorations.activeMatchBorder, 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 +673,52 @@ export class SearchAddon implements ITerminalAddon { } return true; } + + /** + * Applies styles to the decoration when it is rendered + * @param element the decoration's element + * @param backgroundColor the background color to apply + * @param borderColor the border color to apply + * @param result the search result associated with the decoration + * @returns + */ + private _applyStyles(element: HTMLElement, backgroundColor: string | undefined, borderColor: string | undefined, result: ISearchResult): void { + if (element.clientWidth <= 0) { + return; + } + if (!element.classList.contains('xterm-find-result-decoration')) { + element.classList.add('xterm-find-result-decoration'); + if (backgroundColor) { + element.style.backgroundColor = backgroundColor; + } + if (borderColor) { + element.style.outline = `1px solid ${borderColor}`; + } + } + } + + /** + * 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?.matchOverviewRuler) { + return undefined; + } + const findResultDecoration = terminal.registerDecoration({ + marker, + x: result.col, + width: result.size, + overviewRulerOptions: this._resultDecorations?.get(marker.line) && !this._dataChanged ? undefined : { + color: decorations.matchOverviewRuler, position: 'center' + } + }); + findResultDecoration?.onRender((e) => this._applyStyles(e, decorations.matchBackground, decorations.matchBorder, result)); + findResultDecoration?.onDispose(() => marker.dispose()); + return findResultDecoration; + } } diff --git a/addons/xterm-addon-search/src/tsconfig.json b/addons/xterm-addon-search/src/tsconfig.json index d264abe0..5a5e671f 100644 --- a/addons/xterm-addon-search/src/tsconfig.json +++ b/addons/xterm-addon-search/src/tsconfig.json @@ -13,10 +13,20 @@ "strict": true, "types": [ "../../../node_modules/@types/mocha" - ] + ], + "paths": { + "common/*": [ + "../../../src/common/*" + ] + } }, "include": [ "./**/*", "../../../typings/xterm.d.ts" + ], + "references": [ + { + "path": "../../../src/common" + } ] } diff --git a/addons/xterm-addon-search/test/SearchAddon.api.ts b/addons/xterm-addon-search/test/SearchAddon.api.ts index 6d75f18f..7cb15c5d 100644 --- a/addons/xterm-addon-search/test/SearchAddon.api.ts +++ b/addons/xterm-addon-search/test/SearchAddon.api.ts @@ -134,7 +134,7 @@ describe('Search Tests', function(): void { .replace(/\n/g, '\\n\\r'); } fixture = fixture - .replace(/'/g, '\\\''); + .replace(/'/g, `\\'`); }); it('should find all occurrences using findNext', async () => { await writeSync(page, fixture); 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 f27aba78..1cb9740e 100644 --- a/addons/xterm-addon-search/typings/xterm-addon-search.d.ts +++ b/addons/xterm-addon-search/typings/xterm-addon-search.d.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { Terminal, ILinkMatcherOptions, IDisposable, ITerminalAddon } from 'xterm'; +import { Terminal, ITerminalAddon, IEvent } from 'xterm'; declare module 'xterm-addon-search' { /** @@ -32,6 +32,47 @@ 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 background color of a match. + */ + matchBackground?: string; + + /** + * The border color of a match + */ + matchBorder?: string; + + /** + * The overview ruler color of a match. + */ + matchOverviewRuler: string; + + /** + * The background color for the currently active match. + */ + activeMatchBackground?: string; + + /** + * The border color of the currently active match. + */ + activeMatchBorder?: string; + + /** + * The overview ruler color of the currently active match. + */ + activeMatchColorOverviewRuler: string; } /** @@ -64,5 +105,17 @@ 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; + + /** + * When decorations are enabled, fires when + * the search results or the selected result changes, + * returning undefined if there are no matches. + */ + readonly onDidChangeResults: IEvent<{ resultIndex: number, resultCount: number } | undefined>; } } diff --git a/addons/xterm-addon-search/webpack.config.js b/addons/xterm-addon-search/webpack.config.js index 726dceb6..30526812 100644 --- a/addons/xterm-addon-search/webpack.config.js +++ b/addons/xterm-addon-search/webpack.config.js @@ -21,6 +21,13 @@ module.exports = { } ] }, + resolve: { + modules: ['./node_modules'], + extensions: [ '.js' ], + alias: { + common: path.resolve('../../out/common') + } + }, output: { filename: mainFile, path: path.resolve('./lib'), diff --git a/addons/xterm-addon-serialize/package.json b/addons/xterm-addon-serialize/package.json index 1565a7e4..01a8b68f 100644 --- a/addons/xterm-addon-serialize/package.json +++ b/addons/xterm-addon-serialize/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-serialize", - "version": "0.6.1", + "version": "0.6.2", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.test.ts b/addons/xterm-addon-serialize/src/SerializeAddon.test.ts index caa29053..67b83884 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.test.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.test.ts @@ -43,7 +43,7 @@ class TestSelectionService { } } -describe('xterm-addon-serialize html', () => { +describe('xterm-addon-serialize', () => { let cm: ColorManager; let dom: jsdom.JSDOM; let document: Document; @@ -83,123 +83,132 @@ describe('xterm-addon-serialize html', () => { (terminal as any)._core._selectionService = selectionService; }); - it('empty terminal with selection turned off', () => { - const output = serializeAddon.serializeAsHTML(); - assert.notEqual(output, ''); - assert.equal((output.match(new RegExp('
{10}<\/div>', 'g')) || []).length, 2); - }); - - it('empty terminal with no selection', () => { - const output = serializeAddon.serializeAsHTML({ - onlySelection: true + describe('text', () => { + it('restoring cursor styles', async () => { + await writeP(terminal, sgr('32') + '> ' + sgr('0')); + assert.equal(serializeAddon.serialize(), '\u001b[32m> \u001b[0m'); }); - assert.equal(output, ''); }); - it('basic terminal with selection', async () => { - await writeP(terminal, ' terminal '); - terminal.select(1, 0, 8); - - const output = serializeAddon.serializeAsHTML({ - onlySelection: true + describe('html', () => { + it('empty terminal with selection turned off', () => { + const output = serializeAddon.serializeAsHTML(); + assert.notEqual(output, ''); + assert.equal((output.match(/
{10}<\/span><\/div>/g) || []).length, 2); }); - assert.equal((output.match(new RegExp('
terminal<\/span><\/div>', 'g')) || []).length, 1, output); - }); - it('cells with bold styling', async () => { - await writeP(terminal, ' ' + sgr('1') + 'terminal' + sgr('22') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with italic styling', async () => { - await writeP(terminal, ' ' + sgr('3') + 'terminal' + sgr('23') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with inverse styling', async () => { - await writeP(terminal, ' ' + sgr('7') + 'terminal' + sgr('27') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with underline styling', async () => { - await writeP(terminal, ' ' + sgr('4') + 'terminal' + sgr('24') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with invisible styling', async () => { - await writeP(terminal, ' ' + sgr('8') + 'terminal' + sgr('28') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with dim styling', async () => { - await writeP(terminal, ' ' + sgr('2') + 'terminal' + sgr('22') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with strikethrough styling', async () => { - await writeP(terminal, ' ' + sgr('9') + 'terminal' + sgr('29') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with combined styling', async () => { - await writeP(terminal, sgr('1') + ' ' + sgr('9') + 'termi' + sgr('22') + 'nal' + sgr('29') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp(' <\/span>', 'g')) || []).length, 1, output); - assert.equal((output.match(new RegExp('termi<\/span>', 'g')) || []).length, 1, output); - assert.equal((output.match(new RegExp('nal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with color styling', async () => { - await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('cells with background styling', async () => { - await writeP(terminal, ' ' + sgr('48;5;46') + 'terminal' + sgr('49') + ' '); - - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('terminal<\/span>', 'g')) || []).length, 1, output); - }); - - it('empty terminal with default options', async () => { - const output = serializeAddon.serializeAsHTML(); - assert.equal((output.match(new RegExp('color: #000000; background-color: #ffffff; font-family: courier-new, courier, monospace; font-size: 15px;', 'g')) || []).length, 1, output); - }); - - it('empty terminal with custom options', async () => { - terminal.options.fontFamily = 'verdana'; - terminal.options.fontSize = 20; - terminal.options.theme = { - foreground: '#ff00ff', - background: '#00ff00' - }; - const output = serializeAddon.serializeAsHTML({ - includeGlobalBackground: true + it('empty terminal with no selection', () => { + const output = serializeAddon.serializeAsHTML({ + onlySelection: true + }); + assert.equal(output, ''); }); - assert.equal((output.match(new RegExp('color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;', 'g')) || []).length, 1, output); - }); - it('empty terminal with background included', async () => { - const output = serializeAddon.serializeAsHTML({ - includeGlobalBackground: true + it('basic terminal with selection', async () => { + await writeP(terminal, ' terminal '); + terminal.select(1, 0, 8); + + const output = serializeAddon.serializeAsHTML({ + onlySelection: true + }); + assert.equal((output.match(/
terminal<\/span><\/div>/g) || []).length, 1, output); + }); + + it('cells with bold styling', async () => { + await writeP(terminal, ' ' + sgr('1') + 'terminal' + sgr('22') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('cells with italic styling', async () => { + await writeP(terminal, ' ' + sgr('3') + 'terminal' + sgr('23') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('cells with inverse styling', async () => { + await writeP(terminal, ' ' + sgr('7') + 'terminal' + sgr('27') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('cells with underline styling', async () => { + await writeP(terminal, ' ' + sgr('4') + 'terminal' + sgr('24') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('cells with invisible styling', async () => { + await writeP(terminal, ' ' + sgr('8') + 'terminal' + sgr('28') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('cells with dim styling', async () => { + await writeP(terminal, ' ' + sgr('2') + 'terminal' + sgr('22') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('cells with strikethrough styling', async () => { + await writeP(terminal, ' ' + sgr('9') + 'terminal' + sgr('29') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('cells with combined styling', async () => { + await writeP(terminal, sgr('1') + ' ' + sgr('9') + 'termi' + sgr('22') + 'nal' + sgr('29') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/ <\/span>/g) || []).length, 1, output); + assert.equal((output.match(/termi<\/span>/g) || []).length, 1, output); + assert.equal((output.match(/nal<\/span>/g) || []).length, 1, output); + }); + + it('cells with color styling', async () => { + await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('cells with background styling', async () => { + await writeP(terminal, ' ' + sgr('48;5;46') + 'terminal' + sgr('49') + ' '); + + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/terminal<\/span>/g) || []).length, 1, output); + }); + + it('empty terminal with default options', async () => { + const output = serializeAddon.serializeAsHTML(); + assert.equal((output.match(/color: #000000; background-color: #ffffff; font-family: courier-new, courier, monospace; font-size: 15px;/g) || []).length, 1, output); + }); + + it('empty terminal with custom options', async () => { + terminal.options.fontFamily = 'verdana'; + terminal.options.fontSize = 20; + terminal.options.theme = { + foreground: '#ff00ff', + background: '#00ff00' + }; + const output = serializeAddon.serializeAsHTML({ + includeGlobalBackground: true + }); + assert.equal((output.match(/color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;/g) || []).length, 1, output); + }); + + it('empty terminal with background included', async () => { + const output = serializeAddon.serializeAsHTML({ + includeGlobalBackground: true + }); + assert.equal((output.match(/color: #ffffff; background-color: #000000; font-family: courier-new, courier, monospace; font-size: 15px;/g) || []).length, 1, output); }); - assert.equal((output.match(new RegExp('color: #ffffff; background-color: #000000; font-family: courier-new, courier, monospace; font-size: 15px;', 'g')) || []).length, 1, output); }); }); diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.ts b/addons/xterm-addon-serialize/src/SerializeAddon.ts index 25a42e65..ed71e4e1 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.ts @@ -7,6 +7,7 @@ import { Terminal, ITerminalAddon, IBuffer, IBufferCell, IBufferRange } from 'xterm'; import { IColorSet } from 'browser/Types'; +import { IAttributeData } from 'common/Types'; function constrain(value: number, low: number, high: number): number { return Math.max(low, Math.min(value, high)); @@ -62,17 +63,17 @@ abstract class BaseSerializeHandler { protected _serializeString(): string { return ''; } } -function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean { +function equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean { return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor(); } -function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean { +function equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean { return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor(); } -function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { +function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean { return cell1.isInverse() === cell2.isInverse() && cell1.isBold() === cell2.isBold() && cell1.isUnderline() === cell2.isUnderline() @@ -229,7 +230,7 @@ class StringSerializeHandler extends BaseSerializeHandler { this._nullCellCount = 0; } - private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] { + private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] { const sgrSeq: number[] = []; const fgChanged = !equalFg(cell, oldCell); const bgChanged = !equalBg(cell, oldCell); @@ -393,6 +394,15 @@ class StringSerializeHandler extends BaseSerializeHandler { moveRight(realCursorCol - this._lastCursorCol); } + // Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677 + // HACK: Internal API access since it's awkward to expose this in the API and serialize will + // likely be the only consumer + const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData; + const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle); + if (sgrSeq.length > 0) { + content += `\u001b[${sgrSeq.join(';')}m`; + } + return content; } } @@ -544,7 +554,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { return target; } - targetLength = targetLength - target.length; + targetLength -= target.length; if (targetLength > padString.length) { padString += padString.repeat(targetLength / padString.length); } diff --git a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts index 334016e6..5c4ae437 100644 --- a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts +++ b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts @@ -14,7 +14,7 @@ let page: Page; const width = 800; const height = 600; -const writeRawSync = (page: any, str: string): Promise => writeSync(page, '\' +' + JSON.stringify(str) + '+ \''); +const writeRawSync = (page: any, str: string): Promise => writeSync(page, `' +` + JSON.stringify(str) + `+ '`); const testNormalScreenEqual = async (page: any, str: string): Promise => { await writeRawSync(page, str); diff --git a/addons/xterm-addon-web-links/src/WebLinkProvider.ts b/addons/xterm-addon-web-links/src/WebLinkProvider.ts index f0caf974..a620bc5e 100644 --- a/addons/xterm-addon-web-links/src/WebLinkProvider.ts +++ b/addons/xterm-addon-web-links/src/WebLinkProvider.ts @@ -5,9 +5,10 @@ import { ILinkProvider, ILink, Terminal, IViewportRange } from 'xterm'; -interface ILinkProviderOptions { +export interface ILinkProviderOptions { hover?(event: MouseEvent, text: string, location: IViewportRange): void; leave?(event: MouseEvent, text: string): void; + urlRegex?: RegExp; } export class WebLinkProvider implements ILinkProvider { diff --git a/addons/xterm-addon-web-links/src/WebLinksAddon.ts b/addons/xterm-addon-web-links/src/WebLinksAddon.ts index aae921ec..285ef5dc 100644 --- a/addons/xterm-addon-web-links/src/WebLinksAddon.ts +++ b/addons/xterm-addon-web-links/src/WebLinksAddon.ts @@ -3,8 +3,8 @@ * @license MIT */ -import { Terminal, ILinkMatcherOptions, ITerminalAddon, IDisposable, IViewportRange } from 'xterm'; -import { WebLinkProvider } from './WebLinkProvider'; +import { Terminal, ILinkMatcherOptions, ITerminalAddon, IDisposable } from 'xterm'; +import { ILinkProviderOptions, WebLinkProvider } from './WebLinkProvider'; const protocolClause = '(https?:\\/\\/)'; const domainCharacterSet = '[\\da-z\\.-]+'; @@ -40,12 +40,6 @@ function handleLink(event: MouseEvent, uri: string): void { } } -interface ILinkProviderOptions { - hover?(event: MouseEvent, text: string, location: IViewportRange): void; - leave?(event: MouseEvent, text: string): void; - urlRegex?: RegExp; -} - export class WebLinksAddon implements ITerminalAddon { private _linkMatcherId: number | undefined; private _terminal: Terminal | undefined; diff --git a/addons/xterm-addon-web-links/typings/xterm-addon-web-links.d.ts b/addons/xterm-addon-web-links/typings/xterm-addon-web-links.d.ts index 78a258e5..5e6c266d 100644 --- a/addons/xterm-addon-web-links/typings/xterm-addon-web-links.d.ts +++ b/addons/xterm-addon-web-links/typings/xterm-addon-web-links.d.ts @@ -49,5 +49,10 @@ declare module 'xterm-addon-web-links' { * happen even when tooltipCallback hasn't fired for the link yet. */ leave?(event: MouseEvent, text: string): void; + + /** + * A callback to use instead of the default one. + */ + urlRegex?: RegExp; } } diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index dd95f177..e409f51e 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -238,7 +238,7 @@ export class WebglCharAtlas implements IDisposable { const bg = this._config.colors.background.css; if (bg.length === 9) { // Remove bg alpha channel if present - return bg.substr(0, 7); + return bg.slice(0, 7); } return bg; } diff --git a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts index 080d7ce8..0b86b14b 100644 --- a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts +++ b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts @@ -260,9 +260,9 @@ describe('WebGL Renderer Integration Tests', async () => { for (let y = 0; y < 240 / 16; y++) { for (let x = 0; x < 16; x++) { const cssColor = COLORS_16_TO_255[y * 16 + x]; - const r = parseInt(cssColor.substr(1, 2), 16); - const g = parseInt(cssColor.substr(3, 2), 16); - const b = parseInt(cssColor.substr(5, 2), 16); + const r = parseInt(cssColor.slice(1, 3), 16); + const g = parseInt(cssColor.slice(3, 5), 16); + const b = parseInt(cssColor.slice(5, 7), 16); await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); } } @@ -280,9 +280,9 @@ describe('WebGL Renderer Integration Tests', async () => { for (let y = 0; y < 240 / 16; y++) { for (let x = 0; x < 16; x++) { const cssColor = COLORS_16_TO_255[y * 16 + x]; - const r = parseInt(cssColor.substr(1, 2), 16); - const g = parseInt(cssColor.substr(3, 2), 16); - const b = parseInt(cssColor.substr(5, 2), 16); + const r = parseInt(cssColor.slice(1, 3), 16); + const g = parseInt(cssColor.slice(3, 5), 16); + const b = parseInt(cssColor.slice(5, 7), 16); await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); } } @@ -300,9 +300,9 @@ describe('WebGL Renderer Integration Tests', async () => { for (let y = 0; y < 240 / 16; y++) { for (let x = 0; x < 16; x++) { const cssColor = COLORS_16_TO_255[y * 16 + x]; - const r = parseInt(cssColor.substr(1, 2), 16); - const g = parseInt(cssColor.substr(3, 2), 16); - const b = parseInt(cssColor.substr(5, 2), 16); + const r = parseInt(cssColor.slice(1, 3), 16); + const g = parseInt(cssColor.slice(3, 5), 16); + const b = parseInt(cssColor.slice(5, 7), 16); await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); } } @@ -320,9 +320,9 @@ describe('WebGL Renderer Integration Tests', async () => { for (let y = 0; y < 240 / 16; y++) { for (let x = 0; x < 16; x++) { const cssColor = COLORS_16_TO_255[y * 16 + x]; - const r = parseInt(cssColor.substr(1, 2), 16); - const g = parseInt(cssColor.substr(3, 2), 16); - const b = parseInt(cssColor.substr(5, 2), 16); + const r = parseInt(cssColor.slice(1, 3), 16); + const g = parseInt(cssColor.slice(3, 5), 16); + const b = parseInt(cssColor.slice(5, 7), 16); await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); } } @@ -356,9 +356,9 @@ describe('WebGL Renderer Integration Tests', async () => { for (let y = 0; y < 240 / 16; y++) { for (let x = 0; x < 16; x++) { const cssColor = COLORS_16_TO_255[y * 16 + x]; - const r = parseInt(cssColor.substr(1, 2), 16); - const g = parseInt(cssColor.substr(3, 2), 16); - const b = parseInt(cssColor.substr(5, 2), 16); + const r = parseInt(cssColor.slice(1, 3), 16); + const g = parseInt(cssColor.slice(3, 5), 16); + const b = parseInt(cssColor.slice(5, 7), 16); await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); } } diff --git a/bin/publish.js b/bin/publish.js index 75de4b68..a43b8cd4 100644 --- a/bin/publish.js +++ b/bin/publish.js @@ -104,11 +104,11 @@ function getNextBetaVersion(packageJson) { return `${nextStableVersion}-${tag}.1`; } const latestPublishedVersion = publishedVersions.sort((a, b) => { - const aVersion = parseInt(a.substr(a.search(/\d+$/))); - const bVersion = parseInt(b.substr(b.search(/\d+$/))); + const aVersion = parseInt(a.slice(a.search(/\d+$/))); + const bVersion = parseInt(b.slice(b.search(/\d+$/))); return aVersion > bVersion ? -1 : 1; })[0]; - const latestTagVersion = parseInt(latestPublishedVersion.substr(latestPublishedVersion.search(/\d+$/)), 10); + const latestTagVersion = parseInt(latestPublishedVersion.slice(latestPublishedVersion.search(/\d+$/)), 10); return `${nextStableVersion}-${tag}.${latestTagVersion + 1}`; } diff --git a/css/xterm.css b/css/xterm.css index ab3965b4..7432fbb1 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -178,3 +178,10 @@ z-index: 6; position: absolute; } + +.xterm-decoration-overview-ruler { + z-index: 7; + position: absolute; + top: 0; + right: 0; +} diff --git a/demo/client.ts b/demo/client.ts index aee23401..55ff8d62 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,15 @@ 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 ? { + matchBackground: '#55575380', + matchBorder: '#555753', + matchOverviewRuler: '#555753', + activeMatchBackground: '#ef292980', + activeMatchBorder: '#ef2929', + activeMatchColorOverviewRuler: '#ef2929' + } : undefined }; } @@ -151,6 +160,7 @@ if (document.location.pathname === '/test') { document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler); document.getElementById('load-test').addEventListener('click', loadTest); document.getElementById('add-decoration').addEventListener('click', addDecoration); + document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler); } function createTerminal(): void { @@ -544,9 +554,21 @@ function loadTest() { } function addDecoration() { + term.options['overviewRulerWidth'] = 15; const marker = term.addMarker(1); - const decoration = term.registerDecoration({ marker }); - decoration.onRender(() => { - decoration.element.style.backgroundColor = 'red'; - }); + const decoration = term.registerDecoration({ marker, overviewRulerOptions: { color: '#ef2929'} }); + decoration.onRender((e) => e.style.backgroundColor = '#ef2929'); } + +function addOverviewRuler() { + term.options['overviewRulerWidth'] = 15; + term.registerDecoration({marker: term.addMarker(1), overviewRulerOptions: { color: '#ef2929' }}); + term.registerDecoration({marker: term.addMarker(3), overviewRulerOptions: { color: '#8ae234' }}); + term.registerDecoration({marker: term.addMarker(5), overviewRulerOptions: { color: '#729fcf' }}); + term.registerDecoration({marker: term.addMarker(7), overviewRulerOptions: { color: '#ef2929', position: 'left' }}); + term.registerDecoration({marker: term.addMarker(7), overviewRulerOptions: { color: '#8ae234', position: 'center' }}); + term.registerDecoration({marker: term.addMarker(7), overviewRulerOptions: { color: '#729fcf', position: 'right' }}); + term.registerDecoration({marker: term.addMarker(10), overviewRulerOptions: { color: '#8ae234', position: 'center' }}); + term.registerDecoration({marker: term.addMarker(10), overviewRulerOptions: { color: '#ffffff80', position: 'full' }}); +} + diff --git a/demo/index.html b/demo/index.html index 31111631..6bbcb4a2 100644 --- a/demo/index.html +++ b/demo/index.html @@ -43,6 +43,7 @@ +

SerializeAddon

@@ -69,6 +70,7 @@ +
diff --git a/src/browser/ColorManager.ts b/src/browser/ColorManager.ts index b4b57c67..e7ac10ba 100644 --- a/src/browser/ColorManager.ts +++ b/src/browser/ColorManager.ts @@ -185,7 +185,7 @@ export class ColorManager implements IColorManager { foreground: this.colors.foreground, background: this.colors.background, cursor: this.colors.cursor, - ansi: [...this.colors.ansi] + ansi: this.colors.ansi.slice() }; } diff --git a/src/browser/Decorations/BufferDecorationRenderer.ts b/src/browser/Decorations/BufferDecorationRenderer.ts new file mode 100644 index 00000000..22dc73e9 --- /dev/null +++ b/src/browser/Decorations/BufferDecorationRenderer.ts @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableDomListener } from 'browser/Lifecycle'; +import { IRenderService } from 'browser/services/Services'; +import { Disposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration } from 'common/services/Services'; + +export class BufferDecorationRenderer extends Disposable { + private readonly _container: HTMLElement; + private readonly _decorationElements: Map = new Map(); + + private _animationFrame: number | undefined; + private _altBufferIsActive: boolean = false; + + constructor( + private readonly _screenElement: HTMLElement, + @IBufferService private readonly _bufferService: IBufferService, + @IDecorationService private readonly _decorationService: IDecorationService, + @IRenderService private readonly _renderService: IRenderService + ) { + super(); + + this._container = document.createElement('div'); + this._container.classList.add('xterm-decoration-container'); + this._screenElement.appendChild(this._container); + + this.register(this._renderService.onRenderedBufferChange(() => this._queueRefresh())); + this.register(this._renderService.onDimensionsChange(() => this._queueRefresh())); + this.register(addDisposableDomListener(window, 'resize', () => this._queueRefresh())); + this.register(this._bufferService.buffers.onBufferActivate(() => { + this._altBufferIsActive = this._bufferService.buffer === this._bufferService.buffers.alt; + })); + this.register(this._decorationService.onDecorationRegistered(() => this._queueRefresh())); + this.register(this._decorationService.onDecorationRemoved(decoration => this._removeDecoration(decoration))); + } + + public override dispose(): void { + this._container.remove(); + this._decorationElements.clear(); + super.dispose(); + } + + private _queueRefresh(): void { + if (this._animationFrame !== undefined) { + return; + } + this._animationFrame = window.requestAnimationFrame(() => { + this.refreshDecorations(); + this._animationFrame = undefined; + }); + } + + public refreshDecorations(): void { + for (const decoration of this._decorationService.decorations) { + this._renderDecoration(decoration); + } + } + + private _renderDecoration(decoration: IInternalDecoration): void { + let element = this._decorationElements.get(decoration); + if (!element) { + element = this._createElement(decoration); + decoration.onDispose(() => this._removeDecoration(decoration)); + decoration.marker.onDispose(() => decoration.dispose()); + decoration.element = element; + this._decorationElements.set(decoration, element); + this._container.appendChild(element); + } + this._refreshStyle(decoration, element); + decoration.onRenderEmitter.fire(element); + } + + private _createElement(decoration: IInternalDecoration): HTMLElement { + const element = document.createElement('div'); + element.classList.add('xterm-decoration'); + element.style.width = `${(decoration.options.width || 1) * this._renderService.dimensions.actualCellWidth}px`; + element.style.height = `${(decoration.options.height || 1) * this._renderService.dimensions.actualCellHeight}px`; + element.style.top = `${(decoration.marker.line - this._bufferService.buffers.active.ydisp) * this._renderService.dimensions.actualCellHeight}px`; + element.style.lineHeight = `${this._renderService.dimensions.actualCellHeight}px`; + + const x = decoration.options.x ?? 0; + if (x && x > this._bufferService.cols) { + // exceeded the container width, so hide + element.style.display = 'none'; + } + if ((decoration.options.anchor || 'left') === 'right') { + element.style.right = x ? `${x * this._renderService.dimensions.actualCellWidth}px` : ''; + } else { + element.style.left = x ? `${x * this._renderService.dimensions.actualCellWidth}px` : ''; + } + + return element; + } + + private _refreshStyle(decoration: IInternalDecoration, element: HTMLElement): void { + const line = decoration.marker.line - this._bufferService.buffers.active.ydisp; + if (line < 0 || line >= this._bufferService.rows) { + // outside of viewport + element.style.display = 'none'; + } else { + element.style.top = `${line * this._renderService.dimensions.actualCellHeight}px`; + element.style.display = this._altBufferIsActive ? 'none' : 'block'; + } + } + + private _removeDecoration(decoration: IInternalDecoration): void { + this._decorationElements.get(decoration)?.remove(); + this._decorationElements.delete(decoration); + } +} diff --git a/src/browser/Decorations/OverviewRulerRenderer.ts b/src/browser/Decorations/OverviewRulerRenderer.ts new file mode 100644 index 00000000..1407f4bb --- /dev/null +++ b/src/browser/Decorations/OverviewRulerRenderer.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableDomListener } from 'browser/Lifecycle'; +import { IRenderService } from 'browser/services/Services'; +import { Disposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration, IOptionsService } from 'common/services/Services'; + +// Helper objects to avoid excessive calculation and garbage collection during rendering. These are +// static values for each render and can be accessed using the decoration position as the key. +const drawHeight = { + full: 0, + left: 0, + center: 0, + right: 0 +}; +const drawWidth = { + full: 0, + left: 0, + center: 0, + right: 0 +}; +const drawX = { + full: 0, + left: 0, + center: 0, + right: 0 +}; + +export class OverviewRulerRenderer extends Disposable { + private readonly _canvas: HTMLCanvasElement; + private readonly _ctx: CanvasRenderingContext2D; + private readonly _decorationElements: Map = new Map(); + private get _width(): number { + return this._optionsService.options.overviewRulerWidth || 0; + } + private _animationFrame: number | undefined; + + private _shouldUpdateDimensions: boolean | undefined = true; + private _shouldUpdateAnchor: boolean | undefined = true; + + private _containerHeight: number | undefined; + + constructor( + private readonly _viewportElement: HTMLElement, + private readonly _screenElement: HTMLElement, + @IBufferService private readonly _bufferService: IBufferService, + @IDecorationService private readonly _decorationService: IDecorationService, + @IRenderService private readonly _renderService: IRenderService, + @IOptionsService private readonly _optionsService: IOptionsService + ) { + super(); + this._canvas = document.createElement('canvas'); + this._canvas.classList.add('xterm-decoration-overview-ruler'); + this._refreshCanvasDimensions(); + this._viewportElement.parentElement?.insertBefore(this._canvas, this._viewportElement); + const ctx = this._canvas.getContext('2d'); + if (!ctx) { + throw new Error('Ctx cannot be null'); + } else { + this._ctx = ctx; + } + this._registerDecorationListeners(); + this._registerBufferChangeListeners(); + this._registerDimensionChangeListeners(); + } + + /** + * On decoration add or remove, redraw + */ + private _registerDecorationListeners(): void { + this.register(this._decorationService.onDecorationRegistered(() => this._queueRefresh(undefined, true))); + this.register(this._decorationService.onDecorationRemoved(decoration => this._removeDecoration(decoration))); + } + + /** + * On buffer change, redraw + * and hide the canvas if the alt buffer is active + */ + private _registerBufferChangeListeners(): void { + this.register(this._renderService.onRenderedBufferChange(() => this._queueRefresh())); + this.register(this._bufferService.buffers.onBufferActivate(() => { + this._canvas!.style.display = this._bufferService.buffer === this._bufferService.buffers.alt ? 'none' : 'block'; + })); + } + /** + * On dimension change, update canvas dimensions + * and then redraw + */ + private _registerDimensionChangeListeners(): void { + // container height changed + this.register(this._renderService.onRender((): void => { + if (!this._containerHeight || this._containerHeight !== this._screenElement.clientHeight) { + this._queueRefresh(true); + this._containerHeight = this._screenElement.clientHeight; + } + })); + // overview ruler width changed + this.register(this._optionsService.onOptionChange(o => { + if (o === 'overviewRulerWidth') { + this._queueRefresh(true); + } + })); + // device pixel ratio changed + this.register(addDisposableDomListener(window, 'resize', () => { + this._queueRefresh(true); + })); + // set the canvas dimensions + this._queueRefresh(true); + } + + public override dispose(): void { + for (const decoration of this._decorationElements) { + decoration[0].dispose(); + } + this._decorationElements.clear(); + this._canvas?.remove(); + super.dispose(); + } + + private _refreshDrawConstants(): void { + // width + const outerWidth = Math.floor(this._canvas.width / 3); + const innerWidth = Math.ceil(this._canvas.width / 3); + drawWidth.full = this._canvas.width; + drawWidth.left = outerWidth; + drawWidth.center = innerWidth; + drawWidth.right = outerWidth; + // height + drawHeight.full = Math.round(2 * window.devicePixelRatio); + drawHeight.left = Math.round(6 * window.devicePixelRatio); + drawHeight.center = Math.round(6 * window.devicePixelRatio); + drawHeight.right = Math.round(6 * window.devicePixelRatio); + // x + drawX.full = 0; + drawX.left = 0; + drawX.center = drawWidth.left; + drawX.right = drawWidth.left + drawWidth.center; + } + + private _refreshStyle(decoration: IInternalDecoration): void { + if (!decoration.options.overviewRulerOptions) { + this._decorationElements.delete(decoration); + return; + } + this._ctx.lineWidth = 1; + this._ctx.fillStyle = decoration.options.overviewRulerOptions.color; + this._ctx.fillRect( + /* x */ drawX[decoration.options.overviewRulerOptions.position!], + /* y */ Math.round( + (this._canvas.height - 1) * // -1 to ensure at least 2px are allowed for decoration on last line + (decoration.options.marker.line / this._bufferService.buffers.active.lines.length) - drawHeight[decoration.options.overviewRulerOptions.position!] / 2 + ), + /* w */ drawWidth[decoration.options.overviewRulerOptions.position!], + /* h */ drawHeight[decoration.options.overviewRulerOptions.position!] + ); + } + + private _refreshCanvasDimensions(): void { + this._canvas.style.width = `${this._width}px`; + this._canvas.width = Math.round(this._width * window.devicePixelRatio); + this._canvas.style.height = `${this._screenElement.clientHeight}px`; + this._canvas.height = Math.round(this._screenElement.clientHeight * window.devicePixelRatio); + this._refreshDrawConstants(); + } + + private _refreshDecorations(): void { + if (this._shouldUpdateDimensions) { + this._refreshCanvasDimensions(); + } + this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + for (const decoration of this._decorationService.decorations) { + if (decoration.options.overviewRulerOptions && decoration.options.overviewRulerOptions.position !== 'full') { + this._renderDecoration(decoration); + } + } + for (const decoration of this._decorationService.decorations) { + if (decoration.options.overviewRulerOptions && decoration.options.overviewRulerOptions.position === 'full') { + this._renderDecoration(decoration); + } + } + this._shouldUpdateDimensions = false; + this._shouldUpdateAnchor = false; + } + + private _renderDecoration(decoration: IInternalDecoration): void { + const element = this._decorationElements.get(decoration); + if (!element) { + this._decorationElements.set(decoration, this._canvas); + decoration.onDispose(() => this._queueRefresh()); + } + this._refreshStyle(decoration); + } + + private _queueRefresh(updateCanvasDimensions?: boolean, updateAnchor?: boolean): void { + this._shouldUpdateDimensions = updateCanvasDimensions || this._shouldUpdateDimensions; + this._shouldUpdateAnchor = updateAnchor || this._shouldUpdateAnchor; + if (this._animationFrame !== undefined) { + return; + } + this._animationFrame = window.requestAnimationFrame(() => { + this._refreshDecorations(); + this._animationFrame = undefined; + }); + } + + private _removeDecoration(decoration: IInternalDecoration): void { + this._decorationElements.get(decoration)?.remove(); + this._decorationElements.delete(decoration); + } +} diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index 872bfdc7..d039b17b 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -1332,8 +1332,8 @@ describe('Terminal', () => { (!(i % 3)) ? input[i] : (i % 3 === 1) - ? input.substr(i, 2) - : input.substr(i - 1, 2), + ? input.slice(i, i + 2) + : input.slice(i - 1, i + 1), terminal.buffer.lines.get(bufferIndex[0])!.loadCell(bufferIndex[1], new CellData()).getChars()); } }); diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 703c995b..f08d8581 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -26,7 +26,7 @@ import { IRenderer } from 'browser/renderer/Types'; import { CompositionHelper } from 'browser/input/CompositionHelper'; import { Viewport } from 'browser/Viewport'; import { rightClickHandler, moveTextAreaUnderMouseCursor, handlePasteEvent, copyHandler, paste } from 'browser/Clipboard'; -import { C0 } from 'common/data/EscapeSequences'; +import { C0, C1_ESCAPED } from 'common/data/EscapeSequences'; import { WindowsOptionsReportType } from '../common/InputHandler'; import { Renderer } from 'browser/renderer/Renderer'; import { Linkifier } from 'browser/Linkifier'; @@ -45,7 +45,7 @@ import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { ColorManager } from 'browser/ColorManager'; import { RenderService } from 'browser/services/RenderService'; -import { ICharSizeService, IRenderService, IMouseService, ISelectionService, ISoundService, ICoreBrowserService, ICharacterJoinerService, IDecorationService } from 'browser/services/Services'; +import { ICharSizeService, IRenderService, IMouseService, ISelectionService, ISoundService, ICoreBrowserService, ICharacterJoinerService } from 'browser/services/Services'; import { CharSizeService } from 'browser/services/CharSizeService'; import { IBuffer } from 'common/buffer/Types'; import { MouseService } from 'browser/services/MouseService'; @@ -55,7 +55,10 @@ import { CoreTerminal } from 'common/CoreTerminal'; import { color, rgba } from 'browser/Color'; import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'; import { toRgbString } from 'common/input/XParseColor'; -import { DecorationService } from 'browser/services/DecorationService'; +import { BufferDecorationRenderer } from 'browser/Decorations/BufferDecorationRenderer'; +import { OverviewRulerRenderer } from 'browser/Decorations/OverviewRulerRenderer'; +import { DecorationService } from 'common/services/DecorationService'; +import { IDecorationService } from 'common/services/Services'; // Let it work inside Node.js for automated testing purposes. const document: Document = (typeof window !== 'undefined') ? window.document : null as any; @@ -71,6 +74,8 @@ export class Terminal extends CoreTerminal implements ITerminal { private _helperContainer: HTMLElement | undefined; private _compositionView: HTMLElement | undefined; + private _overviewRulerRenderer: OverviewRulerRenderer | undefined; + // private _visualBellTimer: number; public browser: IBrowser = Browser as any; @@ -78,6 +83,7 @@ export class Terminal extends CoreTerminal implements ITerminal { private _customKeyEventHandler: CustomKeyEventHandler | undefined; // browser services + private _decorationService: DecorationService; private _charSizeService: ICharSizeService | undefined; private _mouseService: IMouseService | undefined; private _renderService: IRenderService | undefined; @@ -92,6 +98,12 @@ export class Terminal extends CoreTerminal implements ITerminal { */ private _keyDownHandled: boolean = false; + /** + * Records whether a keydown event has occured since the last keyup event, i.e. whether a key + * is currently "pressed". + */ + private _keyDownSeen: boolean = false; + /** * Records whether the keypress event has already been handled and triggered a data event, if so * the input event should not trigger a data event but should still print to the textarea so @@ -109,7 +121,6 @@ export class Terminal extends CoreTerminal implements ITerminal { public linkifier: ILinkifier; public linkifier2: ILinkifier2; public viewport: IViewport | undefined; - public decorationService: IDecorationService; private _compositionHelper: ICompositionHelper | undefined; private _mouseZoneManager: IMouseZoneManager | undefined; private _accessibilityManager: AccessibilityManager | undefined; @@ -159,7 +170,8 @@ export class Terminal extends CoreTerminal implements ITerminal { this.linkifier = this._instantiationService.createInstance(Linkifier); this.linkifier2 = this.register(this._instantiationService.createInstance(Linkifier2)); - this.decorationService = this.register(this._instantiationService.createInstance(DecorationService)); + this._decorationService = this._instantiationService.createInstance(DecorationService); + this._instantiationService.setService(IDecorationService, this._decorationService); // Setup InputHandler listeners this.register(this._inputHandler.onRequestBell(() => this.bell())); @@ -212,7 +224,7 @@ export class Terminal extends CoreTerminal implements ITerminal { const channels = color.toColorRGB(acc === 'ansi' ? this._colorManager.colors.ansi[req.index] : this._colorManager.colors[acc]); - this.coreService.triggerDataEvent(`${C0.ESC}]${ident};${toRgbString(channels)}${C0.BEL}`); + this.coreService.triggerDataEvent(`${C0.ESC}]${ident};${toRgbString(channels)}${C1_ESCAPED.ST}`); break; case ColorRequestType.SET: if (acc === 'ansi') this._colorManager.colors.ansi[req.index] = rgba.toColor(...req.color); @@ -471,6 +483,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this._viewportElement = document.createElement('div'); this._viewportElement.classList.add('xterm-viewport'); fragment.appendChild(this._viewportElement); + this._viewportScrollArea = document.createElement('div'); this._viewportScrollArea.classList.add('xterm-scroll-area'); this._viewportElement.appendChild(this._viewportScrollArea); @@ -576,8 +589,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this.register(this.onScroll(() => this._mouseZoneManager!.clearAll())); this.linkifier.attachToDom(this.element, this._mouseZoneManager); this.linkifier2.attachToDom(this.screenElement, this._mouseService, this._renderService); - - this.decorationService.attachToDom(this.screenElement, this._renderService, this._bufferService); + this.register(this._instantiationService.createInstance(BufferDecorationRenderer, this.screenElement)); // This event listener must be registered aftre MouseZoneManager is created this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.onMouseDown(e))); @@ -595,6 +607,14 @@ export class Terminal extends CoreTerminal implements ITerminal { this._accessibilityManager = new AccessibilityManager(this, this._renderService); } + if (this.options.overviewRulerWidth) { + this._overviewRulerRenderer = this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement); + } + this.optionsService.onOptionChange(() => { + if (!this._overviewRulerRenderer && this.options.overviewRulerWidth && this._viewportElement && this.screenElement) { + this._overviewRulerRenderer = this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement); + } + }); // Measure the character size this._charSizeService.measure(); @@ -1003,7 +1023,7 @@ export class Terminal extends CoreTerminal implements ITerminal { } public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { - return this.decorationService!.registerDecoration(decorationOptions); + return this._decorationService.registerDecoration(decorationOptions); } /** @@ -1070,6 +1090,7 @@ export class Terminal extends CoreTerminal implements ITerminal { */ protected _keyDown(event: KeyboardEvent): boolean | undefined { this._keyDownHandled = false; + this._keyDownSeen = true; if (this._customKeyEventHandler && this._customKeyEventHandler(event) === false) { return false; @@ -1155,6 +1176,8 @@ export class Terminal extends CoreTerminal implements ITerminal { } protected _keyUp(ev: KeyboardEvent): void { + this._keyDownSeen = false; + if (this._customKeyEventHandler && this._customKeyEventHandler(ev) === false) { return; } @@ -1228,7 +1251,8 @@ export class Terminal extends CoreTerminal implements ITerminal { protected _inputEvent(ev: InputEvent): boolean { // Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to // support reading out character input which can doubling up input characters - if (ev.data && ev.inputType === 'insertText' && !ev.composed && !this.optionsService.rawOptions.screenReaderMode) { + // Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679 + if (ev.data && ev.inputType === 'insertText' && (!ev.composed || !this._keyDownSeen) && !this.optionsService.rawOptions.screenReaderMode) { if (this._keyPressHandled) { return false; } @@ -1301,7 +1325,7 @@ export class Terminal extends CoreTerminal implements ITerminal { // Don't clear if it's already clear return; } - this.buffer.clearMarkers(); + this.buffer.clearAllMarkers(0); this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!); this.buffer.lines.length = 1; this.buffer.ydisp = 0; diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 10a1435b..85e2bb55 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -257,7 +257,10 @@ export class MockBuffer implements IBuffer { public getWhitespaceCell(attr?: IAttributeData): ICellData { throw new Error('Method not implemented.'); } - public clearMarkers(): void { + public clearMarkers(y: number): void { + throw new Error('Method not implemented.'); + } + public clearAllMarkers(excludeY: number): void { throw new Error('Method not implemented.'); } } @@ -368,6 +371,7 @@ export class MockRenderService implements IRenderService { public serviceBrand: undefined; public onDimensionsChange: IEvent = new EventEmitter().event; public onRenderedBufferChange: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event; + public onRender: IEvent<{ start: number, end: number }, void> = new EventEmitter<{ start: number, end: number }>().event; public onRefreshRequest: IEvent<{ start: number, end: number}, void> = new EventEmitter<{ start: number, end: number }>().event; public dimensions: IRenderDimensions = { scaledCharWidth: 0, diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 35b52d62..8860bb41 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -9,6 +9,7 @@ import { ICoreTerminal, CharData, ITerminalOptions } from 'common/Types'; import { IMouseService, IRenderService } from './services/Services'; import { IBuffer } from 'common/buffer/Types'; import { IFunctionIdentifier, IParams } from 'common/parser/Types'; +import { createDecorator } from 'common/services/ServiceRegistry'; export interface ITerminal extends IPublicTerminal, ICoreTerminal { element: HTMLElement | undefined; diff --git a/src/browser/renderer/CustomGlyphs.ts b/src/browser/renderer/CustomGlyphs.ts index 77562790..c2bfc210 100644 --- a/src/browser/renderer/CustomGlyphs.ts +++ b/src/browser/renderer/CustomGlyphs.ts @@ -414,10 +414,10 @@ function drawPatternChar( let b: number; let a: number; if (fillStyle.startsWith('#')) { - r = parseInt(fillStyle.substr(1, 2), 16); - g = parseInt(fillStyle.substr(3, 2), 16); - b = parseInt(fillStyle.substr(5, 2), 16); - a = fillStyle.length > 7 && parseInt(fillStyle.substr(7, 2), 16) || 1; + r = parseInt(fillStyle.slice(1, 3), 16); + g = parseInt(fillStyle.slice(3, 5), 16); + b = parseInt(fillStyle.slice(5, 7), 16); + a = fillStyle.length > 7 && parseInt(fillStyle.slice(7, 9), 16) || 1; } else if (fillStyle.startsWith('rgba')) { ([r, g, b, a] = fillStyle.substring(5, fillStyle.length - 1).split(',').map(e => parseFloat(e))); } else { diff --git a/src/browser/renderer/atlas/CharAtlasUtils.ts b/src/browser/renderer/atlas/CharAtlasUtils.ts index be92727a..696c6c12 100644 --- a/src/browser/renderer/atlas/CharAtlasUtils.ts +++ b/src/browser/renderer/atlas/CharAtlasUtils.ts @@ -16,7 +16,7 @@ export function generateConfig(scaledCharWidth: number, scaledCharHeight: number cursor: undefined, cursorAccent: undefined, selection: undefined, - ansi: [...colors.ansi] + ansi: colors.ansi.slice() }; return { devicePixelRatio: window.devicePixelRatio, diff --git a/src/browser/services/DecorationService.ts b/src/browser/services/DecorationService.ts deleted file mode 100644 index ed3c7224..00000000 --- a/src/browser/services/DecorationService.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Copyright (c) 2022 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { IDecorationService, IRenderService } from 'browser/services/Services'; -import { EventEmitter, IEvent } from 'common/EventEmitter'; -import { Disposable } from 'common/Lifecycle'; -import { IBufferService, IInstantiationService } from 'common/services/Services'; -import { IDecorationOptions, IDecoration, IMarker } from 'xterm'; - -export class DecorationService extends Disposable implements IDecorationService { - - private readonly _decorations: Decoration[] = []; - private _container: HTMLElement | undefined; - private _screenElement: HTMLElement | undefined; - private _renderService: IRenderService | undefined; - private _animationFrame: number | undefined; - - constructor(@IInstantiationService private readonly _instantiationService: IInstantiationService) { super(); } - - public attachToDom(screenElement: HTMLElement, renderService: IRenderService): void { - this._renderService = renderService; - this._screenElement = screenElement; - this._container = document.createElement('div'); - this._container.classList.add('xterm-decoration-container'); - screenElement.appendChild(this._container); - this.register(this._renderService.onRenderedBufferChange(() => this.refresh())); - this.register(this._renderService.onDimensionsChange(() => this.refresh(true))); - } - - public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { - if (decorationOptions.marker.isDisposed || !this._container) { - return undefined; - } - const decoration = this._instantiationService.createInstance(Decoration, decorationOptions, this._container); - this._decorations.push(decoration); - decoration.onDispose(() => this._decorations.splice(this._decorations.indexOf(decoration), 1)); - this._queueRefresh(); - return decoration; - } - - private _queueRefresh(): void { - if (this._animationFrame !== undefined) { - return; - } - this._animationFrame = window.requestAnimationFrame(() => { - this.refresh(); - this._animationFrame = undefined; - }); - } - - public refresh(shouldRecreate?: boolean): void { - if (!this._renderService) { - return; - } - for (const decoration of this._decorations) { - decoration.render(this._renderService, shouldRecreate); - } - } - - public dispose(): void { - for (const decoration of this._decorations) { - decoration.dispose(); - } - if (this._screenElement && this._container && this._screenElement.contains(this._container)) { - this._screenElement.removeChild(this._container); - } - } -} -export class Decoration extends Disposable implements IDecoration { - private readonly _marker: IMarker; - private _element: HTMLElement | undefined; - - public isDisposed: boolean = false; - - public get element(): HTMLElement | undefined { return this._element; } - public get marker(): IMarker { return this._marker; } - - private _onDispose = new EventEmitter(); - public get onDispose(): IEvent { return this._onDispose.event; } - - private _onRender = new EventEmitter(); - public get onRender(): IEvent { return this._onRender.event; } - - public x: number; - public anchor: 'left' | 'right'; - public width: number; - public height: number; - - constructor( - options: IDecorationOptions, - private readonly _container: HTMLElement, - @IBufferService private readonly _bufferService: IBufferService - ) { - super(); - this.x = options.x ?? 0; - this._marker = options.marker; - this._marker.onDispose(() => this.dispose()); - this.anchor = options.anchor || 'left'; - this.width = options.width || 1; - this.height = options.height || 1; - } - - public render(renderService: IRenderService, shouldRecreate?: boolean): void { - if (!this._element || shouldRecreate) { - this._createElement(renderService, shouldRecreate); - } - if (this._container && this._element && !this._container.contains(this._element)) { - this._container.append(this._element); - } - this._refreshStyle(renderService); - if (this._element) { - this._onRender.fire(this._element); - } - } - - private _createElement(renderService: IRenderService, shouldRecreate?: boolean): void { - if (shouldRecreate && this._element && this._container.contains(this._element)) { - this._container.removeChild(this._element); - } - this._element = document.createElement('div'); - this._element.classList.add('xterm-decoration'); - this._element.style.width = `${this.width * renderService.dimensions.actualCellWidth}px`; - this._element.style.height = `${this.height * renderService.dimensions.actualCellHeight}px`; - this._element.style.top = `${(this.marker.line - this._bufferService.buffers.active.ydisp) * renderService.dimensions.actualCellHeight}px`; - this._element.style.lineHeight = `${renderService.dimensions.actualCellHeight}px`; - - if (this.x && this.x > this._bufferService.cols) { - // exceeded the container width, so hide - this._element.style.display = 'none'; - } - if (this.anchor === 'right') { - this._element.style.right = this.x ? `${this.x * renderService.dimensions.actualCellWidth}px` : ''; - } else { - this._element.style.left = this.x ? `${this.x * renderService.dimensions.actualCellWidth}px` : ''; - } - } - - private _refreshStyle(renderService: IRenderService): void { - if (!this._element) { - return; - } - const line = this.marker.line - this._bufferService.buffers.active.ydisp; - if (line < 0 || line > this._bufferService.rows) { - // outside of viewport - this._element.style.display = 'none'; - } else { - this._element.style.top = `${line * renderService.dimensions.actualCellHeight}px`; - this._element.style.display = this._bufferService.buffer === this._bufferService.buffers.alt ? 'none' : 'block'; - } - } - - public override dispose(): void { - if (this.isDisposed) { - return; - } - if (this._element && this._container.contains(this._element)) { - this._container.removeChild(this._element); - } - this.isDisposed = true; - this._onDispose.fire(); - } -} diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index da458abc..91b510a3 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -39,8 +39,10 @@ export class RenderService extends Disposable implements IRenderService { private _onDimensionsChange = new EventEmitter(); public get onDimensionsChange(): IEvent { return this._onDimensionsChange.event; } + private _onRenderedBufferChange = new EventEmitter<{ start: number, end: number }>(); + public get onRenderedBufferChange(): IEvent<{ start: number, end: number }> { return this._onRenderedBufferChange.event; } private _onRender = new EventEmitter<{ start: number, end: number }>(); - public get onRenderedBufferChange(): IEvent<{ start: number, end: number }> { return this._onRender.event; } + public get onRender(): IEvent<{ start: number, end: number }> { return this._onRender.event; } private _onRefreshRequest = new EventEmitter<{ start: number, end: number }>(); public get onRefreshRequest(): IEvent<{ start: number, end: number }> { return this._onRefreshRequest.event; } @@ -122,8 +124,9 @@ export class RenderService extends Disposable implements IRenderService { // Fire render event only if it was not a redraw if (!this._isNextRenderRedrawOnly) { - this._onRender.fire({ start, end }); + this._onRenderedBufferChange.fire({ start, end }); } + this._onRender.fire({ start, end }); this._isNextRenderRedrawOnly = true; } diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts index 7faf3f0f..7191d0ed 100644 --- a/src/browser/services/Services.ts +++ b/src/browser/services/Services.ts @@ -50,6 +50,10 @@ export interface IRenderService extends IDisposable { * or selections are rendered. */ onRenderedBufferChange: IEvent<{ start: number, end: number }>; + /** + * Fires on render + */ + onRender: IEvent<{ start: number, end: number }>; onRefreshRequest: IEvent<{ start: number, end: number }>; dimensions: IRenderDimensions; @@ -115,11 +119,3 @@ export interface ICharacterJoinerService { deregister(joinerId: number): boolean; getJoinedCharacters(row: number): [number, number][]; } - - -export const IDecorationService = createDecorator('DecorationService'); -export interface IDecorationService extends IDisposable { - registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; - refresh(): void; - attachToDom(screenElement: HTMLElement, renderService: IRenderService, bufferService: IBufferService): void; -} diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 7596fef3..ab295784 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -585,20 +585,32 @@ export class Buffer implements IBuffer { return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x; } - public clearMarkers(y?: number): void { + /** + * Clears markers on single line. + * @param y The line to clear. + */ + public clearMarkers(y: number): void { this._isClearing = true; - if (y !== undefined) { - for (let i = 0; i < this.markers.length; i++) { - if (this.markers[i].line === y) { - this.markers[i].dispose(); - this.markers.splice(i--, 1); - } + for (let i = 0; i < this.markers.length; i++) { + if (this.markers[i].line === y) { + this.markers[i].dispose(); + this.markers.splice(i--, 1); } - } else { - for (const marker of this.markers) { - marker.dispose(); + } + this._isClearing = false; + } + + /** + * Clears markers on all lines except for those on a particular line. + * @param excludeY The line to exclude. + */ + public clearAllMarkers(excludeY: number): void { + this._isClearing = true; + for (let i = 0; i < this.markers.length; i++) { + if (this.markers[i].line !== excludeY) { + this.markers[i].dispose(); + this.markers.splice(i--, 1); } - this.markers = []; } this._isClearing = false; } diff --git a/src/common/buffer/Types.d.ts b/src/common/buffer/Types.d.ts index 36b70b7f..f26b4b26 100644 --- a/src/common/buffer/Types.d.ts +++ b/src/common/buffer/Types.d.ts @@ -45,7 +45,8 @@ export interface IBuffer { getNullCell(attr?: IAttributeData): ICellData; getWhitespaceCell(attr?: IAttributeData): ICellData; addMarker(y: number): IMarker; - clearMarkers(y?: number): void; + clearMarkers(y: number): void; + clearAllMarkers(excludeY: number): void; } export interface IBufferSet extends IDisposable { diff --git a/src/common/data/EscapeSequences.ts b/src/common/data/EscapeSequences.ts index e35f01dd..0e034620 100644 --- a/src/common/data/EscapeSequences.ts +++ b/src/common/data/EscapeSequences.ts @@ -148,3 +148,6 @@ export namespace C1 { /** Application Program Command */ export const APC = '\x9f'; } +export namespace C1_ESCAPED { + export const ST = `${C0.ESC}\\`; +} diff --git a/src/common/input/XParseColor.ts b/src/common/input/XParseColor.ts index 8c023a38..fd23ec4b 100644 --- a/src/common/input/XParseColor.ts +++ b/src/common/input/XParseColor.ts @@ -5,7 +5,7 @@ // 'rgb:' rule - matching: r/g/b | rr/gg/bb | rrr/ggg/bbb | rrrr/gggg/bbbb (hex digits) -const RGB_REX = /^([\da-f]{1})\/([\da-f]{1})\/([\da-f]{1})$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/; +const RGB_REX = /^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/; // '#...' rule - matching any hex digits const HASH_REX = /^[\da-f]+$/; diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts new file mode 100644 index 00000000..fba5fc35 --- /dev/null +++ b/src/common/services/DecorationService.ts @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { EventEmitter } from 'common/EventEmitter'; +import { Disposable } from 'common/Lifecycle'; +import { IDecorationService, IInternalDecoration } from 'common/services/Services'; +import { IDecorationOptions, IDecoration, IMarker, IEvent } from 'xterm'; + +export class DecorationService extends Disposable implements IDecorationService { + public serviceBrand: any; + + private readonly _decorations: IInternalDecoration[] = []; + + private _onDecorationRegistered = this.register(new EventEmitter()); + public get onDecorationRegistered(): IEvent { return this._onDecorationRegistered.event; } + private _onDecorationRemoved = this.register(new EventEmitter()); + public get onDecorationRemoved(): IEvent { return this._onDecorationRemoved.event; } + + public get decorations(): IterableIterator { return this._decorations.values(); } + + constructor() { + super(); + } + + public registerDecoration(options: IDecorationOptions): IDecoration | undefined { + if (options.marker.isDisposed) { + return undefined; + } + const decoration = new Decoration(options); + if (decoration) { + decoration.onDispose(() => { + if (decoration) { + const index = this._decorations.indexOf(decoration); + if (index >= 0) { + this._decorations.splice(this._decorations.indexOf(decoration), 1); + } + } + }); + this._decorations.push(decoration); + this._onDecorationRegistered.fire(decoration); + } + return decoration; + } + + public dispose(): void { + for (const decoration of this._decorations) { + this._onDecorationRemoved.fire(decoration); + decoration.dispose(); + } + this._decorations.length = 0; + } +} + +class Decoration extends Disposable implements IInternalDecoration { + public readonly marker: IMarker; + public element: HTMLElement | undefined; + public isDisposed: boolean = false; + + public readonly onRenderEmitter = this.register(new EventEmitter()); + public readonly onRender = this.onRenderEmitter.event; + private _onDispose = this.register(new EventEmitter()); + public readonly onDispose = this._onDispose.event; + + constructor( + public readonly options: IDecorationOptions + ) { + super(); + this.marker = options.marker; + if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) { + this.options.overviewRulerOptions.position = 'full'; + } + } + public override dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + this._onDispose.fire(); + super.dispose(); + } +} diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 43fe9981..4008a431 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -52,7 +52,8 @@ export const DEFAULT_OPTIONS: Readonly = { altClickMovesCursor: true, convertEol: false, termName: 'xterm', - cancelEvents: false + cancelEvents: false, + overviewRulerWidth: undefined }; const FONT_WEIGHT_OPTIONS: Extract[] = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900']; diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 90dca988..876d90bc 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -3,10 +3,11 @@ * @license MIT */ -import { IEvent } from 'common/EventEmitter'; +import { IEvent, IEventEmitter } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; +import { IDecorationOptions, IDecoration } from 'xterm'; export const IBufferService = createDecorator('BufferService'); export interface IBufferService { @@ -245,6 +246,7 @@ export interface ITerminalOptions { windowsMode: boolean; windowOptions: IWindowOptions; wordSeparator: string; + overviewRulerWidth?: number; [key: string]: any; cancelEvents: boolean; @@ -298,3 +300,16 @@ export interface IUnicodeVersionProvider { readonly version: string; wcwidth(ucs: number): 0 | 1 | 2; } + +export const IDecorationService = createDecorator('DecorationService'); +export interface IDecorationService extends IDisposable { + serviceBrand: undefined; + readonly decorations: IterableIterator; + readonly onDecorationRegistered: IEvent; + readonly onDecorationRemoved: IEvent; + registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined; +} +export interface IInternalDecoration extends IDecoration { + readonly options: IDecorationOptions; + readonly onRenderEmitter: IEventEmitter; +} diff --git a/src/tsconfig-library-base.json b/src/tsconfig-library-base.json index e08695d7..00cecc06 100644 --- a/src/tsconfig-library-base.json +++ b/src/tsconfig-library-base.json @@ -4,6 +4,7 @@ "composite": true, "strict": true, "declarationMap": true, - "experimentalDecorators": true + "experimentalDecorators": true, + "downlevelIteration": true } } diff --git a/test/api/InputHandler.api.ts b/test/api/InputHandler.api.ts index 6e0bf88b..c8cbbf66 100644 --- a/test/api/InputHandler.api.ts +++ b/test/api/InputHandler.api.ts @@ -399,13 +399,13 @@ describe('InputHandler Integration Tests', function(): void { }); it('query single color', async () => { await writeSync(page, '\x1b]4;0;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x1b\\']); await writeSync(page, '\x1b]4;77;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x07', '\x1b]4;77;rgb:5f5f/d7d7/5f5f\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x1b\\', '\x1b]4;77;rgb:5f5f/d7d7/5f5f\x1b\\']); }); it('query multiple colors', async () => { await writeSync(page, '\x1b]4;0;?;77;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x07', '\x1b]4;77;rgb:5f5f/d7d7/5f5f\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:2e2e/3434/3636\x1b\\', '\x1b]4;77;rgb:5f5f/d7d7/5f5f\x1b\\']); }); it('set & query single color', async () => { await writeSync(page, '\x1b]4;0;?\x07'); @@ -413,10 +413,10 @@ describe('InputHandler Integration Tests', function(): void { assert.deepEqual(await page.evaluate('window._recordedData'), restore); // set new color & query await writeSync(page, '\x1b]4;0;rgb:01/02/03\x07\x1b]4;0;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], '\x1b]4;0;rgb:0101/0202/0303\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], '\x1b]4;0;rgb:0101/0202/0303\x1b\\']); // restore should set old color await writeSync(page, restore[0] + '\x1b]4;0;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], '\x1b]4;0;rgb:0101/0202/0303\x07', restore[0]]); + assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], '\x1b]4;0;rgb:0101/0202/0303\x1b\\', restore[0]]); }); it('query & set colors mixed', async () => { await writeSync(page, '\x1b]4;0;?;77;?\x07'); @@ -424,11 +424,11 @@ describe('InputHandler Integration Tests', function(): void { await page.evaluate('window._recordedData.length = 0;'); // mixed call - change 0, query 43, change 77 await writeSync(page, '\x1b]4;0;rgb:01/02/03;43;?;77;#aabbcc\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;43;rgb:0000/d7d7/afaf\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;43;rgb:0000/d7d7/afaf\x1b\\']); await page.evaluate('window._recordedData.length = 0;'); // query new values for 0 + 77 await writeSync(page, '\x1b]4;0;?;77;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:0101/0202/0303\x07', '\x1b]4;77;rgb:aaaa/bbbb/cccc\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]4;0;rgb:0101/0202/0303\x1b\\', '\x1b]4;77;rgb:aaaa/bbbb/cccc\x1b\\']); await page.evaluate('window._recordedData.length = 0;'); // restore old values for 0 + 77 await writeSync(page, restore[0] + restore[1] + '\x1b]4;0;?;77;?\x07'); @@ -451,10 +451,10 @@ describe('InputHandler Integration Tests', function(): void { await writeSync(page, `\x1b]4;${i};?\x07`); const restore: string[] = await page.evaluate('window._recordedData'); await writeSync(page, `\x1b]4;${i};rgb:01/02/03\x07\x1b]4;${i};?\x07`); - assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], `\x1b]4;${i};rgb:0101/0202/0303\x07`]); + assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], `\x1b]4;${i};rgb:0101/0202/0303\x1b\\`]); // restore slot color await writeSync(page, `\x1b]104;${i}\x07\x1b]4;${i};?\x07`); - assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], `\x1b]4;${i};rgb:0101/0202/0303\x07`, restore[0]]); + assert.deepEqual(await page.evaluate('window._recordedData'), [restore[0], `\x1b]4;${i};rgb:0101/0202/0303\x1b\\`, restore[0]]); await page.evaluate('window._recordedData.length = 0;'); } }); @@ -491,62 +491,62 @@ describe('InputHandler Integration Tests', function(): void { }); it('query FG color', async () => { await writeSync(page, '\x1b]10;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x1b\\']); }); it('query BG color', async () => { await writeSync(page, '\x1b]11;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:0000/0000/0000\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:0000/0000/0000\x1b\\']); }); it('query FG & BG color in one call', async () => { await writeSync(page, '\x1b]10;?;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x07', '\x1b]11;rgb:0000/0000/0000\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x1b\\', '\x1b]11;rgb:0000/0000/0000\x1b\\']); }); it('set & query FG', async () => { await writeSync(page, '\x1b]10;rgb:1/2/3\x07\x1b]10;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x1b\\']); await writeSync(page, '\x1b]10;#ffffff\x07\x1b]10;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x07', '\x1b]10;rgb:ffff/ffff/ffff\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x1b\\', '\x1b]10;rgb:ffff/ffff/ffff\x1b\\']); }); it('set & query BG', async () => { await writeSync(page, '\x1b]11;rgb:1/2/3\x07\x1b]11;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x1b\\']); await writeSync(page, '\x1b]11;#000000\x07\x1b]11;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x07', '\x1b]11;rgb:0000/0000/0000\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x1b\\', '\x1b]11;rgb:0000/0000/0000\x1b\\']); }); it('set & query cursor color', async () => { await writeSync(page, '\x1b]12;rgb:1/2/3\x07\x1b]12;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x1b\\']); await writeSync(page, '\x1b]12;#ffffff\x07\x1b]12;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x07', '\x1b]12;rgb:ffff/ffff/ffff\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x1b\\', '\x1b]12;rgb:ffff/ffff/ffff\x1b\\']); }); it('set & query FG & BG color in one call', async () => { await writeSync(page, '\x1b]10;#123456;rgb:aa/bb/cc\x07\x1b]10;?;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1212/3434/5656\x07', '\x1b]11;rgb:aaaa/bbbb/cccc\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1212/3434/5656\x1b\\', '\x1b]11;rgb:aaaa/bbbb/cccc\x1b\\']); await writeSync(page, '\x1b]10;#ffffff;#000000\x07'); }); it('OSC 110: restore FG color', async () => { await writeSync(page, '\x1b]10;rgb:1/2/3\x07\x1b]10;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:1111/2222/3333\x1b\\']); await page.evaluate('window._recordedData.length = 0;'); // restore await writeSync(page, '\x1b]110\x07\x1b]10;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]10;rgb:ffff/ffff/ffff\x1b\\']); }); it('OSC 111: restore BG color', async () => { await writeSync(page, '\x1b]11;rgb:1/2/3\x07\x1b]11;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:1111/2222/3333\x1b\\']); await page.evaluate('window._recordedData.length = 0;'); // restore await writeSync(page, '\x1b]111\x07\x1b]11;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:0000/0000/0000\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]11;rgb:0000/0000/0000\x1b\\']); }); it('OSC 112: restore cursor color', async () => { await writeSync(page, '\x1b]12;rgb:1/2/3\x07\x1b]12;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:1111/2222/3333\x1b\\']); await page.evaluate('window._recordedData.length = 0;'); // restore await writeSync(page, '\x1b]112\x07\x1b]12;?\x07'); - assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:ffff/ffff/ffff\x07']); + assert.deepEqual(await page.evaluate('window._recordedData'), ['\x1b]12;rgb:ffff/ffff/ffff\x1b\\']); }); }); }); diff --git a/test/api/Terminal.api.ts b/test/api/Terminal.api.ts index ee6a0cfa..b1582be8 100644 --- a/test/api/Terminal.api.ts +++ b/test/api/Terminal.api.ts @@ -574,7 +574,7 @@ describe('API Integration Tests', function(): void { await page.evaluate(`window.term.scrollLines(10)`); await page.evaluate(`window.term.addMarker(3)`); await page.evaluate(`window.term.addMarker(4)`); - await page.evaluate(` + await page.evaluate(` for (let i = 0; i < window.term.markers.length; ++i) { const marker = window.term.markers[i]; marker.onDispose(() => window.disposeStack.push(marker)); @@ -732,48 +732,60 @@ describe('API Integration Tests', function(): void { }); describe('registerDecoration', () => { - it('should register decorations and render them', async () => { - await openTerminal(page); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await page.evaluate(`window.marker1 = window.term.addMarker(1)`); - await page.evaluate(`window.marker2 = window.term.addMarker(2)`); - await page.evaluate(`window.term.registerDecoration({ marker: window.marker1 })`); - await page.evaluate(`window.term.registerDecoration({ marker: window.marker2 })`); - await page.evaluate(`window.term.resize(10, 5)`); - assert.equal(await page.evaluate(`document.querySelectorAll('.xterm-screen .xterm-decoration').length`), 2); + describe('bufferDecorations', () => { + it('should register decorations and render them when terminal open is called', async () => { + await page.evaluate(`window.term = new Terminal({})`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + await page.waitForSelector('.xterm-text-layer'); + await page.evaluate(`window.marker1 = window.term.addMarker(1)`); + await page.evaluate(`window.marker2 = window.term.addMarker(2)`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker1 })`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker2 })`); + await openTerminal(page); + await pollFor(page, `document.querySelectorAll('.xterm-screen .xterm-decoration').length`, 2); + }); + it('should return undefined when the marker has already been disposed of', async () => { + await openTerminal(page); + await page.evaluate(`window.marker = window.term.addMarker(1)`); + await page.evaluate(`window.marker.dispose()`); + await pollFor(page, `window.decoration = window.term.registerDecoration({ marker: window.marker });`, undefined); + }); + it('should throw when a negative x offset is provided', async () => { + await openTerminal(page); + await page.evaluate(`window.marker = window.term.addMarker(1)`); + await page.evaluate(` + try { + window.decoration = window.term.registerDecoration({ marker: window.marker, x: -2 }); + } catch (e) { + window.throwMessage = e.message; + } + `); + await pollFor(page, 'window.throwMessage', 'This API only accepts positive integers'); + }); }); - it('on resize should dispose of the old decoration and create a new one', async () => { - await openTerminal(page); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await page.evaluate(`window.marker = window.term.addMarker(1)`); - await page.evaluate(`window.decoration = window.term.registerDecoration({ marker: window.marker })`); - await page.evaluate(`window.term.resize(10, 5)`); - assert.equal(await page.evaluate(`document.querySelectorAll('.xterm-screen .xterm-decoration').length`), 1); - }); - it('should return undefined when the marker has already been disposed of', async () => { - await openTerminal(page); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await page.evaluate(`window.marker = window.term.addMarker(1)`); - await page.evaluate(`window.marker.dispose()`); - assert.equal(await page.evaluate(`window.decoration = window.term.registerDecoration({ marker: window.marker });`), undefined); - }); - it('should throw when a negative x offset is provided', async () => { - await openTerminal(page); - await writeSync(page, '\\n\\n\\n\\n'); - await writeSync(page, '\\n\\n\\n\\n'); - await page.evaluate(`window.marker = window.term.addMarker(1)`); - await page.evaluate(` - try { - window.decoration = window.term.registerDecoration({ marker: window.marker, x: -2 }); - } catch (e) { - window.throwMessage = e.message; - } - `); - await pollFor(page, 'window.throwMessage', 'This API only accepts positive integers'); + describe('overviewRulerDecorations', () => { + it('should not add an overview ruler when width is not set', async () => { + await page.evaluate(`window.term = new Terminal({})`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + await page.waitForSelector('.xterm-text-layer'); + await page.evaluate(`window.marker1 = window.term.addMarker(1)`); + await page.evaluate(`window.marker2 = window.term.addMarker(2)`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker1, overviewRulerOptions: { color: 'red', position: 'full' } })`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker2, overviewRulerOptions: { color: 'blue', position: 'full' } })`); + await openTerminal(page); + await pollFor(page, `document.querySelectorAll('.xterm-decoration-overview-ruler').length`, 0); + }); + it('should add an overview ruler when width is set', async () => { + await page.evaluate(`window.term = new Terminal({ overviewRulerWidth: 15 })`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + await page.waitForSelector('.xterm-text-layer'); + await page.evaluate(`window.marker1 = window.term.addMarker(1)`); + await page.evaluate(`window.marker2 = window.term.addMarker(2)`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker1, overviewRulerOptions: { color: 'red', position: 'full' } })`); + await page.evaluate(`window.term.registerDecoration({ marker: window.marker2, overviewRulerOptions: { color: 'blue', position: 'full' } })`); + await openTerminal(page); + await pollFor(page, `document.querySelectorAll('.xterm-decoration-overview-ruler').length`, 1); + }); }); }); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 2cd4daa6..d1eb3890 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -266,6 +266,12 @@ declare module 'xterm' { * All features are disabled by default for security reasons. */ windowOptions?: IWindowOptions; + + /** + * The width, in pixels, of the canvas for the overview ruler. The overview + * ruler will be hidden when not set. + */ + overviewRulerWidth?: number; } /** @@ -394,7 +400,7 @@ declare module 'xterm' { } /** - * Represents a disposable with an + * Represents a disposable that tracks is disposed state. * @param onDispose event listener and * @param isDisposed property. */ @@ -427,49 +433,69 @@ declare module 'xterm' { readonly onRender: IEvent; /** - * The HTMLElement that gets created after the - * first _onRender call, or undefined if accessed before + * The element that the decoration is rendered to. This will be undefined + * until it is rendered for the first time by {@link IDecoration.onRender}. * that. */ - readonly element: HTMLElement | undefined; + element: HTMLElement | undefined; + + /** + * The options for the overview ruler that can be updated. + * This will only take effect when {@link IDecorationOptions.overviewRulerOptions} + * were provided initially. + */ + overviewRulerOptions?: Pick; } + /** - * Options provided when registering a decoration - * containing a @param marker, @param anchor, - * @param x offset from the anchor, @param width in cells - * and @param height in cells. + * Overview ruler decoration options + */ + interface IDecorationOverviewRulerOptions { + color: string; + position?: 'left' | 'center' | 'right' | 'full'; + } + + /* + * Options that define the presentation of the decoration. */ export interface IDecorationOptions { /** * The line in the terminal where * the decoration will be displayed */ - marker: IMarker; + readonly marker: IMarker; /* * Where the decoration will be anchored - * defaults to the left edge */ - anchor?: 'right' | 'left'; + readonly anchor?: 'right' | 'left'; /** * The x position offset relative to the anchor - */ - x?: number; + */ + readonly x?: number; /** - * The width of the decoration in cells, which defaults to - * cell width + * The width of the decoration in cells, defaults to 1. */ - width?: number; + readonly width?: number; /** - * The height of the decoration in cells, which defaults to - * cell height + * The height of the decoration in cells, defaults to 1. */ - height?: number; + readonly height?: number; + + /** + * When defined, renders the decoration in the overview ruler to the right + * of the terminal. {@link ITerminalOptions.overviewRulerWidth} must be set + * in order to see the overview ruler. + * @param color The color of the decoration. + * @param position The position of the decoration. + */ + overviewRulerOptions?: IDecorationOverviewRulerOptions } /** @@ -939,7 +965,7 @@ declare module 'xterm' { /** * (EXPERIMENTAL) Adds a decoration to the terminal using - * @param decorationOptions, which takes a marker and an optional anchor, + * @param decorationOptions, which takes a marker and an optional anchor, * width, height, and x offset from the anchor. Returns the decoration or * undefined if the alt buffer is active or the marker has already been disposed of. * @throws when options include a negative x offset. diff --git a/yarn.lock b/yarn.lock index c85bca27..1177f49a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2948,9 +2948,9 @@ minimatch@3.0.4, minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp@^0.5.3: version "0.5.5"