diff --git a/README.md b/README.md index 94494c71..1359772c 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ Xterm.js is used in several world-class applications to provide great terminal e - [**KubeSail**](https://kubesail.com): The Self-Hosting Company - uses xterm to allow users to exec into kubernetes pods and build github apps - [**WiTTY**](https://github.com/syssecfsu/witty): Web-based interactive terminal emulator that allows users to easily record, share, and replay console sessions. - [**libv86 Terminal Forwarding**](https://github.com/hello-smile6/libv86-terminal-forwarding): Peer-to-peer SSH for the web, using WebRTC via [Bugout](https://github.com/chr15m/bugout) for data transfer and [v86](https://github.com/copy/v86) for web-based virtualization. +- [**hack.courses**](https://hack.courses): Interactive Linux and command-line classes using xterm.js to expose a real terminal available for everyone. +- [**Render**](https://render.com): Platform-as-a-service for your apps, websites, and databases using xterm.js to provide a command prompt for user containers and for streaming build and runtime logs. - [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D) Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Note: Please add any new contributions to the end of the list only. diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index 0f356e14..170a7a2a 100644 --- a/addons/xterm-addon-search/src/SearchAddon.ts +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -134,7 +134,7 @@ export class SearchAddon implements ITerminalAddon { } this._lastSearchOptions = searchOptions; if (searchOptions?.decorations) { - if (this._resultIndex !== undefined || this._cachedSearchTerm && term !== this._cachedSearchTerm) { + if (this._resultIndex !== undefined || this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm) { this._highlightAllMatches(term, searchOptions); } } @@ -288,7 +288,9 @@ export class SearchAddon implements ITerminalAddon { } if (this._searchResults) { - if (this._resultIndex === undefined) { + if (this._searchResults.size === 0) { + this._resultIndex = -1; + } else if (this._resultIndex === undefined) { this._resultIndex = 0; } else { this._resultIndex++; @@ -312,8 +314,10 @@ export class SearchAddon implements ITerminalAddon { throw new Error('Cannot use addon until it has been loaded'); } this._lastSearchOptions = searchOptions; - if (searchOptions?.decorations && (this._resultIndex !== undefined || term !== this._cachedSearchTerm)) { - this._highlightAllMatches(term, searchOptions); + if (searchOptions?.decorations) { + if (this._resultIndex !== undefined || this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm) { + this._highlightAllMatches(term, searchOptions); + } } return this._fireResults(term, this._findPreviousAndSelect(term, searchOptions), searchOptions); } @@ -408,12 +412,14 @@ export class SearchAddon implements ITerminalAddon { } if (this._searchResults) { - if (this._resultIndex === undefined || this._resultIndex < 0) { - this._resultIndex = this._searchResults?.size - 1; + 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; + this._resultIndex = this._searchResults.size - 1; } } } @@ -604,7 +610,8 @@ export class SearchAddon implements ITerminalAddon { break; } if (cell.getWidth()) { - offset += cell.getChars().length; + // Treat null characters as whitespace to align with the translateToString API + offset += cell.getCode() === 0 ? 1 : cell.getChars().length; } } lineIndex++; diff --git a/addons/xterm-addon-search/test/SearchAddon.api.ts b/addons/xterm-addon-search/test/SearchAddon.api.ts index 7cb15c5d..c1305345 100644 --- a/addons/xterm-addon-search/test/SearchAddon.api.ts +++ b/addons/xterm-addon-search/test/SearchAddon.api.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import { readFile } from 'fs'; import { resolve } from 'path'; -import { openTerminal, writeSync, launchBrowser } from '../../../out-test/api/TestUtils'; +import { openTerminal, writeSync, launchBrowser, timeout } from '../../../out-test/api/TestUtils'; import { Browser, Page } from 'playwright'; const APP = 'http://127.0.0.1:3001/test'; @@ -23,8 +23,6 @@ describe('Search Tests', function(): void { await page.setViewportSize({ width, height }); await page.goto(APP); await openTerminal(page); - await page.evaluate(`window.search = new SearchAddon();`); - await page.evaluate(`window.term.loadAddon(window.search);`); }); after(() => { @@ -32,7 +30,12 @@ describe('Search Tests', function(): void { }); beforeEach(async () => { - await page.evaluate(`window.term.reset()`); + await page.evaluate(` + window.term.reset() + window.search?.dispose(); + window.search = new SearchAddon(); + window.term.loadAddon(window.search); + `); }); it('Simple Search', async () => { @@ -120,6 +123,176 @@ describe('Search Tests', function(): void { }); }); + describe('onDidChangeResults', async () => { + describe('findNext', () => { + it('should not fire unless the decorations option is set', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await writeSync(page, 'abc'); + assert.strictEqual(await page.evaluate(`window.search.findNext('a')`), true); + assert.strictEqual(await page.evaluate('window.calls.length'), 0); + assert.strictEqual(await page.evaluate(`window.search.findNext('b', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.strictEqual(await page.evaluate('window.calls.length'), 1); + }); + it('should fire with correct event values', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await writeSync(page, 'abc bc c'); + assert.strictEqual(await page.evaluate(`window.search.findNext('a', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1, resultIndex: 0 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findNext('b', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1, resultIndex: 0 }, + { resultCount: 2, resultIndex: 0 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findNext('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1, resultIndex: 0 }, + { resultCount: 2, resultIndex: 0 }, + { resultCount: -1, resultIndex: -1 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findNext('c', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.strictEqual(await page.evaluate(`window.search.findNext('c', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.strictEqual(await page.evaluate(`window.search.findNext('c', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1, resultIndex: 0 }, + { resultCount: 2, resultIndex: 0 }, + { resultCount: -1, resultIndex: -1 }, + { resultCount: 3, resultIndex: 0 }, + { resultCount: 3, resultIndex: 1 }, + { resultCount: 3, resultIndex: 2 } + ]); + }); + it('should fire with correct event values (incremental)', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await writeSync(page, 'abc aabc'); + 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 } + ]); + assert.deepStrictEqual(await page.evaluate(`window.search.findNext('ab', { incremental: true, decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 3, resultIndex: 0 }, + { resultCount: 2, resultIndex: 0 } + ]); + assert.deepStrictEqual(await page.evaluate(`window.search.findNext('abc', { 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 } + ]); + assert.deepStrictEqual(await page.evaluate(`window.search.findNext('abc', { 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 } + ]); + 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: -1, resultIndex: -1 } + ]); + }); + }); + describe('findPrevious', () => { + it('should not fire unless the decorations option is set', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await writeSync(page, 'abc'); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('a')`), true); + assert.strictEqual(await page.evaluate('window.calls.length'), 0); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('b', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.strictEqual(await page.evaluate('window.calls.length'), 1); + }); + it('should fire with correct event values', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await writeSync(page, 'abc bc c'); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('a', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1, resultIndex: 0 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('b', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1, resultIndex: 0 }, + { resultCount: 2, resultIndex: 1 } + ]); + await timeout(2000); + assert.strictEqual(await page.evaluate(`debugger; window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1, resultIndex: 0 }, + { resultCount: 2, resultIndex: 1 }, + { resultCount: -1, resultIndex: -1 } + ]); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('c', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('c', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('c', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 1, resultIndex: 0 }, + { resultCount: 2, resultIndex: 1 }, + { resultCount: -1, resultIndex: -1 }, + { resultCount: 3, resultIndex: 2 }, + { resultCount: 3, resultIndex: 1 }, + { resultCount: 3, resultIndex: 0 } + ]); + }); + it('should fire with correct event values (incremental)', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + await writeSync(page, 'abc aabc'); + 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 } + ]); + assert.deepStrictEqual(await page.evaluate(`window.search.findPrevious('ab', { incremental: true, decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 3, resultIndex: 2 }, + { resultCount: 2, resultIndex: 1 } + ]); + assert.deepStrictEqual(await page.evaluate(`window.search.findPrevious('abc', { 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 } + ]); + assert.deepStrictEqual(await page.evaluate(`window.search.findPrevious('abc', { 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 } + ]); + 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: -1, resultIndex: -1 } + ]); + }); + }); + }); + describe('Regression tests', () => { describe('#2444 wrapped line content not being found', () => { let fixture: string; @@ -206,6 +379,21 @@ describe('Search Tests', function(): void { }); }); }); + describe('#3834 lines with null characters before search terms', () => { + // This case can be triggered by the prompt when using starship under conpty + it('should find all matches on a line containing null characters', async () => { + await page.evaluate(` + window.calls = []; + window.search.onDidChangeResults(e => window.calls.push(e)); + `); + // Move cursor forward 1 time to create a null character, as opposed to regular whitespace + await writeSync(page, '\\x1b[CHi Hi'); + assert.strictEqual(await page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true); + assert.deepStrictEqual(await page.evaluate('window.calls'), [ + { resultCount: 2, resultIndex: 1 } + ]); + }); + }); }); function makeData(length: number): string { diff --git a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts index 5c4ae437..b86b066b 100644 --- a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts +++ b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts @@ -83,7 +83,7 @@ describe('SerializeAddon', () => { const buffer3 = await page.evaluate(`inspectBuffer(term.buffer.normal);`); await page.evaluate(`term.reset();`); - await writeRawSync(page, '1234567890n12345'); + await writeRawSync(page, '123456789012345'); const buffer4 = await page.evaluate(`inspectBuffer(term.buffer.normal);`); assert.throw(() => { diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index 8edda780..ef31a959 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -455,8 +455,12 @@ export class WebglRenderer extends Disposable implements IRenderer { } y -= this._terminal.buffer.active.viewportY; if (this._model.selection.columnSelectMode) { - return x >= this._model.selection.startCol && y >= this._model.selection.viewportCappedStartRow && - x < this._model.selection.endCol && y < this._model.selection.viewportCappedEndRow; + if (this._model.selection.startCol <= this._model.selection.endCol) { + return x >= this._model.selection.startCol && y >= this._model.selection.viewportCappedStartRow && + x < this._model.selection.endCol && y <= this._model.selection.viewportCappedEndRow; + } + return x < this._model.selection.startCol && y >= this._model.selection.viewportCappedStartRow && + x >= this._model.selection.endCol && y <= this._model.selection.viewportCappedEndRow; } return (y > this._model.selection.viewportStartRow && y < this._model.selection.viewportEndRow) || (this._model.selection.viewportStartRow === this._model.selection.viewportEndRow && y === this._model.selection.viewportStartRow && x >= this._model.selection.startCol && x < this._model.selection.endCol) || diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index 34107fc5..555506fd 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -13,7 +13,7 @@ import { IDisposable } from 'xterm'; import { AttributeData } from 'common/buffer/AttributeData'; import { channels, rgba } from 'common/Color'; import { tryDrawCustomChar } from 'browser/renderer/CustomGlyphs'; -import { isPowerlineGlyph } from 'browser/renderer/RendererUtils'; +import { excludeFromContrastRatioDemands, isPowerlineGlyph } from 'browser/renderer/RendererUtils'; // For debugging purposes, it can be useful to set this to a really tiny value, // to verify that LRU eviction works. @@ -217,8 +217,8 @@ export class WebglCharAtlas implements IDisposable { } } - private _getForegroundCss(bg: number, bgColorMode: number, bgColor: number, fg: number, fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean, isPowerLineGlyph: boolean): string { - const minimumContrastCss = this._getMinimumContrastCss(bg, bgColorMode, bgColor, fg, fgColorMode, fgColor, inverse, bold, isPowerLineGlyph); + private _getForegroundCss(bg: number, bgColorMode: number, bgColor: number, fg: number, fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean, excludeFromContrastRatioDemands: boolean): string { + const minimumContrastCss = this._getMinimumContrastCss(bg, bgColorMode, bgColor, fg, fgColorMode, fgColor, inverse, bold, excludeFromContrastRatioDemands); if (minimumContrastCss) { return minimumContrastCss; } @@ -282,8 +282,8 @@ export class WebglCharAtlas implements IDisposable { } } - private _getMinimumContrastCss(bg: number, bgColorMode: number, bgColor: number, fg: number, fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean, isPowerLineGlyph: boolean): string | undefined { - if (this._config.minimumContrastRatio === 1 || isPowerLineGlyph) { + private _getMinimumContrastCss(bg: number, bgColorMode: number, bgColor: number, fg: number, fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean, excludeFromContrastRatioDemands: boolean): string | undefined { + if (this._config.minimumContrastRatio === 1 || excludeFromContrastRatioDemands) { return undefined; } @@ -322,10 +322,15 @@ export class WebglCharAtlas implements IDisposable { // 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.scaledCharWidth * Math.max(chars.length, 2) + TMP_CANVAS_GLYPH_PADDING * 2; + const allowedWidth = this._config.scaledCellWidth * Math.max(chars.length, 2) + TMP_CANVAS_GLYPH_PADDING * 2; if (this._tmpCanvas.width < allowedWidth) { this._tmpCanvas.width = allowedWidth; } + // Include line height when drawing glyphs + const allowedHeight = this._config.scaledCellHeight + TMP_CANVAS_GLYPH_PADDING * 2; + if (this._tmpCanvas.height < allowedHeight) { + this._tmpCanvas.height = allowedHeight; + } this._tmpCtx.save(); this._workAttributeData.fg = fg; @@ -372,7 +377,7 @@ export class WebglCharAtlas implements IDisposable { this._tmpCtx.textBaseline = TEXT_BASELINE; const powerLineGlyph = chars.length === 1 && isPowerlineGlyph(chars.charCodeAt(0)); - this._tmpCtx.fillStyle = this._getForegroundCss(bg, bgColorMode, bgColor, fg, fgColorMode, fgColor, inverse, bold, powerLineGlyph); + this._tmpCtx.fillStyle = this._getForegroundCss(bg, bgColorMode, bgColor, fg, fgColorMode, fgColor, inverse, bold, excludeFromContrastRatioDemands(chars.charCodeAt(0))); // Apply alpha to dim the character if (dim) { @@ -485,7 +490,7 @@ export class WebglCharAtlas implements IDisposable { */ private _findGlyphBoundingBox(imageData: ImageData, boundingBox: IBoundingBox, allowedWidth: number, restrictedGlyph: boolean, customGlyph: boolean): IRasterizedGlyph { boundingBox.top = 0; - const height = restrictedGlyph ? this._config.scaledCharHeight : this._tmpCanvas.height; + const height = restrictedGlyph ? this._config.scaledCellHeight : this._tmpCanvas.height; const width = restrictedGlyph ? this._config.scaledCharWidth : allowedWidth; let found = false; for (let y = 0; y < height; y++) { diff --git a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts index d51990a7..bf8b4cf4 100644 --- a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts +++ b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts @@ -718,9 +718,10 @@ describe('WebGL Renderer Integration Tests', async () => { window.term.options.theme = ${JSON.stringify(theme)}; window.term.options.minimumContrastRatio = 1; `); + // Block characters ignore block elements so a different char is used here await writeSync(page, - `\\x1b[30m█\\x1b[31m█\\x1b[32m█\\x1b[33m█\\x1b[34m█\\x1b[35m█\\x1b[36m█\\x1b[37m█\\r\\n` + - `\\x1b[90m█\\x1b[91m█\\x1b[92m█\\x1b[93m█\\x1b[94m█\\x1b[95m█\\x1b[96m█\\x1b[97m█` + `\\x1b[30m■\\x1b[31m■\\x1b[32m■\\x1b[33m■\\x1b[34m■\\x1b[35m■\\x1b[36m■\\x1b[37m■\\r\\n` + + `\\x1b[90m■\\x1b[91m■\\x1b[92m■\\x1b[93m■\\x1b[94m■\\x1b[95m■\\x1b[96m■\\x1b[97m■` ); // Validate before minimumContrastRatio is applied await pollFor(page, () => getCellColor(1, 1), [0x2e, 0x34, 0x36, 255]); @@ -786,9 +787,10 @@ describe('WebGL Renderer Integration Tests', async () => { window.term.options.theme = ${JSON.stringify(theme)}; window.term.options.minimumContrastRatio = 1; `); + // Block characters ignore block elements so a different char is used here await writeSync(page, - `\\x1b[30m█\\x1b[31m█\\x1b[32m█\\x1b[33m█\\x1b[34m█\\x1b[35m█\\x1b[36m█\\x1b[37m█\\r\\n` + - `\\x1b[90m█\\x1b[91m█\\x1b[92m█\\x1b[93m█\\x1b[94m█\\x1b[95m█\\x1b[96m█\\x1b[97m█` + `\\x1b[30m■\\x1b[31m■\\x1b[32m■\\x1b[33m■\\x1b[34m■\\x1b[35m■\\x1b[36m■\\x1b[37m■\\r\\n` + + `\\x1b[90m■\\x1b[91m■\\x1b[92m■\\x1b[93m■\\x1b[94m■\\x1b[95m■\\x1b[96m■\\x1b[97m■` ); // Validate before minimumContrastRatio is applied await pollFor(page, () => getCellColor(1, 1), [0x2e, 0x34, 0x36, 255]); diff --git a/demo/client.ts b/demo/client.ts index b9e52d7b..1c38af0f 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -94,7 +94,8 @@ const terminalContainer = document.getElementById('terminal-container'); const actionElements = { find: document.querySelector('#find'), findNext: document.querySelector('#find-next'), - findPrevious: document.querySelector('#find-previous') + findPrevious: document.querySelector('#find-previous'), + findResults: document.querySelector('#find-results') }; const paddingElement = document.getElementById('padding'); @@ -159,6 +160,7 @@ if (document.location.pathname === '/test') { document.getElementById('htmlserialize').addEventListener('click', htmlSerializeButtonHandler); document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler); document.getElementById('load-test').addEventListener('click', loadTest); + document.getElementById('powerline-symbol-test').addEventListener('click', powerlineSymbolTest); document.getElementById('add-decoration').addEventListener('click', addDecoration); document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler); } @@ -397,9 +399,12 @@ function initAddons(term: TerminalType): void { if (!addon.canChange) { checkbox.disabled = true; } - if(name === 'unicode11' && checkbox.checked) { + if (name === 'unicode11' && checkbox.checked) { term.unicode.activeVersion = '11'; } + if (name === 'search' && checkbox.checked) { + addon.instance.onDidChangeResults(e => updateFindResults(e)); + } addDomListener(checkbox, 'change', () => { if (checkbox.checked) { addon.instance = new addon.ctor(); @@ -410,6 +415,8 @@ function initAddons(term: TerminalType): void { }, 0); } else if (name === 'unicode11') { term.unicode.activeVersion = '11'; + } else if (name === 'search') { + addon.instance.onDidChangeResults(e => updateFindResults(e)); } } else { if (name === 'webgl') { @@ -438,6 +445,16 @@ function initAddons(term: TerminalType): void { container.appendChild(fragment); } +function updateFindResults(e: { resultIndex: number, resultCount: number } | undefined) { + let content: string; + if (e === undefined) { + content = 'undefined'; + } else { + content = `index: ${e.resultIndex}, count: ${e.resultCount}`; + } + actionElements.findResults.textContent = content; +} + function addDomListener(element: HTMLElement, type: string, handler: (...args: any[]) => any): void { element.addEventListener(type, handler); term._core.register({ dispose: () => element.removeEventListener(type, handler) }); @@ -558,6 +575,54 @@ function loadTest() { }); } +function powerlineSymbolTest() { + function s(char: string): string { + return `${char} \x1b[7m${char}\x1b[0m `; + } + term.write('\n\n\r'); + term.writeln('Standard powerline symbols:'); + term.writeln(' 0 1 2 3 4 5 6 7 8 9 A B C D E F'); + term.writeln(`0xA_ ${s('\ue0a0')}${s('\ue0a1')}${s('\ue0a2')}`); + term.writeln(`0xB_ ${s('\ue0b0')}${s('\ue0b1')}${s('\ue0b2')}${s('\ue0b3')}`); + term.writeln(''); + term.writeln( + `\x1b[7m` + + ` inverse \ue0b1 \x1b[0;40m\ue0b0` + + ` 0 \ue0b1 \x1b[30;41m\ue0b0\x1b[39m` + + ` 1 \ue0b1 \x1b[31;42m\ue0b0\x1b[39m` + + ` 2 \ue0b1 \x1b[32;43m\ue0b0\x1b[39m` + + ` 3 \ue0b1 \x1b[33;44m\ue0b0\x1b[39m` + + ` 4 \ue0b1 \x1b[34;45m\ue0b0\x1b[39m` + + ` 5 \ue0b1 \x1b[35;46m\ue0b0\x1b[39m` + + ` 6 \ue0b1 \x1b[36;47m\ue0b0\x1b[39m` + + ` 7 \ue0b1 \x1b[37;49m\ue0b0\x1b[0m` + ); + term.writeln(''); + term.writeln( + `\x1b[7m` + + ` inverse \ue0b3 \x1b[0;7;40m\ue0b2\x1b[27m` + + ` 0 \ue0b3 \x1b[7;30;41m\ue0b2\x1b[27;39m` + + ` 1 \ue0b3 \x1b[7;31;42m\ue0b2\x1b[27;39m` + + ` 2 \ue0b3 \x1b[7;32;43m\ue0b2\x1b[27;39m` + + ` 3 \ue0b3 \x1b[7;33;44m\ue0b2\x1b[27;39m` + + ` 4 \ue0b3 \x1b[7;34;45m\ue0b2\x1b[27;39m` + + ` 5 \ue0b3 \x1b[7;35;46m\ue0b2\x1b[27;39m` + + ` 6 \ue0b3 \x1b[7;36;47m\ue0b2\x1b[27;39m` + + ` 7 \ue0b3 \x1b[7;37;49m\ue0b2\x1b[0m` + ); + term.writeln(''); + term.writeln('Powerline extra symbols:'); + term.writeln(' 0 1 2 3 4 5 6 7 8 9 A B C D E F'); + term.writeln(`0xA_ ${s('\ue0a3')}`); + term.writeln(`0xB_ ${s('\ue0b4')}${s('\ue0b5')}${s('\ue0b6')}${s('\ue0b7')}${s('\ue0b8')}${s('\ue0b9')}${s('\ue0ba')}${s('\ue0bb')}${s('\ue0bc')}${s('\ue0bd')}${s('\ue0be')}${s('\ue0bf')}`); + term.writeln(`0xC_ ${s('\ue0c0')}${s('\ue0c1')}${s('\ue0c2')}${s('\ue0c3')}${s('\ue0c4')}${s('\ue0c5')}${s('\ue0c6')}${s('\ue0c7')}${s('\ue0c8')}${s('\ue0c9')}${s('\ue0ca')}${s('\ue0cb')}${s('\ue0cc')}${s('\ue0cd')}${s('\ue0be')}${s('\ue0bf')}`); + term.writeln(`0xD_ ${s('\ue0d0')}${s('\ue0d1')}${s('\ue0d2')} ${s('\ue0d4')}`); + term.writeln(''); + term.writeln('Sample of nerd fonts icons:'); + term.writeln(' nf-linux-apple (\\uF302) \uf302'); + term.writeln('nf-mdi-github_face (\\uFbd9) \ufbd9'); +} + function addDecoration() { term.options['overviewRulerWidth'] = 15; const marker = term.addMarker(1); diff --git a/demo/index.html b/demo/index.html index f36fc629..23f78aa6 100644 --- a/demo/index.html +++ b/demo/index.html @@ -40,6 +40,7 @@
+
Results:
@@ -66,11 +67,21 @@

Test

- - - - - +
+
Lifecycle
+
+ +
Performance
+
+ +
Styles
+
+
+ +
Decorations
+
+
+
diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index c24b1330..1ba9c732 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -703,10 +703,13 @@ export class Terminal extends CoreTerminal implements ITerminal { but = ev.button < 3 ? ev.button : CoreMouseButton.NONE; break; case 'wheel': - // only UP/DOWN wheel events are respected - if ((ev as WheelEvent).deltaY !== 0) { - action = (ev as WheelEvent).deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN; + const amount = self.viewport!.getLinesScrolled(ev as WheelEvent); + + if (amount === 0) { + return false; } + + action = (ev as WheelEvent).deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN; but = CoreMouseButton.WHEEL; break; default: @@ -1096,14 +1099,17 @@ export class Terminal extends CoreTerminal implements ITerminal { return false; } - if (!this._compositionHelper!.keydown(event)) { + // Ignore composing with Alt key on Mac when macOptionIsMeta is enabled + const shouldIgnoreComposition = this.browser.isMac && this.options.macOptionIsMeta && event.altKey; + + if (!shouldIgnoreComposition && !this._compositionHelper!.keydown(event)) { if (this.buffer.ybase !== this.buffer.ydisp) { this._bufferService.scrollToBottom(); } return false; } - if (event.key === 'Dead' || event.key === 'AltGraph') { + if (!shouldIgnoreComposition && (event.key === 'Dead' || event.key === 'AltGraph')) { this._unprocessedDeadKey = true; } @@ -1334,7 +1340,7 @@ export class Terminal extends CoreTerminal implements ITerminal { // Don't clear if it's already clear return; } - this.buffer.clearAllMarkers(0); + this.buffer.clearAllMarkers(); 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 1ba175f3..92f90a1d 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -262,7 +262,7 @@ export class MockBuffer implements IBuffer { public clearMarkers(y: number): void { throw new Error('Method not implemented.'); } - public clearAllMarkers(excludeY: number): void { + public clearAllMarkers(): void { throw new Error('Method not implemented.'); } } diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index f31409ca..22f3ddf5 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -88,6 +88,7 @@ export class OverviewRulerRenderer extends Disposable { })); this.register(this._bufferService.onScroll(() => { if (this._lastKnownBufferLength !== this._bufferService.buffers.normal.lines.length) { + this._refreshDrawHeightConstants(); this._refreshColorZonePadding(); } })); @@ -132,10 +133,7 @@ export class OverviewRulerRenderer extends Disposable { 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); + this._refreshDrawHeightConstants(); // x drawX.full = 0; drawX.left = 0; @@ -143,6 +141,17 @@ export class OverviewRulerRenderer extends Disposable { drawX.right = drawWidth.left + drawWidth.center; } + private _refreshDrawHeightConstants(): void { + drawHeight.full = Math.round(2 * window.devicePixelRatio); + // Calculate actual pixels per line + const pixelsPerLine = this._canvas.height / this._bufferService.buffer.lines.length; + // Clamp actual pixels within a range + const nonFullHeight = Math.round(Math.max(Math.min(pixelsPerLine, 12), 6) * window.devicePixelRatio); + drawHeight.left = nonFullHeight; + drawHeight.center = nonFullHeight; + drawHeight.right = nonFullHeight; + } + private _refreshColorZonePadding(): void { this._colorZoneStore.setPadding({ full: Math.floor(this._bufferService.buffers.active.lines.length / (this._canvas.height - 1) * drawHeight.full), diff --git a/src/browser/renderer/BaseRenderLayer.ts b/src/browser/renderer/BaseRenderLayer.ts index ead0844f..0a9b8057 100644 --- a/src/browser/renderer/BaseRenderLayer.ts +++ b/src/browser/renderer/BaseRenderLayer.ts @@ -14,7 +14,7 @@ import { AttributeData } from 'common/buffer/AttributeData'; import { IColorSet } from 'browser/Types'; import { CellData } from 'common/buffer/CellData'; import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services'; -import { isPowerlineGlyph, throwIfFalsy } from 'browser/renderer/RendererUtils'; +import { excludeFromContrastRatioDemands, throwIfFalsy } from 'browser/renderer/RendererUtils'; import { channels, color, rgba } from 'common/Color'; import { removeElementFromParent } from 'browser/Dom'; import { tryDrawCustomChar } from 'browser/renderer/CustomGlyphs'; @@ -473,7 +473,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { } } - if (!bgOverride && !fgOverride && (this._optionsService.rawOptions.minimumContrastRatio === 1 || isPowerlineGlyph(cell.getCode()))) { + if (!bgOverride && !fgOverride && (this._optionsService.rawOptions.minimumContrastRatio === 1 || excludeFromContrastRatioDemands(cell.getCode()))) { return undefined; } diff --git a/src/browser/renderer/CustomGlyphs.ts b/src/browser/renderer/CustomGlyphs.ts index c2bfc210..360969cb 100644 --- a/src/browser/renderer/CustomGlyphs.ts +++ b/src/browser/renderer/CustomGlyphs.ts @@ -325,6 +325,37 @@ export const boxDrawingDefinitions: { [character: string]: { [fontWeight: number '╰': { [Style.NORMAL]: 'C.5,0,.5,.5,1,.5' } }; +interface IVectorShape { + d: string; + type: VectorType; + /** Padding to apply to the vector's x axis in CSS pixels. */ + horizontalPadding?: number; +} + +const enum VectorType { + FILL, + STROKE +} + +/** + * This contains the definitions of the primarily used box drawing characters as vector shapes. The + * reason these characters are defined specially is to avoid common problems if a user's font has + * not been patched with powerline characters and also to get pixel perfect rendering as rendering + * issues can occur around AA/SPAA. + * + * Original symbols defined in https://github.com/powerline/fontpatcher + */ +export const powerlineDefinitions: { [index: string]: IVectorShape } = { + // Right triangle solid + '\u{E0B0}': { d: 'M0,0 L1,.5 L0,1', type: VectorType.FILL }, + // Right triangle line + '\u{E0B1}': { d: 'M0,0 L1,.5 L0,1', type: VectorType.STROKE, horizontalPadding: 0.5 }, + // Left triangle solid + '\u{E0B2}': { d: 'M1,0 L0,.5 L1,1', type: VectorType.FILL }, + // Left triangle line + '\u{E0B3}': { d: 'M1,0 L0,.5 L1,1', type: VectorType.STROKE, horizontalPadding: 0.5 } +}; + /** * Try drawing a custom block element or box drawing character, returning whether it was * successfully drawn. @@ -355,6 +386,12 @@ export function tryDrawCustomChar( return true; } + const powerlineDefinition = powerlineDefinitions[c]; + if (powerlineDefinition) { + drawPowerlineChar(ctx, powerlineDefinition, xOffset, yOffset, scaledCellWidth, scaledCellHeight); + return true; + } + return false; } @@ -518,6 +555,38 @@ function drawBoxDrawingChar( } } +function drawPowerlineChar( + ctx: CanvasRenderingContext2D, + charDefinition: IVectorShape, + xOffset: number, + yOffset: number, + scaledCellWidth: number, + scaledCellHeight: number +): void { + ctx.beginPath(); + ctx.lineWidth = window.devicePixelRatio; + for (const instruction of charDefinition.d.split(' ')) { + const type = instruction[0]; + const f = svgToCanvasInstructionMap[type]; + if (!f) { + console.error(`Could not find drawing instructions for "${type}"`); + continue; + } + const args: string[] = instruction.substring(1).split(','); + if (!args[0] || !args[1]) { + continue; + } + f(ctx, translateArgs(args, scaledCellWidth, scaledCellHeight, xOffset, yOffset, charDefinition.horizontalPadding)); + } + if (charDefinition.type === VectorType.STROKE) { + ctx.strokeStyle = ctx.fillStyle; + ctx.stroke(); + } else { + ctx.fill(); + } + ctx.closePath(); +} + function clamp(value: number, max: number, min: number = 0): number { return Math.max(Math.min(value, max), min); } @@ -528,7 +597,7 @@ const svgToCanvasInstructionMap: { [index: string]: any } = { 'M': (ctx: CanvasRenderingContext2D, args: number[]) => ctx.moveTo(args[0], args[1]) }; -function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number): number[] { +function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, horizontalPadding: number = 0): number[] { const result = args.map(e => parseFloat(e) || parseInt(e)); if (result.length < 2) { @@ -537,14 +606,14 @@ function translateArgs(args: string[], cellWidth: number, cellHeight: number, xO for (let x = 0; x < result.length; x += 2) { // Translate from 0-1 to 0-cellWidth - result[x] *= cellWidth; + result[x] *= cellWidth - (horizontalPadding * 2 * window.devicePixelRatio); // Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp // line at 100% devicePixelRatio if (result[x] !== 0) { result[x] = clamp(Math.round(result[x] + 0.5) - 0.5, cellWidth, 0); } // Apply the cell's offset (ie. x*cellWidth) - result[x] += xOffset; + result[x] += xOffset + (horizontalPadding * window.devicePixelRatio); } for (let y = 1; y < result.length; y += 2) { diff --git a/src/browser/renderer/RendererUtils.ts b/src/browser/renderer/RendererUtils.ts index 3fc2bb3b..0a4a77e8 100644 --- a/src/browser/renderer/RendererUtils.ts +++ b/src/browser/renderer/RendererUtils.ts @@ -14,5 +14,13 @@ export function isPowerlineGlyph(codepoint: number): boolean { // Only return true for Powerline symbols which require // different padding and should be excluded from minimum contrast // ratio standards - return 0xE0A0 <= codepoint && codepoint <= 0xE0D6; + return 0xE0A4 <= codepoint && codepoint <= 0xE0D6; +} + +function isBoxOrBlockGlyph(codepoint: number): boolean { + return (0x2500 <= codepoint && codepoint <= 0x259F); +} + +export function excludeFromContrastRatioDemands(codepoint: number): boolean { + return isPowerlineGlyph(codepoint) || isBoxOrBlockGlyph(codepoint); } diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index fdeef91c..248f37ac 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -226,7 +226,7 @@ export class DomRenderer extends Disposable implements IRenderer { `}` + `${this._terminalSelector} .${SELECTION_CLASS} div {` + ` position: absolute;` + - ` background-color: ${this._colors.selectionTransparent.css};` + + ` background-color: ${this._colors.selectionOpaque.css};` + `}`; // Colors this._colors.ansi.forEach((c, i) => { @@ -304,8 +304,9 @@ export class DomRenderer extends Disposable implements IRenderer { const documentFragment = document.createDocumentFragment(); if (columnSelectMode) { + const isXFlipped = start[0] > end[0]; documentFragment.appendChild( - this._createSelectionElement(viewportCappedStartRow, start[0], end[0], viewportCappedEndRow - viewportCappedStartRow + 1) + this._createSelectionElement(viewportCappedStartRow, isXFlipped ? end[0] : start[0], isXFlipped ? start[0] : end[0], viewportCappedEndRow - viewportCappedStartRow + 1) ); } else { // Draw first row diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 5a374b14..db5d258e 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -12,7 +12,7 @@ import { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; import { MockCoreService, MockDecorationService, MockOptionsService } from 'common/TestUtils.test'; import { css } from 'common/Color'; -import { MockCharacterJoinerService, MockSelectionService } from 'browser/TestUtils.test'; +import { MockCharacterJoinerService } from 'browser/TestUtils.test'; describe('DomRendererRowFactory', () => { let dom: jsdom.JSDOM; @@ -184,7 +184,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); @@ -195,7 +195,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); @@ -205,7 +205,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); @@ -230,7 +230,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); @@ -241,7 +241,27 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' + ); + }); + }); + + describe('selectionForeground', () => { + it('should force selected cells with content to be rendered above the background', () => { + lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); + lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); + rowFactory.onSelectionChanged([1, 0], [2, 0], false); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20); + assert.equal(getFragmentHtml(fragment), + 'ab' + ); + }); + it('should force whitespace cells to be rendered above the background', () => { + lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); + rowFactory.onSelectionChanged([0, 0], [2, 0], false); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20); + assert.equal(getFragmentHtml(fragment), + ' a' ); }); }); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 266d7587..fadf5032 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -12,7 +12,7 @@ import { color, rgba } from 'common/Color'; import { IColorSet } from 'browser/Types'; import { ICharacterJoinerService, ISelectionService } from 'browser/services/Services'; import { JoinedCellData } from 'browser/services/CharacterJoinerService'; -import { isPowerlineGlyph } from 'browser/renderer/RendererUtils'; +import { excludeFromContrastRatioDemands } from 'browser/renderer/RendererUtils'; export const BOLD_CLASS = 'xterm-bold'; export const DIM_CLASS = 'xterm-dim'; @@ -206,19 +206,49 @@ export class DomRendererRowFactory { } // Apply selection foreground if applicable + const isInSelection = this._isCellInSelection(x, row); if (!isTop) { - if (this._colors.selectionForeground && this._isCellInSelection(x, row)) { + if (this._colors.selectionForeground && isInSelection) { fgColorMode = Attributes.CM_RGB; fg = this._colors.selectionForeground.rgba >> 8 & 0xFFFFFF; fgOverride = this._colors.selectionForeground; } } + // If in the selection, force the element to be above the selection to improve contrast and + // support opaque selections + if (isInSelection) { + bgOverride = this._colors.selectionOpaque; + isTop = true; + } + // If it's a top decoration, render above the selection if (isTop) { charElement.classList.add(`xterm-decoration-top`); } + // Background + let resolvedBg: IColor; + switch (bgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + resolvedBg = this._colors.ansi[bg]; + charElement.classList.add(`xterm-bg-${bg}`); + break; + case Attributes.CM_RGB: + resolvedBg = rgba.toColor(bg >> 16, bg >> 8 & 0xFF, bg & 0xFF); + this._addStyle(charElement, `background-color:#${padStart((bg >>> 0).toString(16), '0', 6)}`); + break; + case Attributes.CM_DEFAULT: + default: + if (isInverse) { + resolvedBg = this._colors.foreground; + charElement.classList.add(`xterm-bg-${INVERTED_DEFAULT_COLOR}`); + } else { + resolvedBg = this._colors.background; + } + } + // Foreground switch (fgColorMode) { case Attributes.CM_P16: @@ -226,7 +256,7 @@ export class DomRendererRowFactory { if (cell.isBold() && fg < 8 && this._optionsService.rawOptions.drawBoldTextInBrightColors) { fg += 8; } - if (!this._applyMinimumContrast(charElement, this._colors.background, this._colors.ansi[fg], cell, undefined, undefined)) { + if (!this._applyMinimumContrast(charElement, resolvedBg, this._colors.ansi[fg], cell, bgOverride, undefined)) { charElement.classList.add(`xterm-fg-${fg}`); } break; @@ -236,35 +266,19 @@ export class DomRendererRowFactory { (fg >> 8) & 0xFF, (fg ) & 0xFF ); - if (!this._applyMinimumContrast(charElement, this._colors.background, color, cell, bgOverride, fgOverride)) { + if (!this._applyMinimumContrast(charElement, resolvedBg, color, cell, bgOverride, fgOverride)) { this._addStyle(charElement, `color:#${padStart(fg.toString(16), '0', 6)}`); } break; case Attributes.CM_DEFAULT: default: - if (!this._applyMinimumContrast(charElement, this._colors.background, this._colors.foreground, cell, undefined, undefined)) { + if (!this._applyMinimumContrast(charElement, resolvedBg, this._colors.foreground, cell, bgOverride, undefined)) { if (isInverse) { charElement.classList.add(`xterm-fg-${INVERTED_DEFAULT_COLOR}`); } } } - // Background - switch (bgColorMode) { - case Attributes.CM_P16: - case Attributes.CM_P256: - charElement.classList.add(`xterm-bg-${bg}`); - break; - case Attributes.CM_RGB: - this._addStyle(charElement, `background-color:#${padStart((bg >>> 0).toString(16), '0', 6)}`); - break; - case Attributes.CM_DEFAULT: - default: - if (isInverse) { - charElement.classList.add(`xterm-bg-${INVERTED_DEFAULT_COLOR}`); - } - } - fragment.appendChild(charElement); x = lastCharX; @@ -273,22 +287,20 @@ export class DomRendererRowFactory { } private _applyMinimumContrast(element: HTMLElement, bg: IColor, fg: IColor, cell: ICellData, bgOverride: IColor | undefined, fgOverride: IColor | undefined): boolean { - if (this._optionsService.rawOptions.minimumContrastRatio === 1 || isPowerlineGlyph(cell.getCode())) { + if (this._optionsService.rawOptions.minimumContrastRatio === 1 || excludeFromContrastRatioDemands(cell.getCode())) { return false; } // Try get from cache first, only use the cache when there are no decoration overrides let adjustedColor: IColor | undefined | null = undefined; - if (!bgOverride || !fgOverride) { - adjustedColor = this._colors.contrastCache.getColor(this._workCell.bg, this._workCell.fg); + if (!bgOverride && !fgOverride) { + adjustedColor = this._colors.contrastCache.getColor(bg.rgba, fg.rgba); } // Calculate and store in cache if (adjustedColor === undefined) { adjustedColor = color.ensureContrastRatio(bgOverride || bg, fgOverride || fg, this._optionsService.rawOptions.minimumContrastRatio); - if (!bgOverride || !fgOverride) { - this._colors.contrastCache.setColor(this._workCell.bg, this._workCell.fg, adjustedColor ?? null); - } + this._colors.contrastCache.setColor((bgOverride || bg).rgba, (fgOverride || fg).rgba, adjustedColor ?? null); } if (adjustedColor) { @@ -310,8 +322,12 @@ export class DomRendererRowFactory { return false; } if (this._columnSelectMode) { - return x >= start[0] && y >= start[1] && - x < end[0] && y < end[1]; + if (start[0] <= end[0]) { + return x >= start[0] && y >= start[1] && + x < end[0] && y <= end[1]; + } + return x < start[0] && y >= start[1] && + x >= end[0] && y <= end[1]; } return (y > start[1] && y < end[1]) || (start[1] === end[1] && y === start[1] && x >= start[0] && x < end[0]) || diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 57ba048f..6b5c1425 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -207,8 +207,12 @@ export class SelectionService extends Disposable implements ISelectionService { return ''; } + // For column selection it's not enough to rely on final selection's swapping of reversed + // values, it also needs the x coordinates to swap independently of the y coordinate is needed + const startCol = start[0] < end[0] ? start[0] : end[0]; + const endCol = start[0] < end[0] ? end[0] : start[0]; for (let i = start[1]; i <= end[1]; i++) { - const lineText = buffer.translateBufferLineToString(i, true, start[0], end[0]); + const lineText = buffer.translateBufferLineToString(i, true, startCol, endCol); result.push(lineText); } } else { diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index c48b23ea..1ff8f6df 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -50,6 +50,7 @@ export interface IKeyboardEvent { keyCode: number; key: string; type: string; + code: string; } export interface IScrollEvent { diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index ab295784..c8b0d1b2 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -601,16 +601,13 @@ export class Buffer implements IBuffer { } /** - * Clears markers on all lines except for those on a particular line. - * @param excludeY The line to exclude. + * Clears markers on all lines */ - public clearAllMarkers(excludeY: number): void { + public clearAllMarkers(): 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[i].dispose(); + this.markers.splice(i--, 1); } this._isClearing = false; } @@ -671,7 +668,7 @@ export class Buffer implements IBuffer { export class BufferStringIterator implements IBufferStringIterator { private _current: number; - constructor ( + constructor( private _buffer: IBuffer, private _trimRight: boolean, private _startIndex: number = 0, diff --git a/src/common/buffer/Types.d.ts b/src/common/buffer/Types.d.ts index f26b4b26..421ddd37 100644 --- a/src/common/buffer/Types.d.ts +++ b/src/common/buffer/Types.d.ts @@ -10,7 +10,7 @@ import { IEvent } from 'common/EventEmitter'; export type BufferIndex = [number, number]; export interface IBufferStringIteratorResult { - range: {first: number, last: number}; + range: { first: number, last: number }; content: string; } @@ -46,7 +46,7 @@ export interface IBuffer { getWhitespaceCell(attr?: IAttributeData): ICellData; addMarker(y: number): IMarker; clearMarkers(y: number): void; - clearAllMarkers(excludeY: number): void; + clearAllMarkers(): void; } export interface IBufferSet extends IDisposable { diff --git a/src/common/input/Keyboard.test.ts b/src/common/input/Keyboard.test.ts index ac78ce3c..68fbaac1 100644 --- a/src/common/input/Keyboard.test.ts +++ b/src/common/input/Keyboard.test.ts @@ -13,6 +13,7 @@ function testEvaluateKeyboardEvent(partialEvent: { shiftKey?: boolean; metaKey?: boolean; keyCode?: number; + code?: string; key?: string; type?: string; }, partialOptions: { @@ -26,6 +27,7 @@ function testEvaluateKeyboardEvent(partialEvent: { shiftKey: partialEvent.shiftKey || false, metaKey: partialEvent.metaKey || false, keyCode: partialEvent.keyCode !== undefined ? partialEvent.keyCode : 0, + code: partialEvent.code || '', key: partialEvent.key || '', type: partialEvent.type || '' }; @@ -313,5 +315,9 @@ describe('Keyboard', () => { assert.equal(testEvaluateKeyboardEvent({ shiftKey: true, keyCode: 49, key: '!' }).key, '!'); }); + it('should return proper sequence for ctrl+@', () => { + assert.equal(testEvaluateKeyboardEvent({ ctrlKey: true, shiftKey: true, keyCode: 50, key: '@' }).key, '\x00'); + }); + }); }); diff --git a/src/common/input/Keyboard.ts b/src/common/input/Keyboard.ts index 6d916a38..0481ad99 100644 --- a/src/common/input/Keyboard.ts +++ b/src/common/input/Keyboard.ts @@ -358,7 +358,23 @@ export function evaluateKeyboardEvent( result.key = C0.ESC + key; } else if (ev.keyCode >= 65 && ev.keyCode <= 90) { const keyCode = ev.ctrlKey ? ev.keyCode - 64 : ev.keyCode + 32; - result.key = C0.ESC + String.fromCharCode(keyCode); + let keyString = String.fromCharCode(keyCode); + if (ev.shiftKey) { + keyString = keyString.toUpperCase(); + } + result.key = C0.ESC + keyString; + } else if (ev.key === 'Dead' && ev.code.startsWith('Key')) { + // Reference: https://github.com/xtermjs/xterm.js/issues/3725 + // Alt will produce a "dead key" (initate composition) with some + // of the letters in US layout (e.g. N/E/U). + // It's safe to match against Key* since no other `code` values begin with "Key". + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values#code_values_on_mac + let keyString = ev.code.slice(3, 4); + if (!ev.shiftKey) { + keyString = keyString.toLowerCase(); + } + result.key = C0.ESC + keyString; + result.cancel = true; } } else if (isMac && !ev.altKey && !ev.ctrlKey && !ev.shiftKey && ev.metaKey) { if (ev.keyCode === 65) { // cmd + a @@ -371,6 +387,9 @@ export function evaluateKeyboardEvent( if (ev.key === '_') { // ^_ result.key = C0.US; } + if (ev.key === '@') { // ^ + shift + 2 = ^ + @ + result.key = C0.NUL; + } } break; }