diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 684f881c..00000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM node:14-buster - -# Configure apt -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update \ - && apt-get -y install --no-install-recommends apt-utils 2>&1 - -# Verify git and process tools are installed -RUN apt-get install -y git procps - -# Install yarn -RUN apt-get install -y curl apt-transport-https lsb-release \ - && curl -sS https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/pubkey.gpg | apt-key add - 2>/dev/null \ - && echo "deb https://dl.yarnpkg.com/$(lsb_release -is | tr '[:upper:]' '[:lower:]')/ stable main" | tee /etc/apt/sources.list.d/yarn.list \ - && apt-get update \ - && apt-get -y install --no-install-recommends \ - yarn - -# Clean up -RUN apt-get autoremove -y \ - && apt-get clean -y \ - && rm -rf /var/lib/apt/lists/* -ENV DEBIAN_FRONTEND=dialog diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9aec3a02..c09a29bf 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,10 +1,20 @@ { "name": "xterm.js", - "dockerFile": "Dockerfile", - "appPort": 3000, - "extensions": [ - "dbaeumer.vscode-eslint", - "editorconfig.editorconfig", - "hbenl.vscode-mocha-test-adapter" - ] + "image": "mcr.microsoft.com/devcontainers/typescript-node:0-18-buster", + "features": { + "ghcr.io/devcontainers/features/node:1": {} // yarn + }, + "forwardPorts": [ + 3000 + ], + "postCreateCommand": "yarn install", + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "editorconfig.editorconfig", + "hbenl.vscode-mocha-test-adapter" + ] + } + } } diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 59beeeb9..5ec6e6c7 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: FAQ + url: https://github.com/xtermjs/xterm.js/wiki/FAQ + about: See our frequently asked questions before filing a bug report - name: Support / Q&A url: https://github.com/xtermjs/xterm.js/discussions/categories/q-a about: Use GitHub Discussions for community support and general Q&A diff --git a/addons/xterm-addon-canvas/src/BaseRenderLayer.ts b/addons/xterm-addon-canvas/src/BaseRenderLayer.ts index 88d929f1..1d1936e7 100644 --- a/addons/xterm-addon-canvas/src/BaseRenderLayer.ts +++ b/addons/xterm-addon-canvas/src/BaseRenderLayer.ts @@ -379,7 +379,17 @@ export abstract class BaseRenderLayer extends Disposable implements IRenderLayer } this._ctx.save(); this._clipRow(y); + // Draw the image, use the bitmap if it's available + + // HACK: If the canvas doesn't match, delete the generator. It's not clear how this happens but + // something is wrong with either the lifecycle of _bitmapGenerator or the page canvases are + // swapped out unexpectedly + if (this._bitmapGenerator[glyph.texturePage] && this._charAtlas.pages[glyph.texturePage].canvas !== this._bitmapGenerator[glyph.texturePage]!.canvas) { + this._bitmapGenerator[glyph.texturePage]?.bitmap?.close(); + delete this._bitmapGenerator[glyph.texturePage]; + } + if (this._charAtlas.pages[glyph.texturePage].version !== this._bitmapGenerator[glyph.texturePage]?.version) { if (!this._bitmapGenerator[glyph.texturePage]) { this._bitmapGenerator[glyph.texturePage] = new BitmapGenerator(this._charAtlas.pages[glyph.texturePage].canvas); @@ -446,11 +456,12 @@ class BitmapGenerator { public get bitmap(): ImageBitmap | undefined { return this._bitmap; } public version: number = -1; - constructor(private readonly _canvas: HTMLCanvasElement) { + constructor(public readonly canvas: HTMLCanvasElement) { } public refresh(): void { // Clear the bitmap immediately as it's stale + this._bitmap?.close(); this._bitmap = undefined; // Disable ImageBitmaps on Safari because of https://bugs.webkit.org/show_bug.cgi?id=149990 if (isSafari) { @@ -466,9 +477,10 @@ class BitmapGenerator { private _generate(): void { if (this._state === BitmapGeneratorState.IDLE) { + this._bitmap?.close(); this._bitmap = undefined; this._state = BitmapGeneratorState.GENERATING; - window.createImageBitmap(this._canvas).then(bitmap => { + window.createImageBitmap(this.canvas).then(bitmap => { if (this._state === BitmapGeneratorState.GENERATING_INVALID) { this.refresh(); } else { diff --git a/addons/xterm-addon-canvas/src/CanvasAddon.ts b/addons/xterm-addon-canvas/src/CanvasAddon.ts index b96ce21d..d6136174 100644 --- a/addons/xterm-addon-canvas/src/CanvasAddon.ts +++ b/addons/xterm-addon-canvas/src/CanvasAddon.ts @@ -4,7 +4,7 @@ */ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; -import { IColorSet, ITerminal } from 'browser/Types'; +import { ITerminal } from 'browser/Types'; import { CanvasRenderer } from './CanvasRenderer'; import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { ITerminalAddon, Terminal } from 'xterm'; diff --git a/addons/xterm-addon-canvas/src/CursorRenderLayer.ts b/addons/xterm-addon-canvas/src/CursorRenderLayer.ts index cd05b36e..7efcd74e 100644 --- a/addons/xterm-addon-canvas/src/CursorRenderLayer.ts +++ b/addons/xterm-addon-canvas/src/CursorRenderLayer.ts @@ -13,6 +13,7 @@ import { IEventEmitter } from 'common/EventEmitter'; import { ICoreBrowserService, IThemeService } from 'browser/services/Services'; import { Terminal } from 'xterm'; import { toDisposable } from 'common/Lifecycle'; +import { isFirefox } from 'common/Platform'; interface ICursorState { x: number; @@ -190,8 +191,9 @@ export class CursorRenderLayer extends BaseRenderLayer { private _clearCursor(): void { if (this._state) { - // Avoid potential rounding errors when device pixel ratio is less than 1 - if (this._coreBrowserService.dpr < 1) { + // Avoid potential rounding errors when browser is Firefox (#4487) or device pixel ratio is + // less than 1 + if (isFirefox || this._coreBrowserService.dpr < 1) { this._clearAll(); } else { this._clearCells(this._state.x, this._state.y, this._state.width, 1); diff --git a/addons/xterm-addon-canvas/src/TextRenderLayer.ts b/addons/xterm-addon-canvas/src/TextRenderLayer.ts index 4ee7c548..0066cc7d 100644 --- a/addons/xterm-addon-canvas/src/TextRenderLayer.ts +++ b/addons/xterm-addon-canvas/src/TextRenderLayer.ts @@ -188,12 +188,6 @@ export class TextRenderLayer extends BaseRenderLayer { nextFillStyle = this._themeService.colors.ansi[cell.getBgColor()].css; } - // Apply dim to the background, this is relatively slow as the CSS is re-parsed but dim is - // rarely used - if (nextFillStyle && cell.isDim()) { - nextFillStyle = color.multiplyOpacity(css.toColor(nextFillStyle), 0.5).css; - } - // Get any decoration foreground/background overrides, this must be fetched before the early // exist but applied after inverse let isTop = false; diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index 3f71af29..a029fc24 100644 --- a/addons/xterm-addon-search/src/SearchAddon.ts +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -3,9 +3,9 @@ * @license MIT */ -import { Terminal, IDisposable, ITerminalAddon, IBufferRange, IDecoration } from 'xterm'; +import { Terminal, IDisposable, ITerminalAddon, IDecoration } from 'xterm'; import { EventEmitter } from 'common/EventEmitter'; -import { Disposable, toDisposable } from 'common/Lifecycle'; +import { Disposable, toDisposable, disposeArray } from 'common/Lifecycle'; export interface ISearchOptions { regex?: boolean; @@ -30,6 +30,10 @@ export interface ISearchPosition { startRow: number; } +export interface ISearchAddonOptions { + highlightLimit: number; +} + export interface ISearchResult { term: string; col: number; @@ -48,15 +52,22 @@ type LineCacheEntry = [ lineOffsets: number[] ]; +interface IHighlight extends IDisposable { + decoration: IDecoration; + match: ISearchResult; +} + const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?'; const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs +const DEFAULT_HIGHLIGHT_LIMIT = 1000; export class SearchAddon extends Disposable implements ITerminalAddon { private _terminal: Terminal | undefined; private _cachedSearchTerm: string | undefined; - private _selectedDecoration: IDecoration | undefined; - private _resultDecorations: Map | undefined; - private _searchResults: Map | undefined; + private _highlightedLines: Set = new Set(); + private _highlightDecorations: IHighlight[] = []; + private _selectedDecoration: IHighlight | undefined; + private _highlightLimit: number; private _onDataDisposable: IDisposable | undefined; private _onResizeDisposable: IDisposable | undefined; private _lastSearchOptions: ISearchOptions | undefined; @@ -71,11 +82,15 @@ export class SearchAddon extends Disposable implements ITerminalAddon { private _cursorMoveListener: IDisposable | undefined; private _resizeListener: IDisposable | undefined; - private _resultIndex: number | undefined; - - private readonly _onDidChangeResults = this.register(new EventEmitter<{ resultIndex: number, resultCount: number } | undefined>()); + private readonly _onDidChangeResults = this.register(new EventEmitter<{ resultIndex: number, resultCount: number }>()); public readonly onDidChangeResults = this._onDidChangeResults.event; + constructor(options?: Partial) { + super(); + + this._highlightLimit = options?.highlightLimit ?? DEFAULT_HIGHLIGHT_LIMIT; + } + public activate(terminal: Terminal): void { this._terminal = terminal; this._onDataDisposable = this.register(this._terminal.onWriteParsed(() => this._updateMatches())); @@ -93,24 +108,18 @@ export class SearchAddon extends Disposable implements ITerminalAddon { } if (this._cachedSearchTerm && this._lastSearchOptions?.decorations) { this._highlightTimeout = setTimeout(() => { - this.findPrevious(this._cachedSearchTerm!, { ...this._lastSearchOptions, incremental: true, noScroll: true }); - this._resultIndex = this._searchResults ? this._searchResults.size - 1 : -1; - this._onDidChangeResults.fire({ resultIndex: this._resultIndex, resultCount: this._searchResults?.size ?? -1 }); + const term = this._cachedSearchTerm; + this._cachedSearchTerm = undefined; + this.findPrevious(term!, { ...this._lastSearchOptions, incremental: true, noScroll: true }); }, 200); } } public clearDecorations(retainCachedSearchTerm?: boolean): void { - this._selectedDecoration?.dispose(); - this._searchResults?.clear(); - this._resultDecorations?.forEach(decorations => { - for (const d of decorations) { - d.dispose(); - } - }); - this._resultDecorations?.clear(); - this._searchResults = undefined; - this._resultDecorations = undefined; + this.clearActiveDecoration(); + disposeArray(this._highlightDecorations); + this._highlightDecorations = []; + this._highlightedLines.clear(); if (!retainCachedSearchTerm) { this._cachedSearchTerm = undefined; } @@ -134,11 +143,16 @@ export class SearchAddon extends Disposable implements ITerminalAddon { } this._lastSearchOptions = searchOptions; if (searchOptions?.decorations) { - if (this._resultIndex !== undefined || this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm) { + if (this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm) { this._highlightAllMatches(term, searchOptions); } } - return this._fireResults(term, this._findNextAndSelect(term, searchOptions), searchOptions); + + const found = this._findNextAndSelect(term, searchOptions); + this._fireResults(searchOptions); + this._cachedSearchTerm = term; + + return found; } private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void { @@ -153,32 +167,30 @@ export class SearchAddon extends Disposable implements ITerminalAddon { // new search, clear out the old decorations this.clearDecorations(true); - this._searchResults = new Map(); - this._resultDecorations = new Map(); - const resultDecorations = this._resultDecorations; + + const searchResultsWithHighlight: ISearchResult[] = []; + let prevResult: ISearchResult | undefined = undefined; 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); + while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) { + if (searchResultsWithHighlight.length >= this._highlightLimit) { + break; + } + prevResult = result; + searchResultsWithHighlight.push(prevResult); 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, + prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row, + prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1, searchOptions ); - if (this._searchResults.size > 1000) { - this.clearDecorations(); - this._resultIndex = undefined; - return; + } + for (const match of searchResultsWithHighlight) { + const decoration = this._createResultDecoration(match, searchOptions.decorations!); + if (decoration) { + this._highlightedLines.add(decoration.marker.line); + this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } }); } } - 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); - } - }); } private _find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined { @@ -223,26 +235,22 @@ export class SearchAddon extends Disposable implements ITerminalAddon { if (!this._terminal || !term || term.length === 0) { this._terminal?.clearSelection(); this.clearDecorations(); - this._cachedSearchTerm = undefined; - this._resultIndex = -1; return false; } - if (this._cachedSearchTerm !== term) { - this._resultIndex = undefined; - this._terminal.clearSelection(); - } + const prevSelectedPos = this._terminal.getSelectionPosition(); + this._terminal.clearSelection(); let startCol = 0; let startRow = 0; - let currentSelection: IBufferRange | undefined; - if (this._terminal.hasSelection()) { - const incremental = searchOptions ? searchOptions.incremental : false; - // Start from the selection end if there is a selection - // For incremental search, use existing row - currentSelection = this._terminal.getSelectionPosition()!; - startRow = incremental ? currentSelection.start.y : currentSelection.end.y; - startCol = incremental ? currentSelection.start.x : currentSelection.end.x; + if (prevSelectedPos) { + if (this._cachedSearchTerm === term) { + startCol = prevSelectedPos.end.x; + startRow = prevSelectedPos.end.y; + } else { + startCol = prevSelectedPos.start.x; + startRow = prevSelectedPos.start.y; + } } this._initLinesCache(); @@ -281,24 +289,12 @@ export class SearchAddon extends Disposable implements ITerminalAddon { } // If there is only one result, wrap back and return selection if it exists. - if (!result && currentSelection) { - searchPosition.startRow = currentSelection.start.y; + if (!result && prevSelectedPos) { + searchPosition.startRow = prevSelectedPos.start.y; searchPosition.startCol = 0; result = this._findInLine(term, searchPosition, searchOptions); } - if (this._searchResults) { - if (this._searchResults.size === 0) { - this._resultIndex = -1; - } else 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, searchOptions?.noScroll); } @@ -315,75 +311,74 @@ export class SearchAddon extends Disposable implements ITerminalAddon { } this._lastSearchOptions = searchOptions; if (searchOptions?.decorations) { - if (this._resultIndex !== undefined || this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm) { + if (this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm) { this._highlightAllMatches(term, searchOptions); } } - return this._fireResults(term, this._findPreviousAndSelect(term, searchOptions), searchOptions); + + const found = this._findPreviousAndSelect(term, searchOptions); + this._fireResults(searchOptions); + this._cachedSearchTerm = term; + + return found; } - private _fireResults(term: string, found: boolean, searchOptions?: ISearchOptions): boolean { + private _fireResults(searchOptions?: ISearchOptions): void { if (searchOptions?.decorations) { - if (this._resultIndex !== undefined && this._searchResults?.size !== undefined) { - this._onDidChangeResults.fire({ resultIndex: this._resultIndex, resultCount: this._searchResults.size }); - } else { - this._onDidChangeResults.fire(undefined); + let resultIndex = -1; + if (this._selectedDecoration) { + const selectedMatch = this._selectedDecoration.match; + for (let i = 0; i < this._highlightDecorations.length; i++) { + const match = this._highlightDecorations[i].match; + if (match.row === selectedMatch.row && match.col === selectedMatch.col && match.size === selectedMatch.size) { + resultIndex = i; + break; + } + } } + this._onDidChangeResults.fire({ resultIndex, resultCount: this._highlightDecorations.length }); } - this._cachedSearchTerm = term; - return found; } 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(); - this._resultIndex = -1; return false; } - if (this._cachedSearchTerm !== term) { - this._resultIndex = undefined; - this._terminal.clearSelection(); - } + const prevSelectedPos = this._terminal.getSelectionPosition(); + this._terminal.clearSelection(); - let startRow = this._terminal.buffer.active.baseY + this._terminal.rows; + let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1; let startCol = this._terminal.cols; const isReverseSearch = true; - const incremental = searchOptions ? searchOptions.incremental : false; - let currentSelection: IBufferRange | undefined; - if (this._terminal.hasSelection()) { - currentSelection = this._terminal.getSelectionPosition()!; - // Start from selection start if there is a selection - startRow = currentSelection.start.y; - startCol = currentSelection.start.x; - } - this._initLinesCache(); const searchPosition: ISearchPosition = { startRow, startCol }; - if (incremental) { - // Try to expand selection to right first. - result = this._findInLine(term, searchPosition, searchOptions, false); - const isOldResultHighlighted = result && result.row === startRow && result.col === startCol; - if (!isOldResultHighlighted) { - // If selection was not able to be expanded to the right, then try reverse search - if (currentSelection) { - searchPosition.startRow = currentSelection.end.y; - searchPosition.startCol = currentSelection.end.x; + let result: ISearchResult | undefined; + if (prevSelectedPos) { + searchPosition.startRow = startRow = prevSelectedPos.start.y; + searchPosition.startCol = startCol = prevSelectedPos.start.x; + if (this._cachedSearchTerm !== term) { + // Try to expand selection to right first. + result = this._findInLine(term, searchPosition, searchOptions, false); + if (!result) { + // If selection was not able to be expanded to the right, then try reverse search + searchPosition.startRow = startRow = prevSelectedPos.end.y; + searchPosition.startCol = startCol = prevSelectedPos.end.x; } - result = this._findInLine(term, searchPosition, searchOptions, true); } - } else { + } + + if (!result) { result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch); } @@ -399,8 +394,8 @@ export class SearchAddon extends Disposable implements ITerminalAddon { } } // If we hit the top and didn't search from the very bottom wrap back down - if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows)) { - for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows); y >= startRow; y--) { + if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) { + for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) { searchPosition.startRow = y; result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch); if (result) { @@ -409,22 +404,6 @@ export class SearchAddon extends Disposable implements ITerminalAddon { } } - if (this._searchResults) { - if (this._searchResults.size === 0) { - this._resultIndex = -1; - } else if (this._resultIndex === undefined || this._resultIndex < 0) { - 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, searchOptions?.decorations, searchOptions?.noScroll); } @@ -675,7 +654,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon { if (options) { const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row); if (marker) { - this._selectedDecoration = terminal.registerDecoration({ + const decoration = terminal.registerDecoration({ marker, x: result.col, width: result.size, @@ -685,8 +664,13 @@ export class SearchAddon extends Disposable implements ITerminalAddon { color: options.activeMatchColorOverviewRuler } }); - this._selectedDecoration?.onRender((e) => this._applyStyles(e, options.activeMatchBorder, true)); - this._selectedDecoration?.onDispose(() => marker.dispose()); + if (decoration) { + const disposables: IDisposable[] = []; + disposables.push(marker); + disposables.push(decoration.onRender((e) => this._applyStyles(e, options.activeMatchBorder, true))); + disposables.push(decoration.onDispose(() => disposeArray(disposables))); + this._selectedDecoration = { decoration, match: result, dispose() { decoration.dispose(); } }; + } } } @@ -709,9 +693,6 @@ export class SearchAddon extends Disposable implements ITerminalAddon { * @returns */ private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void { - if (element.clientWidth <= 0) { - return; - } if (!element.classList.contains('xterm-find-result-decoration')) { element.classList.add('xterm-find-result-decoration'); if (borderColor) { @@ -740,13 +721,17 @@ export class SearchAddon extends Disposable implements ITerminalAddon { x: result.col, width: result.size, backgroundColor: options.matchBackground, - overviewRulerOptions: this._resultDecorations?.get(marker.line) ? undefined : { + overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : { color: options.matchOverviewRuler, position: 'center' } }); - findResultDecoration?.onRender((e) => this._applyStyles(e, options.matchBorder, false)); - findResultDecoration?.onDispose(() => marker.dispose()); + if (findResultDecoration) { + const disposables: IDisposable[] = []; + disposables.push(marker); + disposables.push(findResultDecoration.onRender((e) => this._applyStyles(e, options.matchBorder, false))); + disposables.push(findResultDecoration.onDispose(() => disposeArray(disposables))); + } return findResultDecoration; } } diff --git a/addons/xterm-addon-search/test/SearchAddon.api.ts b/addons/xterm-addon-search/test/SearchAddon.api.ts index f17ecd1b..0c20084a 100644 --- a/addons/xterm-addon-search/test/SearchAddon.api.ts +++ b/addons/xterm-addon-search/test/SearchAddon.api.ts @@ -178,7 +178,7 @@ describe('Search Tests', function (): void { window.calls = []; window.search.onDidChangeResults(e => window.calls.push(e)); `); - await writeSync(page, 'abc aabc'); + await writeSync(page, 'd abc aabc d'); assert.deepStrictEqual(await page.evaluate(`window.search.findNext('a', { incremental: true, decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); assert.deepStrictEqual(await page.evaluate('window.calls'), [ { resultCount: 3, resultIndex: 0 } @@ -201,15 +201,64 @@ describe('Search Tests', function (): void { { resultCount: 2, resultIndex: 0 }, { resultCount: 2, resultIndex: 1 } ]); + assert.deepStrictEqual(await page.evaluate(`window.search.findNext('d', { incremental: true, decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 3, resultIndex: 0 }, + { resultCount: 2, resultIndex: 0 }, + { resultCount: 2, resultIndex: 0 }, + { resultCount: 2, resultIndex: 1 }, + { resultCount: 2, resultIndex: 1 } + ]); assert.deepStrictEqual(await page.evaluate(`window.search.findNext('abcd', { incremental: true, decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false); assert.deepStrictEqual(await page.evaluate('window.calls'), [ { resultCount: 3, resultIndex: 0 }, { resultCount: 2, resultIndex: 0 }, { resultCount: 2, resultIndex: 0 }, { resultCount: 2, resultIndex: 1 }, + { resultCount: 2, resultIndex: 1 }, { resultCount: 0, resultIndex: -1 } ]); }); + it('should fire with more than 1k matches', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + const data = ('a bc'.repeat(10) + '\\n\\r').repeat(150); + await writeSync(page, data); + assert.strictEqual(await page.evaluate(`window.search.findNext('a', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1000, resultIndex: 0 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findNext('a', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1000, resultIndex: 0 }, + { resultCount: 1000, resultIndex: 1 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findNext('bc', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1000, resultIndex: 0 }, + { resultCount: 1000, resultIndex: 1 }, + { resultCount: 1000, resultIndex: 1 } + ]); + }); + it('should fire when writing to terminal', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await writeSync(page, 'abc bc c\\n\\r'.repeat(2)); + assert.strictEqual(await page.evaluate(`window.search.findNext('abc', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 2, resultIndex: 0 } + ]); + await writeSync(page, 'abc bc c\\n\\r'); + await timeout(300); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 2, resultIndex: 0 }, + { resultCount: 3, resultIndex: 0 } + ]); + }); }); describe('findPrevious', () => { it('should not fire unless the decorations option is set', async () => { @@ -233,6 +282,7 @@ describe('Search Tests', function (): void { assert.deepStrictEqual(await page.evaluate('window.calls'), [ { resultCount: 1, resultIndex: 0 } ]); + await page.evaluate(`window.term.clearSelection()`); assert.strictEqual(await page.evaluate(`window.search.findPrevious('b', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); assert.deepStrictEqual(await page.evaluate('window.calls'), [ { resultCount: 1, resultIndex: 0 }, @@ -262,7 +312,7 @@ describe('Search Tests', function (): void { window.calls = []; window.search.onDidChangeResults(e => window.calls.push(e)); `); - await writeSync(page, 'abc aabc'); + await writeSync(page, 'd abc aabc d'); assert.deepStrictEqual(await page.evaluate(`window.search.findPrevious('a', { incremental: true, decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); assert.deepStrictEqual(await page.evaluate('window.calls'), [ { resultCount: 3, resultIndex: 2 } @@ -285,15 +335,64 @@ describe('Search Tests', function (): void { { resultCount: 2, resultIndex: 1 }, { resultCount: 2, resultIndex: 0 } ]); + assert.deepStrictEqual(await page.evaluate(`window.search.findPrevious('d', { incremental: true, decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 3, resultIndex: 2 }, + { resultCount: 2, resultIndex: 1 }, + { resultCount: 2, resultIndex: 1 }, + { resultCount: 2, resultIndex: 0 }, + { resultCount: 2, resultIndex: 1 } + ]); assert.deepStrictEqual(await page.evaluate(`window.search.findPrevious('abcd', { incremental: true, decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false); assert.deepStrictEqual(await page.evaluate('window.calls'), [ { resultCount: 3, resultIndex: 2 }, { resultCount: 2, resultIndex: 1 }, { resultCount: 2, resultIndex: 1 }, { resultCount: 2, resultIndex: 0 }, + { resultCount: 2, resultIndex: 1 }, { resultCount: 0, resultIndex: -1 } ]); }); + it('should fire with more than 1k matches', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + const data = ('a bc'.repeat(10) + '\\n\\r').repeat(150); + await writeSync(page, data); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('a', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1000, resultIndex: -1 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('a', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1000, resultIndex: -1 }, + { resultCount: 1000, resultIndex: -1 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('bc', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1000, resultIndex: -1 }, + { resultCount: 1000, resultIndex: -1 }, + { resultCount: 1000, resultIndex: -1 } + ]); + }); + it('should fire when writing to terminal', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await writeSync(page, 'abc bc c\\n\\r'.repeat(2)); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('abc', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 2, resultIndex: 1 } + ]); + await writeSync(page, 'abc bc c\\n\\r'); + await timeout(300); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 2, resultIndex: 1 }, + { resultCount: 3, resultIndex: 1 } + ]); + }); }); }); 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 ad51262d..3a3b7c89 100644 --- a/addons/xterm-addon-search/typings/xterm-addon-search.d.ts +++ b/addons/xterm-addon-search/typings/xterm-addon-search.d.ts @@ -75,10 +75,28 @@ declare module 'xterm-addon-search' { activeMatchColorOverviewRuler: string; } + /** + * Options for the search addon. + */ + export interface ISearchAddonOptions { + /** + * Max number of matches highlighted when decorations are enabled. + * Defaults to 1000 highlighted matches + */ + highlightLimit: number + } + /** * An xterm.js addon that provides search functionality. */ export class SearchAddon implements ITerminalAddon { + + /** + * Creates a new search addon. + * @param options Options for the search addon. + */ + constructor(options?: Partial); + /** * Activates the addon * @param terminal The terminal the addon is being loaded in. @@ -121,10 +139,8 @@ declare module 'xterm-addon-search' { /** * When decorations are enabled, fires when * the search results change. - * @returns -1 for resultIndex for a resultCount of 0 - * and @returns undefined when the threshold of 1k results - * is exceeded and decorations are disposed of. + * @returns -1 for resultIndex when the threshold of matches is exceeded. */ - readonly onDidChangeResults: IEvent<{ resultIndex: number, resultCount: number } | undefined>; + readonly onDidChangeResults: IEvent<{ resultIndex: number, resultCount: number }>; } } diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.ts b/addons/xterm-addon-serialize/src/SerializeAddon.ts index b83ff160..0279fee8 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.ts @@ -77,6 +77,7 @@ function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): bo return cell1.isInverse() === cell2.isInverse() && cell1.isBold() === cell2.isBold() && cell1.isUnderline() === cell2.isUnderline() + && cell1.isOverline() === cell2.isOverline() && cell1.isBlink() === cell2.isBlink() && cell1.isInvisible() === cell2.isInvisible() && cell1.isItalic() === cell2.isItalic() @@ -264,6 +265,7 @@ class StringSerializeHandler extends BaseSerializeHandler { if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); } if (cell.isUnderline() !== oldCell.isUnderline()) { sgrSeq.push(cell.isUnderline() ? 4 : 24); } + if (cell.isOverline() !== oldCell.isOverline()) { sgrSeq.push(cell.isOverline() ? 53 : 55); } if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } @@ -625,7 +627,9 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { if (cell.isInverse()) { content.push('color: #000000; background-color: #BFBFBF;'); } if (cell.isBold()) { content.push('font-weight: bold;'); } - if (cell.isUnderline()) { content.push('text-decoration: underline;'); } + if (cell.isUnderline() && cell.isOverline()) { content.push('text-decoration: overline underline;'); } + else if (cell.isUnderline()) { content.push('text-decoration: underline;'); } + else if (cell.isOverline()) { content.push('text-decoration: overline;'); } if (cell.isBlink()) { content.push('text-decoration: blink;'); } if (cell.isInvisible()) { content.push('visibility: hidden;'); } if (cell.isItalic()) { content.push('font-style: italic;'); } diff --git a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts index 157b7072..5128910a 100644 --- a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts +++ b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts @@ -220,6 +220,18 @@ describe('SerializeAddon', () => { assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); }); + it('serialize all rows of content with overline', async () => { + const cols = 10; + const line = '+'.repeat(cols); + const lines: string[] = [ + sgr(OVERLINED) + line, // Overlined + sgr(UNDERLINED) + line, // Overlined, Underlined + sgr(NORMAL) + line // Normal + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + it('serialize all rows of content with color16 and style separately', async function(): Promise { const cols = 10; const line = '+'.repeat(cols); @@ -601,6 +613,7 @@ const BLINK = '5'; const INVERSE = '7'; const INVISIBLE = '8'; const STRIKETHROUGH = '9'; +const OVERLINED = '53'; const NO_BOLD = '22'; const NO_DIM = '22'; @@ -610,3 +623,4 @@ const NO_BLINK = '25'; const NO_INVERSE = '27'; const NO_INVISIBLE = '28'; const NO_STRIKETHROUGH = '29'; +const NO_OVERLINED = '55'; diff --git a/addons/xterm-addon-webgl/src/RectangleRenderer.ts b/addons/xterm-addon-webgl/src/RectangleRenderer.ts index f45ae3df..fb5a0762 100644 --- a/addons/xterm-addon-webgl/src/RectangleRenderer.ts +++ b/addons/xterm-addon-webgl/src/RectangleRenderer.ts @@ -270,7 +270,7 @@ export class RectangleRenderer extends Disposable { $r = (($rgba >> 24) & 0xFF) / 255; $g = (($rgba >> 16) & 0xFF) / 255; $b = (($rgba >> 8 ) & 0xFF) / 255; - $a = (!$isDefault && bg & BgFlags.DIM) ? DIM_OPACITY : 1; + $a = 1; this._addRectangle(vertices.attributes, offset, $x1, $y1, (endX - startX) * this._dimensions.device.cell.width, this._dimensions.device.cell.height, $r, $g, $b, $a); } diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index fcb0dd80..4fc4427f 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -336,13 +336,16 @@ export class WebglRenderer extends Disposable implements IRenderer { } // Tell renderer the frame is beginning + // upon a model clear also refresh the full viewport model + // (also triggered by an atlas page merge, part of #4480) if (this._glyphRenderer.beginFrame()) { this._clearModel(true); + this._updateModel(0, this._terminal.rows - 1); + } else { + // just update changed lines to draw + this._updateModel(start, end); } - // Update model to reflect what's drawn - this._updateModel(start, end); - // Render this._rectangleRenderer?.render(); this._glyphRenderer?.render(this._model); diff --git a/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts index 9830697c..c6a33a5c 100644 --- a/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts +++ b/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts @@ -12,6 +12,7 @@ import { IEventEmitter } from 'common/EventEmitter'; import { ICoreBrowserService, IThemeService } from 'browser/services/Services'; import { ICoreService, IOptionsService } from 'common/services/Services'; import { toDisposable } from 'common/Lifecycle'; +import { isFirefox } from 'common/Platform'; interface ICursorState { x: number; @@ -190,8 +191,9 @@ export class CursorRenderLayer extends BaseRenderLayer { private _clearCursor(): void { if (this._state) { - // Avoid potential rounding errors when device pixel ratio is less than 1 - if (this._coreBrowserService.dpr < 1) { + // Avoid potential rounding errors when browser is Firefox (#4487) or device pixel ratio is + // less than 1 + if (isFirefox || this._coreBrowserService.dpr < 1) { this._clearAll(); } else { this._clearCells(this._state.x, this._state.y, this._state.width, 1); diff --git a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts index 5e34aede..3edfdc33 100644 --- a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts +++ b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts @@ -364,6 +364,55 @@ describe('WebGL Renderer Integration Tests', async () => { } }); + itWebgl('foreground 16-255 dim', async () => { + let data = ''; + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + data += `\\x1b[2;38;5;${16 + y * 16 + x}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(page, data); + 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.slice(1, 3), 16); + const g = parseInt(cssColor.slice(3, 5), 16); + const b = parseInt(cssColor.slice(5, 7), 16); + // It's difficult to assert the exact color due to rounding, just ensure the color differs + // to the regular color + await pollFor(page, async () => { + const c = await getCellColor(x + 1, y + 1); + return ( + (c[0] === 0 || c[0] !== r) && + (c[1] === 0 || c[1] !== g) && + (c[2] === 0 || c[2] !== b) + ); + }, true); + } + } + }); + + itWebgl('background 16-255 dim', async () => { + let data = ''; + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + data += `\\x1b[2;48;5;${16 + y * 16 + x}m \\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(page, data); + 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.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]); + } + } + }); + itWebgl('foreground true color red', async () => { let data = ''; for (let y = 0; y < 16; y++) { diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 1bc414d1..354f72c2 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1,10 +1,10 @@ pr: branches: - include: ["main", "v5"] + include: ["main"] trigger: branches: - include: ["main", "v5"] + include: ["main"] jobs: - job: Linux @@ -13,7 +13,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: YarnInstaller@3 inputs: @@ -46,7 +46,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: CacheBeta@1 inputs: @@ -66,7 +66,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: CacheBeta@1 inputs: @@ -95,7 +95,7 @@ jobs: displayName: Install required packages - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: YarnInstaller@3 inputs: @@ -111,11 +111,11 @@ jobs: # Integration tests are too flaky on macOS https://github.com/xtermjs/xterm.js/issues/3590 # - job: macOS_IntegrationTests # pool: -# vmImage: 'macOS-10.15' +# vmImage: 'macOS-11' # steps: # - task: NodeTool@0 # inputs: -# versionSpec: '14.x' +# versionSpec: '18.x' # displayName: 'Install Node.js' # - script: yarn --frozen-lockfile # displayName: 'Install dependencies and build' @@ -132,7 +132,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - script: yarn --frozen-lockfile displayName: 'Install dependencies and build' @@ -155,7 +155,7 @@ jobs: steps: - task: NodeTool@0 inputs: - versionSpec: '14.x' + versionSpec: '18.x' displayName: 'Install Node.js' - task: YarnInstaller@3 inputs: diff --git a/css/xterm.css b/css/xterm.css index e9fd8153..74acc267 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -149,6 +149,7 @@ right: 0; z-index: 10; color: transparent; + pointer-events: none; } .xterm .live-region { @@ -160,7 +161,9 @@ } .xterm-dim { - opacity: 0.5; + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; } .xterm-underline-1 { text-decoration: underline; } @@ -169,6 +172,16 @@ .xterm-underline-4 { text-decoration: dotted underline; } .xterm-underline-5 { text-decoration: dashed underline; } +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + .xterm-strikethrough { text-decoration: line-through; } @@ -178,8 +191,12 @@ position: absolute; } +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + .xterm-decoration-overview-ruler { - z-index: 7; + z-index: 8; position: absolute; top: 0; right: 0; diff --git a/demo/client.ts b/demo/client.ts index 68cdec65..d72ddf65 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -132,12 +132,11 @@ function setPadding(): void { addons.fit.instance.fit(); } -function getSearchOptions(e: KeyboardEvent): ISearchOptions { +function getSearchOptions(): ISearchOptions { return { 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`, decorations: (document.getElementById('highlight-all-matches') as HTMLInputElement).checked ? { matchBackground: '#232422', matchBorder: '#555753', @@ -303,11 +302,23 @@ function createTerminal(): void { addDomListener(paddingElement, 'change', setPadding); - addDomListener(actionElements.findNext, 'keyup', (e) => { - addons.search.instance.findNext(actionElements.findNext.value, getSearchOptions(e)); + addDomListener(actionElements.findNext, 'keydown', (e) => { + if (e.key === 'Enter') { + addons.search.instance.findNext(actionElements.findNext.value, getSearchOptions()); + e.preventDefault(); + } }); - addDomListener(actionElements.findPrevious, 'keyup', (e) => { - addons.search.instance.findPrevious(actionElements.findPrevious.value, getSearchOptions(e)); + addDomListener(actionElements.findNext, 'input', (e) => { + addons.search.instance.findNext(actionElements.findNext.value, getSearchOptions()); + }); + addDomListener(actionElements.findPrevious, 'keydown', (e) => { + if (e.key === 'Enter') { + addons.search.instance.findPrevious(actionElements.findPrevious.value, getSearchOptions()); + e.preventDefault(); + } + }); + addDomListener(actionElements.findPrevious, 'input', (e) => { + addons.search.instance.findPrevious(actionElements.findPrevious.value, getSearchOptions()); }); addDomListener(actionElements.findNext, 'blur', (e) => { addons.search.instance.clearActiveDecoration(); @@ -965,7 +976,9 @@ function sgrTest(): void { { ps: 45, name: 'Background Magenta' }, { ps: 46, name: 'Background Cyan' }, { ps: 47, name: 'Background White' }, - { ps: 49, name: 'Background default' } + { ps: 49, name: 'Background default' }, + { ps: 53, name: 'Overlined' }, + { ps: 55, name: 'Not overlined' } ]; const maxNameLength = entries.reduce((p, c) => Math.max(c.name.length, p), 0); for (const e of entries) { @@ -977,7 +990,8 @@ function sgrTest(): void { } const comboEntries: { ps: number[] }[] = [ { ps: [1, 2, 3, 4, 5, 6, 7, 9] }, - { ps: [2, 41] } + { ps: [2, 41] }, + { ps: [4, 53] } ]; term.write('\n\n\r'); term.writeln(`Combinations`); diff --git a/demo/server.js b/demo/server.js index 92b82e98..f477ae79 100644 --- a/demo/server.js +++ b/demo/server.js @@ -111,35 +111,32 @@ function startServer() { } // binary message buffering function bufferUtf8(socket, timeout, maxSize) { - const dataBuffer = new Uint8Array(maxSize); - let sender = null; + const chunks = []; let length = 0; + let sender = null; return (data) => { - function flush() { - socket.send(Buffer.from(dataBuffer.buffer, 0, length)); + chunks.push(data); + length += data.length; + if (length > maxSize || userInput) { + userInput = false; + socket.send(Buffer.concat(chunks)); + chunks.length = 0; length = 0; if (sender) { clearTimeout(sender); sender = null; } - } - if (length + data.length > maxSize) { - flush(); - } - dataBuffer.set(data, length); - length += data.length; - if (length > maxSize || userInput) { - userInput = false; - flush(); } else if (!sender) { sender = setTimeout(() => { + socket.send(Buffer.concat(chunks)); + chunks.length = 0; + length = 0; sender = null; - flush(); }, timeout); } }; } - const send = (USE_BINARY ? bufferUtf8 : buffer)(ws, 5, 262144); + const send = (USE_BINARY ? bufferUtf8 : buffer)(ws, 3, 262144); // WARNING: This is a naive implementation that will not throttle the flow of data. This means // it could flood the communication channel and make the terminal unresponsive. Learn more about diff --git a/package.json b/package.json index 1423cab0..70a85a71 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@types/glob": "^7.2.0", "@types/jsdom": "^16.2.13", "@types/mocha": "^9.0.0", - "@types/node": "^14.14.44", + "@types/node": "^18.16.0", "@types/utf8": "^3.0.0", "@types/webpack": "^5.28.0", "@types/ws": "^8.2.0", diff --git a/src/browser/AccessibilityManager.ts b/src/browser/AccessibilityManager.ts index 42242fef..0a2cbf23 100644 --- a/src/browser/AccessibilityManager.ts +++ b/src/browser/AccessibilityManager.ts @@ -8,16 +8,32 @@ import { ITerminal, IRenderDebouncer } from 'browser/Types'; import { isMac } from 'common/Platform'; import { TimeBasedDebouncer } from 'browser/TimeBasedDebouncer'; import { Disposable, toDisposable } from 'common/Lifecycle'; +import { ScreenDprMonitor } from 'browser/ScreenDprMonitor'; +import { IRenderService } from 'browser/services/Services'; +import { addDisposableDomListener } from 'browser/Lifecycle'; const MAX_ROWS_TO_READ = 20; +const enum BoundaryPosition { + TOP, + BOTTOM +} + export class AccessibilityManager extends Disposable { private _accessibilityContainer: HTMLElement; + private _rowContainer: HTMLElement; + private _rowElements: HTMLElement[]; + private _liveRegion: HTMLElement; private _liveRegionLineCount: number = 0; private _liveRegionDebouncer: IRenderDebouncer; + private _screenDprMonitor: ScreenDprMonitor; + + private _topBoundaryFocusListener: (e: FocusEvent) => void; + private _bottomBoundaryFocusListener: (e: FocusEvent) => void; + /** * This queue has a character pushed to it for keys that are pressed, if the * next character added to the terminal is equal to the key char then it is @@ -32,12 +48,30 @@ export class AccessibilityManager extends Disposable { private _charsToAnnounce: string = ''; constructor( - private readonly _terminal: ITerminal + private readonly _terminal: ITerminal, + @IRenderService private readonly _renderService: IRenderService ) { super(); this._accessibilityContainer = document.createElement('div'); this._accessibilityContainer.classList.add('xterm-accessibility'); + this._rowContainer = document.createElement('div'); + this._rowContainer.setAttribute('role', 'list'); + this._rowContainer.classList.add('xterm-accessibility-tree'); + this._rowElements = []; + for (let i = 0; i < this._terminal.rows; i++) { + this._rowElements[i] = this._createAccessibilityTreeNode(); + this._rowContainer.appendChild(this._rowElements[i]); + } + + this._topBoundaryFocusListener = e => this._handleBoundaryFocus(e, BoundaryPosition.TOP); + this._bottomBoundaryFocusListener = e => this._handleBoundaryFocus(e, BoundaryPosition.BOTTOM); + this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); + + this._refreshRowsDimensions(); + this._accessibilityContainer.appendChild(this._rowContainer); + this._liveRegion = document.createElement('div'); this._liveRegion.classList.add('live-region'); this._liveRegion.setAttribute('aria-live', 'assertive'); @@ -50,6 +84,7 @@ export class AccessibilityManager extends Disposable { this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityContainer); this.register(this._liveRegionDebouncer); + this.register(this._terminal.onResize(e => this._handleResize(e.rows))); this.register(this._terminal.onRender(e => this._refreshRows(e.start, e.end))); this.register(this._terminal.onScroll(() => this._refreshRows())); // Line feed is an issue as the prompt won't be read out after a command is run @@ -58,7 +93,20 @@ export class AccessibilityManager extends Disposable { this.register(this._terminal.onA11yTab(spaceCount => this._handleTab(spaceCount))); this.register(this._terminal.onKey(e => this._handleKey(e.key))); this.register(this._terminal.onBlur(() => this._clearLiveRegion())); - this.register(toDisposable(() => this._accessibilityContainer.remove())); + this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions())); + + this._screenDprMonitor = new ScreenDprMonitor(window); + this.register(this._screenDprMonitor); + this._screenDprMonitor.setListener(() => this._refreshRowsDimensions()); + // This shouldn't be needed on modern browsers but is present in case the + // media query that drives the ScreenDprMonitor isn't supported + this.register(addDisposableDomListener(window, 'resize', () => this._refreshRowsDimensions())); + + this._refreshRows(); + this.register(toDisposable(() => { + this._accessibilityContainer.remove(); + this._rowElements.length = 0; + })); } private _handleTab(spaceCount: number): void { @@ -126,4 +174,107 @@ export class AccessibilityManager extends Disposable { this._liveRegion.textContent += this._charsToAnnounce; this._charsToAnnounce = ''; } + + private _handleBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void { + const boundaryElement = e.target as HTMLElement; + const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2]; + + // Don't scroll if the buffer top has reached the end in that direction + const posInSet = boundaryElement.getAttribute('aria-posinset'); + const lastRowPos = position === BoundaryPosition.TOP ? '1' : `${this._terminal.buffer.lines.length}`; + if (posInSet === lastRowPos) { + return; + } + + // Don't scroll when the last focused item was not the second row (focus is going the other + // direction) + if (e.relatedTarget !== beforeBoundaryElement) { + return; + } + + // Remove old boundary element from array + let topBoundaryElement: HTMLElement; + let bottomBoundaryElement: HTMLElement; + if (position === BoundaryPosition.TOP) { + topBoundaryElement = boundaryElement; + bottomBoundaryElement = this._rowElements.pop()!; + this._rowContainer.removeChild(bottomBoundaryElement); + } else { + topBoundaryElement = this._rowElements.shift()!; + bottomBoundaryElement = boundaryElement; + this._rowContainer.removeChild(topBoundaryElement); + } + + // Remove listeners from old boundary elements + topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener); + bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener); + + // Add new element to array/DOM + if (position === BoundaryPosition.TOP) { + const newElement = this._createAccessibilityTreeNode(); + this._rowElements.unshift(newElement); + this._rowContainer.insertAdjacentElement('afterbegin', newElement); + } else { + const newElement = this._createAccessibilityTreeNode(); + this._rowElements.push(newElement); + this._rowContainer.appendChild(newElement); + } + + // Add listeners to new boundary elements + this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener); + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); + + // Scroll up + this._terminal.scrollLines(position === BoundaryPosition.TOP ? -1 : 1); + + // Focus new boundary before element + this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2].focus(); + + // Prevent the standard behavior + e.preventDefault(); + e.stopImmediatePropagation(); + } + + private _handleResize(rows: number): void { + // Remove bottom boundary listener + this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener); + + // Grow rows as required + for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) { + this._rowElements[i] = this._createAccessibilityTreeNode(); + this._rowContainer.appendChild(this._rowElements[i]); + } + // Shrink rows as required + while (this._rowElements.length > rows) { + this._rowContainer.removeChild(this._rowElements.pop()!); + } + + // Add bottom boundary listener + this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener); + + this._refreshRowsDimensions(); + } + + private _createAccessibilityTreeNode(): HTMLElement { + const element = document.createElement('div'); + element.setAttribute('role', 'listitem'); + element.tabIndex = -1; + this._refreshRowDimensions(element); + return element; + } + private _refreshRowsDimensions(): void { + if (!this._renderService.dimensions.css.cell.height) { + return; + } + this._accessibilityContainer.style.width = `${this._renderService.dimensions.css.canvas.width}px`; + if (this._rowElements.length !== this._terminal.rows) { + this._handleResize(this._terminal.rows); + } + for (let i = 0; i < this._terminal.rows; i++) { + this._refreshRowDimensions(this._rowElements[i]); + } + } + private _refreshRowDimensions(element: HTMLElement): void { + element.style.height = `${this._renderService.dimensions.css.cell.height}px`; + } } diff --git a/src/browser/decorations/BufferDecorationRenderer.ts b/src/browser/decorations/BufferDecorationRenderer.ts index e195c5ae..fb77ce9c 100644 --- a/src/browser/decorations/BufferDecorationRenderer.ts +++ b/src/browser/decorations/BufferDecorationRenderer.ts @@ -72,6 +72,7 @@ export class BufferDecorationRenderer extends Disposable { private _createElement(decoration: IInternalDecoration): HTMLElement { const element = document.createElement('div'); element.classList.add('xterm-decoration'); + element.classList.toggle('xterm-decoration-top-layer', decoration?.options?.layer === 'top'); element.style.width = `${Math.round((decoration.options.width || 1) * this._renderService.dimensions.css.cell.width)}px`; element.style.height = `${(decoration.options.height || 1) * this._renderService.dimensions.css.cell.height}px`; element.style.top = `${(decoration.marker.line - this._bufferService.buffers.active.ydisp) * this._renderService.dimensions.css.cell.height}px`; diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index fe6cba2d..54711ed8 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { BOLD_CLASS, CURSOR_BLINK_CLASS, CURSOR_CLASS, CURSOR_STYLE_BAR_CLASS, CURSOR_STYLE_BLOCK_CLASS, CURSOR_STYLE_UNDERLINE_CLASS, DomRendererRowFactory, ITALIC_CLASS } from 'browser/renderer/dom/DomRendererRowFactory'; +import { BOLD_CLASS, CURSOR_BLINK_CLASS, CURSOR_CLASS, CURSOR_STYLE_BAR_CLASS, CURSOR_STYLE_BLOCK_CLASS, CURSOR_STYLE_UNDERLINE_CLASS, DIM_CLASS, DomRendererRowFactory, ITALIC_CLASS } from 'browser/renderer/dom/DomRendererRowFactory'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/shared/Constants'; import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types'; @@ -53,7 +53,7 @@ export class DomRenderer extends Disposable implements IRenderer { @IOptionsService private readonly _optionsService: IOptionsService, @IBufferService private readonly _bufferService: IBufferService, @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService, - @IThemeService themeService: IThemeService + @IThemeService private readonly _themeService: IThemeService ) { super(); this._rowContainer = document.createElement('div'); @@ -69,8 +69,8 @@ export class DomRenderer extends Disposable implements IRenderer { this._updateDimensions(); this.register(this._optionsService.onOptionChange(() => this._handleOptionsChanged())); - this.register(themeService.onChangeColors(e => this._injectCss(e))); - this._injectCss(themeService.colors); + this.register(this._themeService.onChangeColors(e => this._injectCss(e))); + this._injectCss(this._themeService.colors); this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document); @@ -149,6 +149,10 @@ export class DomRenderer extends Disposable implements IRenderer { ` font-family: ${this._optionsService.rawOptions.fontFamily};` + ` font-size: ${this._optionsService.rawOptions.fontSize}px;` + `}`; + styles += + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` + + ` color: ${color.multiplyOpacity(colors.foreground, 0.5).css};` + + `}`; // Text styles styles += `${this._terminalSelector} span:not(.${BOLD_CLASS}) {` + @@ -224,10 +228,12 @@ export class DomRenderer extends Disposable implements IRenderer { for (const [i, c] of colors.ansi.entries()) { styles += `${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` + + `${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` + `${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`; } styles += `${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` + + `${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` + `${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`; this._themeStyleElement.textContent = styles; @@ -340,6 +346,8 @@ export class DomRenderer extends Disposable implements IRenderer { private _handleOptionsChanged(): void { // Force a refresh this._updateDimensions(); + // Refresh CSS + this._injectCss(this._themeService.colors); } public clear(): void { @@ -385,8 +393,37 @@ export class DomRenderer extends Disposable implements IRenderer { } private _setCellUnderline(x: number, x2: number, y: number, y2: number, cols: number, enabled: boolean): void { - x = this._cellToRowElements[y][x]; - x2 = this._cellToRowElements[y2][x2]; + /** + * NOTE: The linkifier may send out of viewport y-values if: + * - negative y-value: the link started at a higher line + * - y-value >= maxY: the link ends at a line below viewport + * + * For negative y-values we can simply adjust x = 0, + * as higher up link start means, that everything from + * (0,0) is a link under top-down-left-right char progression + * + * Additionally there might be a small chance of out-of-sync x|y-values + * from a race condition of render updates vs. link event handler execution: + * - (sync) resize: chances terminal buffer in sync, schedules render update async + * - (async) link handler race condition: new buffer metrics, but still on old render state + * - (async) render update: brings term metrics and render state back in sync + */ + if (y < 0) x = 0; + if (y2 < 0) x2 = 0; + + // avoid out-of-sync y-values, simply clamp into valid area + const maxY = this._cellToRowElements.length - 1; + y = Math.max(Math.min(y, maxY), 0); + y2 = Math.max(Math.min(y2, maxY), 0); + const elemY = this._cellToRowElements[y]; + const elemY2 = this._cellToRowElements[y2]; + if (x >= elemY.length || x2 >= elemY2.length) { + // avoid out-of-sync x-values + // simply exit early, gets fixed by the next render update + return; + } + x = elemY[x]; + x2 = elemY2[x2]; if (x === -1 || x2 === -1) { return; diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 5969abff..0c2fb79d 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -167,6 +167,16 @@ describe('DomRendererRowFactory', () => { }); }); + it('should add class for overline', () => { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.OVERLINE; + lineData.setCell(0, cell); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING); + assert.equal(getFragmentHtml(fragment), + 'a' + ); + }); + it('should add class for strikethrough', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.STRIKETHROUGH; diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index cc64a438..a0b44d2c 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -9,7 +9,6 @@ import { NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Attributes } from 'common/buffer/ import { CellData } from 'common/buffer/CellData'; import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { color, rgba } from 'common/Color'; -import { IColorSet, ReadonlyColorSet } from 'browser/Types'; import { ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'browser/services/Services'; import { JoinedCellData } from 'browser/services/CharacterJoinerService'; import { excludeFromContrastRatioDemands } from 'browser/renderer/shared/RendererUtils'; @@ -19,6 +18,7 @@ export const BOLD_CLASS = 'xterm-bold'; export const DIM_CLASS = 'xterm-dim'; export const ITALIC_CLASS = 'xterm-italic'; export const UNDERLINE_CLASS = 'xterm-underline'; +export const OVERLINE_CLASS = 'xterm-overline'; export const STRIKETHROUGH_CLASS = 'xterm-strikethrough'; export const CURSOR_CLASS = 'xterm-cursor'; export const CURSOR_BLINK_CLASS = 'xterm-cursor-blink'; @@ -186,6 +186,13 @@ export class DomRendererRowFactory { } } + if (cell.isOverline()) { + charElement.classList.add(OVERLINE_CLASS); + if (charElement.textContent === ' ') { + charElement.textContent = '\xa0'; // =   + } + } + if (cell.isStrikethrough()) { charElement.classList.add(STRIKETHROUGH_CLASS); } diff --git a/src/browser/renderer/shared/TextureAtlas.ts b/src/browser/renderer/shared/TextureAtlas.ts index ce168465..d6bf1581 100644 --- a/src/browser/renderer/shared/TextureAtlas.ts +++ b/src/browser/renderer/shared/TextureAtlas.ts @@ -304,12 +304,6 @@ export class TextureAtlas implements ITextureAtlas { break; } - if (dim) { - // Blend here instead of using opacity because transparent colors mess with clipping the - // glyph's bounding box - result = color.blend(this._config.colors.background, color.multiplyOpacity(result, DIM_OPACITY)); - } - return result; } @@ -429,12 +423,12 @@ export class TextureAtlas implements ITextureAtlas { // Allow 1 cell width per character, with a minimum of 2 (CJK), plus some padding. This is used // to draw the glyph to the canvas as well as to restrict the bounding box search to ensure // giant ligatures (eg. =====>) don't impact overall performance. - const allowedWidth = this._config.deviceCellWidth * Math.max(chars.length, 2) + TMP_CANVAS_GLYPH_PADDING * 2; + const allowedWidth = Math.min(this._config.deviceCellWidth * Math.max(chars.length, 2) + TMP_CANVAS_GLYPH_PADDING * 2, this._textureSize); if (this._tmpCanvas.width < allowedWidth) { this._tmpCanvas.width = allowedWidth; } // Include line height when drawing glyphs - const allowedHeight = this._config.deviceCellHeight + TMP_CANVAS_GLYPH_PADDING * 4; + const allowedHeight = Math.min(this._config.deviceCellHeight + TMP_CANVAS_GLYPH_PADDING * 4, this._textureSize); if (this._tmpCanvas.height < allowedHeight) { this._tmpCanvas.height = allowedHeight; } @@ -455,6 +449,7 @@ export class TextureAtlas implements ITextureAtlas { const italic = !!this._workAttributeData.isItalic(); const underline = !!this._workAttributeData.isUnderline(); const strikethrough = !!this._workAttributeData.isStrikethrough(); + const overline = !!this._workAttributeData.isOverline(); let fgColor = this._workAttributeData.getFgColor(); let fgColorMode = this._workAttributeData.getFgColorMode(); let bgColor = this._workAttributeData.getBgColor(); @@ -638,12 +633,24 @@ export class TextureAtlas implements ITextureAtlas { } } + // Overline + if (overline) { + const lineWidth = Math.max(1, Math.floor(this._config.fontSize * this._config.devicePixelRatio / 15)); + const yOffset = lineWidth % 2 === 1 ? 0.5 : 0; + this._tmpCtx.lineWidth = lineWidth; + this._tmpCtx.strokeStyle = this._tmpCtx.fillStyle; + this._tmpCtx.beginPath(); + this._tmpCtx.moveTo(padding, padding + yOffset); + this._tmpCtx.lineTo(padding + this._config.deviceCharWidth * chWidth, padding + yOffset); + this._tmpCtx.stroke(); + } + // Draw the character if (!customGlyph) { this._tmpCtx.fillText(chars, padding, padding + this._config.deviceCharHeight); } - // If this charcater is underscore and beyond the cell bounds, shift it up until it is visible + // If this character is underscore and beyond the cell bounds, shift it up until it is visible // even on the bottom row, try for a maximum of 5 pixels. if (chars === '_' && !this._config.allowTransparency) { let isBeyondCellBounds = clearColor(this._tmpCtx.getImageData(padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight), backgroundColor, foregroundColor, enableClearThresholdCheck); diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 858019ff..09a6a714 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -2222,7 +2222,7 @@ describe('InputHandler', () => { }); it('ANSI 2 (keyboard action mode)', async () => { await inputHandler.parseP('\x1b[2$p'); - assert.deepEqual(reportStack.pop(), '\x1b[2;3$y'); // always set + assert.deepEqual(reportStack.pop(), '\x1b[2;4$y'); // always reset }); it('ANSI 4 (insert mode)', async () => { await inputHandler.parseP('\x1b[4$p'); @@ -2236,7 +2236,7 @@ describe('InputHandler', () => { }); it('ANSI 12 (send/receive)', async () => { await inputHandler.parseP('\x1b[12$p'); - assert.deepEqual(reportStack.pop(), '\x1b[12;4$y'); // always reset + assert.deepEqual(reportStack.pop(), '\x1b[12;3$y'); // always set }); it('ANSI 20 (newline mode)', async () => { await inputHandler.parseP('\x1b[20$p'); @@ -2280,7 +2280,7 @@ describe('InputHandler', () => { }); it('DEC privates perma modes', async () => { // [mode number, state value] - const perma = [[3, 0], [8, 3], [1005, 4], [1015, 4], [1048, 1]]; + const perma = [[3, 0], [8, 3], [67, 4], [1005, 4], [1015, 4], [1048, 1]]; for (const [mode, value] of perma) { await inputHandler.parseP(`\x1b[?${mode}$p`); assert.deepEqual(reportStack.pop(), `\x1b[?${mode};${value}$y`); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index fa65b93b..670cd68c 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2223,9 +2223,9 @@ export class InputHandler extends Disposable implements IInputHandler { const p = params.params[0]; if (ansi) { - if (p === 2) return f(p, V.PERMANENTLY_SET); + if (p === 2) return f(p, V.PERMANENTLY_RESET); if (p === 4) return f(p, b2v(cs.modes.insertMode)); - if (p === 12) return f(p, V.PERMANENTLY_RESET); + if (p === 12) return f(p, V.PERMANENTLY_SET); if (p === 20) return f(p, b2v(opts.convertEol)); return f(p, V.NOT_RECOGNIZED); } @@ -2240,6 +2240,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (p === 25) return f(p, b2v(!cs.isCursorHidden)); if (p === 45) return f(p, b2v(dm.reverseWraparound)); if (p === 66) return f(p, b2v(dm.applicationKeypad)); + if (p === 67) return f(p, V.PERMANENTLY_RESET); if (p === 1000) return f(p, b2v(mouseProtocol === 'VT200')); if (p === 1002) return f(p, b2v(mouseProtocol === 'DRAG')); if (p === 1003) return f(p, b2v(mouseProtocol === 'ANY')); @@ -2425,6 +2426,8 @@ export class InputHandler extends Disposable implements IInputHandler { * | 47 | Background color: White. | #Y | * | 48 | Background color: Extended color. | #P[Support for RGB and indexed colors, see below.] | * | 49 | Background color: Default (original). | #Y | + * | 53 | Overlined. | #Y | + * | 55 | Not Overlined. | #Y | * | 58 | Underline color: Extended color. | #P[Support for RGB and indexed colors, see below.] | * | 90 - 97 | Bright foreground color (analogous to 30 - 37). | #Y | * | 100 - 107 | Bright background color (analogous to 40 - 47). | #Y | @@ -2551,6 +2554,12 @@ export class InputHandler extends Disposable implements IInputHandler { } else if (p === 38 || p === 48 || p === 58) { // fg color 256 and RGB i += this._extractColor(params, i, attr); + } else if (p === 53) { + // overline + attr.bg |= BgFlags.OVERLINE; + } else if (p === 55) { + // not overline + attr.bg &= ~BgFlags.OVERLINE; } else if (p === 59) { attr.extended = attr.extended.clone(); attr.extended.underlineColor = -1; diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 73471512..70143525 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -165,6 +165,7 @@ export interface IAttributeData { isDim(): number; isStrikethrough(): number; isProtected(): number; + isOverline(): number; /** * The color mode of the foreground color which determines how to decode {@link getFgColor}, diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index c9f4cd61..f4d12c2b 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -47,6 +47,7 @@ export class AttributeData implements IAttributeData { public isDim(): number { return this.bg & BgFlags.DIM; } public isStrikethrough(): number { return this.fg & FgFlags.STRIKETHROUGH; } public isProtected(): number { return this.bg & BgFlags.PROTECTED; } + public isOverline(): number { return this.bg & BgFlags.OVERLINE; } // color modes public getFgColorMode(): number { return this.fg & Attributes.CM_MASK; } diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index da455794..f6a31be7 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -128,7 +128,8 @@ export const enum BgFlags { ITALIC = 0x4000000, DIM = 0x8000000, HAS_EXTENDED = 0x10000000, - PROTECTED = 0x20000000 + PROTECTED = 0x20000000, + OVERLINE = 0x40000000 } export const enum ExtFlags { diff --git a/src/headless/public/Terminal.test.ts b/src/headless/public/Terminal.test.ts index c7acc818..f869a991 100644 --- a/src/headless/public/Terminal.test.ts +++ b/src/headless/public/Terminal.test.ts @@ -22,7 +22,7 @@ describe('Headless API Tests', function (): void { it('Proposed API check', async () => { term = new Terminal({ allowProposedApi: false }); - throws(() => term.markers, (error) => error.message === 'You must set the allowProposedApi option to true to use proposed API'); + throws(() => term.markers, (error: any) => error.message === 'You must set the allowProposedApi option to true to use proposed API'); }); it('write', async () => { diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 2ceb6a94..741bce85 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -999,6 +999,8 @@ declare module 'xterm-headless' { isInvisible(): number; /** Whether the cell has the strikethrough attribute (CSI 9 m). */ isStrikethrough(): number; + /** Whether the cell has the overline attribute (CSI 53 m). */ + isOverline(): number; /** Whether the cell is using the RGB foreground color mode. */ isFgRGB(): boolean; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 68b37153..6a9be2f1 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1516,6 +1516,8 @@ declare module 'xterm' { isInvisible(): number; /** Whether the cell has the strikethrough attribute (CSI 9 m). */ isStrikethrough(): number; + /** Whether the cell has the overline attribute (CSI 53 m). */ + isOverline(): number; /** Whether the cell is using the RGB foreground color mode. */ isFgRGB(): boolean; diff --git a/yarn.lock b/yarn.lock index 22dd31e5..ecf9507e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -425,10 +425,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.50.tgz#14ba5198f1754ffd0472a2f84ab433b45ee0b65e" integrity sha512-+9axpWx2b2JCVovr7Ilgt96uc6C1zBKOQMpGtRbWT9IoR/8ue32GGMfGA4woP8QyP2gBs6GQWEVM3tCybGCxDA== -"@types/node@^14.14.44": - version "14.14.44" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.44.tgz#df7503e6002847b834371c004b372529f3f85215" - integrity sha512-+gaugz6Oce6ZInfI/tK4Pq5wIIkJMEJUu92RB3Eu93mtj4wjjjz9EB5mLp5s1pSsLXdC/CPut/xF20ZzAQJbTA== +"@types/node@^18.16.0": + version "18.16.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.16.tgz#3b64862856c7874ccf7439e6bab872d245c86d8e" + integrity sha512-NpaM49IGQQAUlBhHMF82QH80J08os4ZmyF9MkpCzWAGuOHqE4gTEbhzd7L3l5LmWuZ6E0OiC1FweQ4tsiW35+g== "@types/parse5@*": version "6.0.2"