diff --git a/addons/xterm-addon-attach/src/AttachAddon.ts b/addons/xterm-addon-attach/src/AttachAddon.ts index 279d1b2e..035807ae 100644 --- a/addons/xterm-addon-attach/src/AttachAddon.ts +++ b/addons/xterm-addon-attach/src/AttachAddon.ts @@ -41,7 +41,9 @@ export class AttachAddon implements ITerminalAddon { } public dispose(): void { - this._disposables.forEach(d => d.dispose()); + for (const d of this._disposables) { + d.dispose(); + } } private _sendData(data: string): void { diff --git a/addons/xterm-addon-attach/test/AttachAddon.api.ts b/addons/xterm-addon-attach/test/AttachAddon.api.ts index 41e579f5..2dea645f 100644 --- a/addons/xterm-addon-attach/test/AttachAddon.api.ts +++ b/addons/xterm-addon-attach/test/AttachAddon.api.ts @@ -18,7 +18,7 @@ describe('AttachAddon', () => { before(async function(): Promise { const browserType = getBrowserType(); browser = await browserType.launch({ - headless: process.argv.indexOf('--headless') !== -1 + headless: process.argv.includes('--headless') }); page = await (await browser.newContext()).newPage(); await page.setViewportSize({ width, height }); diff --git a/addons/xterm-addon-fit/test/FitAddon.api.ts b/addons/xterm-addon-fit/test/FitAddon.api.ts index 1111dff0..5a5b3264 100644 --- a/addons/xterm-addon-fit/test/FitAddon.api.ts +++ b/addons/xterm-addon-fit/test/FitAddon.api.ts @@ -18,7 +18,7 @@ describe('FitAddon', () => { before(async function(): Promise { const browserType = getBrowserType(); browser = await browserType.launch({ - headless: process.argv.indexOf('--headless') !== -1 + headless: process.argv.includes('--headless') }); page = await (await browser.newContext()).newPage(); await page.setViewportSize({ width, height }); @@ -45,7 +45,7 @@ describe('FitAddon', () => { describe('proposeDimensions', () => { afterEach(async () => { - return unloadFit(); + return await unloadFit(); }); it('default', async function(): Promise { @@ -84,7 +84,7 @@ describe('FitAddon', () => { describe('fit', () => { afterEach(async () => { - return unloadFit(); + return await unloadFit(); }); it('default', async function(): Promise { diff --git a/addons/xterm-addon-ligatures/src/parse.ts b/addons/xterm-addon-ligatures/src/parse.ts index 5ad7747f..6289e468 100644 --- a/addons/xterm-addon-ligatures/src/parse.ts +++ b/addons/xterm-addon-ligatures/src/parse.ts @@ -68,7 +68,7 @@ function parseString(context: IParseContext, quoteChar: '\'' | '"'): string { while (context.offset < context.input.length) { const char = context.input[context.offset++]; if (escaped) { - if (/[0-9a-fA-F]/.test(char)) { + if (/[\dA-Fa-f]/.test(char)) { // Unicode escape context.offset--; str += parseUnicode(context); @@ -107,7 +107,7 @@ function parseIdentifier(context: IParseContext): string { while (context.offset < context.input.length) { const char = context.input[context.offset++]; if (escaped) { - if (/[0-9a-fA-F]/.test(char)) { + if (/[\dA-Fa-f]/.test(char)) { // Unicode escape context.offset--; str += parseUnicode(context); @@ -156,7 +156,7 @@ function parseUnicode(context: IParseContext): string { // of the escape and is swallowed. return unicodeToString(str); } - if (str.length >= 6 || !/[0-9a-fA-F]/.test(char)) { + if (str.length >= 6 || !/[\dA-Fa-f]/.test(char)) { // If the next character is not a valid hex digit or we have reached the // maximum of 6 digits in the escape, terminate the escape. context.offset--; diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index efc12662..64e89bb3 100644 --- a/addons/xterm-addon-search/src/SearchAddon.ts +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -240,8 +240,8 @@ export class SearchAddon implements ITerminalAddon { * @param term the substring that starts at searchIndex */ private _isWholeWord(searchIndex: number, line: string, term: string): boolean { - return (((searchIndex === 0) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex - 1]) !== -1)) && - (((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex + term.length]) !== -1))); + return ((searchIndex === 0) || (NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) && + (((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.includes(line[searchIndex + term.length]))); } /** @@ -262,7 +262,7 @@ export class SearchAddon implements ITerminalAddon { // Ignore wrapped lines, only consider on unwrapped line (first row of command string). const firstLine = terminal.buffer.active.getLine(row); - if (firstLine && firstLine.isWrapped) { + if (firstLine?.isWrapped) { if (isReverseSearch) { searchPosition.startCol += terminal.cols; return; diff --git a/addons/xterm-addon-search/test/SearchAddon.api.ts b/addons/xterm-addon-search/test/SearchAddon.api.ts index 3707aecd..b92dbc3a 100644 --- a/addons/xterm-addon-search/test/SearchAddon.api.ts +++ b/addons/xterm-addon-search/test/SearchAddon.api.ts @@ -20,7 +20,7 @@ describe('Search Tests', function(): void { before(async function(): Promise { const browserType = getBrowserType(); browser = await browserType.launch({ - headless: process.argv.indexOf('--headless') !== -1 + headless: process.argv.includes('--headless') }); page = await (await browser.newContext()).newPage(); await page.setViewportSize({ width, height }); diff --git a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts index 47af9d91..d472d753 100644 --- a/addons/xterm-addon-serialize/test/SerializeAddon.api.ts +++ b/addons/xterm-addon-serialize/test/SerializeAddon.api.ts @@ -34,7 +34,7 @@ describe('SerializeAddon', () => { before(async function(): Promise { const browserType = getBrowserType(); browser = await browserType.launch({ - headless: process.argv.indexOf('--headless') !== -1 + headless: process.argv.includes('--headless') }); page = await (await browser.newContext()).newPage(); await page.setViewportSize({ width, height }); diff --git a/addons/xterm-addon-unicode11/test/Unicode11Addon.api.ts b/addons/xterm-addon-unicode11/test/Unicode11Addon.api.ts index d0e09da2..7369eeaa 100644 --- a/addons/xterm-addon-unicode11/test/Unicode11Addon.api.ts +++ b/addons/xterm-addon-unicode11/test/Unicode11Addon.api.ts @@ -18,7 +18,7 @@ describe('Unicode11Addon', () => { before(async function(): Promise { const browserType = getBrowserType(); browser = await browserType.launch({ - headless: process.argv.indexOf('--headless') !== -1 + headless: process.argv.includes('--headless') }); page = await (await browser.newContext()).newPage(); await page.setViewportSize({ width, height }); diff --git a/addons/xterm-addon-web-links/src/WebLinkProvider.ts b/addons/xterm-addon-web-links/src/WebLinkProvider.ts index 487d5fe6..f0caf974 100644 --- a/addons/xterm-addon-web-links/src/WebLinkProvider.ts +++ b/addons/xterm-addon-web-links/src/WebLinkProvider.ts @@ -41,7 +41,7 @@ export class WebLinkProvider implements ILinkProvider { } export class LinkComputer { - public static computeLink(y: number, regex: RegExp, terminal: Terminal, handler: (event: MouseEvent, uri: string) => void): ILink[] { + public static computeLink(y: number, regex: RegExp, terminal: Terminal, activate: (event: MouseEvent, uri: string) => void): ILink[] { const rex = new RegExp(regex.source, (regex.flags || '') + 'g'); const [line, startLineIndex] = LinkComputer._translateBufferLineToStringWithWrap(y - 1, false, terminal); @@ -89,7 +89,7 @@ export class LinkComputer { } }; - result.push({ range, text, activate: handler }); + result.push({ range, text, activate }); } return result; diff --git a/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts b/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts index 6e42ff96..47fd7911 100644 --- a/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts +++ b/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts @@ -18,7 +18,7 @@ describe('WebLinksAddon', () => { before(async function(): Promise { const browserType = getBrowserType(); browser = await browserType.launch({ - headless: process.argv.indexOf('--headless') !== -1 + headless: process.argv.includes('--headless') }); page = await (await browser.newContext()).newPage(); await page.setViewportSize({ width, height }); diff --git a/addons/xterm-addon-webgl/README.md b/addons/xterm-addon-webgl/README.md index 026a738e..67fafe7b 100644 --- a/addons/xterm-addon-webgl/README.md +++ b/addons/xterm-addon-webgl/README.md @@ -1,8 +1,6 @@ ## xterm-addon-webgl -An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables a WebGL-based renderer. This addon requires xterm.js v4+. - -⚠️ This is an experimental addon that is [missing some features and may be unstable](https://github.com/xtermjs/xterm.js/issues?q=is%3Aopen+is%3Aissue+label%3Aarea%2Faddon%2Fwebgl) ⚠️ +An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables a WebGL2-based renderer. This addon requires xterm.js v4+. ### Install @@ -21,3 +19,18 @@ terminal.loadAddon(new WebglAddon()); ``` See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts) for more advanced usage. + +### Handling Context Loss + +The browser may drop WebGL contexts for various reasons like OOM or after the system has been suspended. There is an API exposed that fires the `webglcontextlost` event fired on the canvas so embedders can handle it however they wish. An easy, but suboptimal way, to handle this is by disposing of WebglAddon when the event fires: + +```ts +const terminal = new Terminal(); +const addon = new WebglAddon(); +addon.onContextLoss(e => { + addon.dispose(); +}); +terminal.loadAddon(addon); +``` + +Read more about handling WebGL context losses on the [Khronos wiki](https://www.khronos.org/webgl/wiki/HandlingContextLost). diff --git a/addons/xterm-addon-webgl/src/WebglAddon.ts b/addons/xterm-addon-webgl/src/WebglAddon.ts index aef1301e..c07bc0d7 100644 --- a/addons/xterm-addon-webgl/src/WebglAddon.ts +++ b/addons/xterm-addon-webgl/src/WebglAddon.ts @@ -3,14 +3,17 @@ * @license MIT */ -import { Terminal, ITerminalAddon } from 'xterm'; +import { Terminal, ITerminalAddon, IEvent } from 'xterm'; import { WebglRenderer } from './WebglRenderer'; import { IRenderService } from 'browser/services/Services'; import { IColorSet } from 'browser/Types'; +import { EventEmitter } from 'common/EventEmitter'; export class WebglAddon implements ITerminalAddon { private _terminal?: Terminal; private _renderer?: WebglRenderer; + private _onContextLoss = new EventEmitter(); + public get onContextLoss(): IEvent { return this._onContextLoss.event; } constructor( private _preserveDrawingBuffer?: boolean @@ -24,6 +27,7 @@ export class WebglAddon implements ITerminalAddon { const renderService: IRenderService = (terminal)._core._renderService; const colors: IColorSet = (terminal)._core._colorManager.colors; this._renderer = new WebglRenderer(terminal, colors, this._preserveDrawingBuffer); + this._renderer.onContextLoss(() => this._onContextLoss.fire()); renderService.setRenderer(this._renderer); } diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index 3bf74242..08f83b52 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -19,6 +19,7 @@ import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/rende import { ITerminal, IColorSet } from 'browser/Types'; import { EventEmitter } from 'common/EventEmitter'; import { CellData } from 'common/buffer/CellData'; +import { addDisposableDomListener } from 'browser/Lifecycle'; export class WebglRenderer extends Disposable implements IRenderer { private _renderLayers: IRenderLayer[]; @@ -41,6 +42,9 @@ export class WebglRenderer extends Disposable implements IRenderer { private _onRequestRedraw = new EventEmitter(); public get onRequestRedraw(): IEvent { return this._onRequestRedraw.event; } + private _onContextLoss = new EventEmitter(); + public get onContextLoss(): IEvent { return this._onContextLoss.event; } + constructor( private _terminal: Terminal, private _colors: IColorSet, @@ -82,6 +86,9 @@ export class WebglRenderer extends Disposable implements IRenderer { if (!this._gl) { throw new Error('WebGL2 not supported ' + this._gl); } + + this.register(addDisposableDomListener(this._canvas, 'webglcontextlost', (e) => { this._onContextLoss.fire(e); })); + this._core.screenElement!.appendChild(this._canvas); this._rectangleRenderer = new RectangleRenderer(this._terminal, this._colors, this._gl, this.dimensions); @@ -94,7 +101,9 @@ export class WebglRenderer extends Disposable implements IRenderer { } public dispose(): void { - this._renderLayers.forEach(l => l.dispose()); + for (const l of this._renderLayers) { + l.dispose(); + } this._core.screenElement!.removeChild(this._canvas); super.dispose(); } @@ -106,10 +115,10 @@ export class WebglRenderer extends Disposable implements IRenderer { public setColors(colors: IColorSet): void { this._colors = colors; // Clear layers and force a full render - this._renderLayers.forEach(l => { + for (const l of this._renderLayers) { l.setColors(this._terminal, this._colors); l.reset(this._terminal); - }); + } this._rectangleRenderer.setColors(); this._glyphRenderer.setColors(); @@ -136,7 +145,9 @@ export class WebglRenderer extends Disposable implements IRenderer { this._model.resize(this._terminal.cols, this._terminal.rows); // Resize all render layers - this._renderLayers.forEach(l => l.resize(this._terminal, this.dimensions)); + for (const l of this._renderLayers) { + l.resize(this._terminal, this.dimensions); + } // Resize the canvas this._canvas.width = this.dimensions.scaledCanvasWidth; @@ -168,15 +179,21 @@ export class WebglRenderer extends Disposable implements IRenderer { } public onBlur(): void { - this._renderLayers.forEach(l => l.onBlur(this._terminal)); + for (const l of this._renderLayers) { + l.onBlur(this._terminal); + } } public onFocus(): void { - this._renderLayers.forEach(l => l.onFocus(this._terminal)); + for (const l of this._renderLayers) { + l.onFocus(this._terminal); + } } public onSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void { - this._renderLayers.forEach(l => l.onSelectionChanged(this._terminal, start, end, columnSelectMode)); + for (const l of this._renderLayers) { + l.onSelectionChanged(this._terminal, start, end, columnSelectMode); + } this._updateSelectionModel(start, end, columnSelectMode); @@ -184,11 +201,15 @@ export class WebglRenderer extends Disposable implements IRenderer { } public onCursorMove(): void { - this._renderLayers.forEach(l => l.onCursorMove(this._terminal)); + for (const l of this._renderLayers) { + l.onCursorMove(this._terminal); + } } public onOptionsChanged(): void { - this._renderLayers.forEach(l => l.onOptionsChanged(this._terminal)); + for (const l of this._renderLayers) { + l.onOptionsChanged(this._terminal); + } this._updateDimensions(); this._refreshCharAtlas(); } @@ -222,7 +243,9 @@ export class WebglRenderer extends Disposable implements IRenderer { } public clear(): void { - this._renderLayers.forEach(l => l.reset(this._terminal)); + for (const l of this._renderLayers) { + l.reset(this._terminal); + } } public registerCharacterJoiner(handler: (text: string) => [number, number][]): number { @@ -245,7 +268,9 @@ export class WebglRenderer extends Disposable implements IRenderer { } // Update render layers - this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); + for (const l of this._renderLayers) { + l.onGridChanged(this._terminal, start, end); + } // Tell renderer the frame is beginning if (this._glyphRenderer.beginFrame()) { diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index 223ec16a..1396d09f 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -358,7 +358,7 @@ export class WebglCharAtlas implements IDisposable { const fontStyle = italic ? 'italic' : ''; this._tmpCtx.font = `${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`; - this._tmpCtx.textBaseline = 'middle'; + this._tmpCtx.textBaseline = 'ideographic'; this._tmpCtx.fillStyle = this._getForegroundCss(bg, bgColorMode, bgColor, fg, fgColorMode, fgColor, inverse, bold); @@ -367,8 +367,22 @@ export class WebglCharAtlas implements IDisposable { this._tmpCtx.globalAlpha = DIM_OPACITY; } + // Check if the char is a powerline glyph, these will be restricted to a single cell glyph, no + // padding on either side that are allowed for other glyphs since they are designed to be pixel + // perfect but may render with "bad" anti-aliasing + let isPowerlineGlyph = false; + if (chars.length === 1) { + const code = chars.charCodeAt(0); + if (code >= 0xE0A0 && code <= 0xE0D6) { + isPowerlineGlyph = true; + } + } + + // For powerline glyphs left/top padding is excluded (https://github.com/microsoft/vscode/issues/120129) + const padding = isPowerlineGlyph ? 0 : TMP_CANVAS_GLYPH_PADDING; + // Draw the character - this._tmpCtx.fillText(chars, TMP_CANVAS_GLYPH_PADDING, TMP_CANVAS_GLYPH_PADDING + this._config.scaledCharHeight / 2); + this._tmpCtx.fillText(chars, padding, padding + this._config.scaledCharHeight); this._tmpCtx.restore(); // clear the background from the character to avoid issues with drawing over the previous @@ -391,7 +405,7 @@ export class WebglCharAtlas implements IDisposable { return NULL_RASTERIZED_GLYPH; } - const rasterizedGlyph = this._findGlyphBoundingBox(imageData, this._workBoundingBox); + const rasterizedGlyph = this._findGlyphBoundingBox(imageData, this._workBoundingBox, isPowerlineGlyph); const clippedImageData = this._clipImageData(imageData, this._workBoundingBox); // Check if there is enough room in the current row and go to next if needed @@ -424,12 +438,14 @@ export class WebglCharAtlas implements IDisposable { * @param imageData The image data to read. * @param boundingBox An IBoundingBox to put the clipped bounding box values. */ - private _findGlyphBoundingBox(imageData: ImageData, boundingBox: IBoundingBox): IRasterizedGlyph { + private _findGlyphBoundingBox(imageData: ImageData, boundingBox: IBoundingBox, restrictedGlyph: boolean): IRasterizedGlyph { boundingBox.top = 0; + const height = restrictedGlyph ? this._config.scaledCharHeight : this._tmpCanvas.height; + const width = restrictedGlyph ? this._config.scaledCharWidth : this._tmpCanvas.width; let found = false; - for (let y = 0; y < this._tmpCanvas.height; y++) { - for (let x = 0; x < this._tmpCanvas.width; x++) { - const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const alphaOffset = y * width * 4 + x * 4 + 3; if (imageData.data[alphaOffset] !== 0) { boundingBox.top = y; found = true; @@ -442,9 +458,9 @@ export class WebglCharAtlas implements IDisposable { } boundingBox.left = 0; found = false; - for (let x = 0; x < this._tmpCanvas.width; x++) { - for (let y = 0; y < this._tmpCanvas.height; y++) { - const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) { + const alphaOffset = y * width * 4 + x * 4 + 3; if (imageData.data[alphaOffset] !== 0) { boundingBox.left = x; found = true; @@ -455,11 +471,11 @@ export class WebglCharAtlas implements IDisposable { break; } } - boundingBox.right = this._tmpCanvas.width; + boundingBox.right = width; found = false; - for (let x = this._tmpCanvas.width - 1; x >= 0; x--) { - for (let y = 0; y < this._tmpCanvas.height; y++) { - const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; + for (let x = width - 1; x >= 0; x--) { + for (let y = 0; y < height; y++) { + const alphaOffset = y * width * 4 + x * 4 + 3; if (imageData.data[alphaOffset] !== 0) { boundingBox.right = x; found = true; @@ -470,11 +486,11 @@ export class WebglCharAtlas implements IDisposable { break; } } - boundingBox.bottom = this._tmpCanvas.height; + boundingBox.bottom = height; found = false; - for (let y = this._tmpCanvas.height - 1; y >= 0; y--) { - for (let x = 0; x < this._tmpCanvas.width; x++) { - const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; + for (let y = height - 1; y >= 0; y--) { + for (let x = 0; x < width; x++) { + const alphaOffset = y * width * 4 + x * 4 + 3; if (imageData.data[alphaOffset] !== 0) { boundingBox.bottom = y; found = true; @@ -497,8 +513,8 @@ export class WebglCharAtlas implements IDisposable { y: (boundingBox.bottom - boundingBox.top + 1) / TEXTURE_HEIGHT }, offset: { - x: -boundingBox.left + TMP_CANVAS_GLYPH_PADDING, - y: -boundingBox.top + TMP_CANVAS_GLYPH_PADDING + x: -boundingBox.left + (restrictedGlyph ? 0 : TMP_CANVAS_GLYPH_PADDING), + y: -boundingBox.top + (restrictedGlyph ? 0 : TMP_CANVAS_GLYPH_PADDING) } }; } diff --git a/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts index da3b4d41..6229fb7f 100644 --- a/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts +++ b/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts @@ -224,12 +224,12 @@ export abstract class BaseRenderLayer implements IRenderLayer { */ protected _fillCharTrueColor(terminal: Terminal, cell: CellData, x: number, y: number): void { this._ctx.font = this._getFont(terminal, false, false); - this._ctx.textBaseline = 'middle'; + this._ctx.textBaseline = 'ideographic'; this._clipRow(terminal, y); this._ctx.fillText( cell.getChars(), x * this._scaledCellWidth + this._scaledCharLeft, - y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight / 2); + y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight); } /** diff --git a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts index 558605e2..e0aa68e7 100644 --- a/addons/xterm-addon-webgl/test/WebglRenderer.api.ts +++ b/addons/xterm-addon-webgl/test/WebglRenderer.api.ts @@ -18,7 +18,7 @@ const height = 600; describe('WebGL Renderer Integration Tests', async () => { const browserType = getBrowserType(); - const isHeadless = process.argv.indexOf('--headless') !== -1; + const isHeadless = process.argv.includes('--headless'); // Firefox works only in non-headless mode https://github.com/microsoft/playwright/issues/1032 const areTestsEnabled = browserType.name() === 'chromium' || (browserType.name() === 'firefox' && !isHeadless); const itWebgl = areTestsEnabled ? it : it.skip; @@ -893,7 +893,7 @@ async function getCellColor(col: number, row: number): Promise { async function setupBrowser(options: ITerminalOptions = { rendererType: 'dom' }): Promise { const browserType = getBrowserType(); browser = await browserType.launch({ - headless: process.argv.indexOf('--headless') !== -1 + headless: process.argv.includes('--headless') }); page = await (await browser.newContext()).newPage(); await page.setViewportSize({ width, height }); diff --git a/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts b/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts index 5c15aa17..d95d8961 100644 --- a/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts +++ b/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts @@ -3,6 +3,7 @@ * @license MIT */ +import { IEvent } from 'node-pty'; import { Terminal, ITerminalAddon } from 'xterm'; declare module 'xterm-addon-webgl' { @@ -29,5 +30,10 @@ declare module 'xterm-addon-webgl' { * Clears the terminal's texture atlas and triggers a redraw. */ public clearTextureAtlas(): void; + + /** + * Fired when the WebglRenderer loses context + */ + public get onContextLoss(): IEvent; } } diff --git a/src/browser/Linkifier2.ts b/src/browser/Linkifier2.ts index 73ea0268..89542936 100644 --- a/src/browser/Linkifier2.ts +++ b/src/browser/Linkifier2.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { ILinkifier2, ILinkProvider, IBufferCellPosition, ILink, ILinkifierEvent, ILinkDecorations } from 'browser/Types'; +import { ILinkifier2, ILinkProvider, IBufferCellPosition, ILink, ILinkifierEvent, ILinkDecorations, ILinkWithState } from 'browser/Types'; import { IDisposable } from 'common/Types'; import { IMouseService, IRenderService } from './services/Services'; import { IBufferService } from 'common/services/Services'; @@ -11,21 +11,12 @@ import { EventEmitter, IEvent } from 'common/EventEmitter'; import { Disposable, getDisposeArrayDisposable, disposeArray } from 'common/Lifecycle'; import { addDisposableDomListener } from 'browser/Lifecycle'; -interface ILinkState { - decorations: ILinkDecorations; - isHovered: boolean; -} - -interface ILinkWithState { - link: ILink; - state?: ILinkState; -} - export class Linkifier2 extends Disposable implements ILinkifier2 { private _element: HTMLElement | undefined; private _mouseService: IMouseService | undefined; private _renderService: IRenderService | undefined; private _linkProviders: ILinkProvider[] = []; + public get currentLink(): ILinkWithState | undefined { return this._currentLink; } protected _currentLink: ILinkWithState | undefined; private _lastMouseEvent: MouseEvent | undefined; private _linkCacheDisposables: IDisposable[] = []; diff --git a/src/browser/MouseZoneManager.ts b/src/browser/MouseZoneManager.ts index 59b6b0f1..b6740157 100644 --- a/src/browser/MouseZoneManager.ts +++ b/src/browser/MouseZoneManager.ts @@ -156,9 +156,7 @@ export class MouseZoneManager extends Disposable implements IMouseZoneManager { private _onTooltip(e: MouseEvent): void { this._tooltipTimeout = undefined; const zone = this._findZoneEventAt(e); - if (zone && zone.tooltipCallback) { - zone.tooltipCallback(e); - } + zone?.tooltipCallback(e); } private _onMouseDown(e: MouseEvent): void { diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index 5724715f..b0075d88 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -56,25 +56,25 @@ describe('Terminal', () => { // term.handler('fake'); // }); it('should fire the onCursorMove event', () => { - return new Promise(async r => { + return new Promise(async r => { term.onCursorMove(() => r()); await term.writeP('foo'); }); }); it('should fire the onLineFeed event', () => { - return new Promise(async r => { + return new Promise(async r => { term.onLineFeed(() => r()); await term.writeP('\n'); }); }); it('should fire a scroll event when scrollback is created', () => { - return new Promise(async r => { + return new Promise(async r => { term.onScroll(() => r()); await term.writeP('\n'.repeat(INIT_ROWS)); }); }); it('should fire a scroll event when scrollback is cleared', () => { - return new Promise(async r => { + return new Promise(async r => { await term.writeP('\n'.repeat(INIT_ROWS)); term.onScroll(() => r()); term.clear(); @@ -233,7 +233,7 @@ describe('Terminal', () => { term.paste('\r\nfoo\nbar\r'); }); it('should respect bracketed paste mode', () => { - return new Promise(async r => { + return new Promise(async r => { term.onData(e => { assert.equal(e, '\x1b[200~foo\x1b[201~'); r(); @@ -1054,10 +1054,10 @@ describe('Terminal', () => { linkifier.attachToDom({} as any, mouseZoneManager); }); - function assertLinkifiesInTerminal(rowText: string, linkMatcherRegex: RegExp, links: {x1: number, y1: number, x2: number, y2: number}[]): Promise { + function assertLinkifiesInTerminal(rowText: string, linkMatcherRegex: RegExp, links: { x1: number, y1: number, x2: number, y2: number }[]): Promise { return new Promise(async r => { await terminal.writeP(rowText); - linkifier.registerLinkMatcher(linkMatcherRegex, () => {}); + linkifier.registerLinkMatcher(linkMatcherRegex, () => { }); linkifier.linkifyRows(); // Allow linkify to happen setTimeout(() => { @@ -1075,66 +1075,66 @@ describe('Terminal', () => { describe('unicode before the match', () => { it('combining - match within one line', () => { - return assertLinkifiesInTerminal('e\u0301e\u0301e\u0301 foo', /foo/, [{x1: 4, x2: 7, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('e\u0301e\u0301e\u0301 foo', /foo/, [{ x1: 4, x2: 7, y1: 0, y2: 0 }]); }); it('combining - match over two lines', () => { - return assertLinkifiesInTerminal('e\u0301e\u0301e\u0301 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('e\u0301e\u0301e\u0301 foo', /foo/, [{ x1: 8, x2: 1, y1: 0, y2: 1 }]); }); it('surrogate - match within one line', () => { - return assertLinkifiesInTerminal('𝄞𝄞𝄞 foo', /foo/, [{x1: 4, x2: 7, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('𝄞𝄞𝄞 foo', /foo/, [{ x1: 4, x2: 7, y1: 0, y2: 0 }]); }); it('surrogate - match over two lines', () => { - return assertLinkifiesInTerminal('𝄞𝄞𝄞 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('𝄞𝄞𝄞 foo', /foo/, [{ x1: 8, x2: 1, y1: 0, y2: 1 }]); }); it('combining surrogate - match within one line', () => { - return assertLinkifiesInTerminal('𓂀\u0301𓂀\u0301𓂀\u0301 foo', /foo/, [{x1: 4, x2: 7, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('𓂀\u0301𓂀\u0301𓂀\u0301 foo', /foo/, [{ x1: 4, x2: 7, y1: 0, y2: 0 }]); }); it('combining surrogate - match over two lines', () => { - return assertLinkifiesInTerminal('𓂀\u0301𓂀\u0301𓂀\u0301 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('𓂀\u0301𓂀\u0301𓂀\u0301 foo', /foo/, [{ x1: 8, x2: 1, y1: 0, y2: 1 }]); }); it('fullwidth - match within one line', () => { - return assertLinkifiesInTerminal('12 foo', /foo/, [{x1: 5, x2: 8, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('12 foo', /foo/, [{ x1: 5, x2: 8, y1: 0, y2: 0 }]); }); it('fullwidth - match over two lines', () => { - return assertLinkifiesInTerminal('12 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('12 foo', /foo/, [{ x1: 8, x2: 1, y1: 0, y2: 1 }]); }); it('combining fullwidth - match within one line', () => { - return assertLinkifiesInTerminal('¥\u0301¥\u0301 foo', /foo/, [{x1: 5, x2: 8, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('¥\u0301¥\u0301 foo', /foo/, [{ x1: 5, x2: 8, y1: 0, y2: 0 }]); }); it('combining fullwidth - match over two lines', () => { - return assertLinkifiesInTerminal('¥\u0301¥\u0301 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('¥\u0301¥\u0301 foo', /foo/, [{ x1: 8, x2: 1, y1: 0, y2: 1 }]); }); }); describe('unicode within the match', () => { it('combining - match within one line', () => { - return assertLinkifiesInTerminal('test cafe\u0301', /cafe\u0301/, [{x1: 5, x2: 9, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('test cafe\u0301', /cafe\u0301/, [{ x1: 5, x2: 9, y1: 0, y2: 0 }]); }); it('combining - match over two lines', () => { - return assertLinkifiesInTerminal('testtest cafe\u0301', /cafe\u0301/, [{x1: 9, x2: 3, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('testtest cafe\u0301', /cafe\u0301/, [{ x1: 9, x2: 3, y1: 0, y2: 1 }]); }); it('surrogate - match within one line', () => { - return assertLinkifiesInTerminal('test a𝄞b', /a𝄞b/, [{x1: 5, x2: 8, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('test a𝄞b', /a𝄞b/, [{ x1: 5, x2: 8, y1: 0, y2: 0 }]); }); it('surrogate - match over two lines', () => { - return assertLinkifiesInTerminal('testtest a𝄞b', /a𝄞b/, [{x1: 9, x2: 2, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('testtest a𝄞b', /a𝄞b/, [{ x1: 9, x2: 2, y1: 0, y2: 1 }]); }); it('combining surrogate - match within one line', () => { - return assertLinkifiesInTerminal('test a𓂀\u0301b', /a𓂀\u0301b/, [{x1: 5, x2: 8, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('test a𓂀\u0301b', /a𓂀\u0301b/, [{ x1: 5, x2: 8, y1: 0, y2: 0 }]); }); it('combining surrogate - match over two lines', () => { - return assertLinkifiesInTerminal('testtest a𓂀\u0301b', /a𓂀\u0301b/, [{x1: 9, x2: 2, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('testtest a𓂀\u0301b', /a𓂀\u0301b/, [{ x1: 9, x2: 2, y1: 0, y2: 1 }]); }); it('fullwidth - match within one line', () => { - return assertLinkifiesInTerminal('test a1b', /a1b/, [{x1: 5, x2: 9, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('test a1b', /a1b/, [{ x1: 5, x2: 9, y1: 0, y2: 0 }]); }); it('fullwidth - match over two lines', () => { - return assertLinkifiesInTerminal('testtest a1b', /a1b/, [{x1: 9, x2: 3, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('testtest a1b', /a1b/, [{ x1: 9, x2: 3, y1: 0, y2: 1 }]); }); it('combining fullwidth - match within one line', () => { - return assertLinkifiesInTerminal('test a¥\u0301b', /a¥\u0301b/, [{x1: 5, x2: 9, y1: 0, y2: 0}]); + return assertLinkifiesInTerminal('test a¥\u0301b', /a¥\u0301b/, [{ x1: 5, x2: 9, y1: 0, y2: 0 }]); }); it('combining fullwidth - match over two lines', () => { - return assertLinkifiesInTerminal('testtest a¥\u0301b', /a¥\u0301b/, [{x1: 9, x2: 3, y1: 0, y2: 1}]); + return assertLinkifiesInTerminal('testtest a¥\u0301b', /a¥\u0301b/, [{ x1: 9, x2: 3, y1: 0, y2: 1 }]); }); }); }); @@ -1143,7 +1143,7 @@ describe('Terminal', () => { let terminal: TestTerminal; beforeEach(() => { - terminal = new TestTerminal({rows: 5, cols: 10, scrollback: 5}); + terminal = new TestTerminal({ rows: 5, cols: 10, scrollback: 5 }); }); it('multiline ascii', async () => { @@ -1318,7 +1318,7 @@ describe('Terminal', () => { it('test fully wrapped buffer up to last char with full width odd', async () => { const input = 'a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301' - + 'a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301'; + + 'a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301a¥\u0301'; await terminal.writeP(input); const s = terminal.buffer.iterator(true).next().content; assert.equal(input, s); @@ -1342,9 +1342,9 @@ describe('Terminal', () => { }); }); - describe('BufferStringIterator', function(): void { + describe('BufferStringIterator', function (): void { it('iterator does not overflow buffer limits', async () => { - const terminal = new TestTerminal({rows: 5, cols: 10, scrollback: 5}); + const terminal = new TestTerminal({ rows: 5, cols: 10, scrollback: 5 }); const data = [ 'aaaaaaaaaa', 'aaaaaaaaa\n', @@ -1382,13 +1382,13 @@ describe('Terminal', () => { 'aaaaaaaaa' // not wrapped ]; - const normalTerminal = new TestTerminal({rows: 5, cols: 10, windowsMode: false}); + const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: false }); await normalTerminal.writeP(data.join('')); assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false); assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false); assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false); - const windowsModeTerminal = new TestTerminal({rows: 5, cols: 10, windowsMode: true}); + const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: true }); await windowsModeTerminal.writeP(data.join('')); assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false); assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character'); @@ -1402,13 +1402,13 @@ describe('Terminal', () => { 'aaaaaaaaa' // not wrapped ]; - const normalTerminal = new TestTerminal({rows: 5, cols: 10, windowsMode: false}); + const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: false }); await normalTerminal.writeP(data.join('')); assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false); assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false); assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false); - const windowsModeTerminal = new TestTerminal({rows: 5, cols: 10, windowsMode: true}); + const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: true }); await windowsModeTerminal.writeP(data.join('')); assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false); assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character'); @@ -1417,7 +1417,7 @@ describe('Terminal', () => { }); it('convertEol setting', async () => { // not converting - const termNotConverting = new TestTerminal({cols: 15, rows: 10}); + const termNotConverting = new TestTerminal({ cols: 15, rows: 10 }); await termNotConverting.writeP('Hello\nWorld'); assert.equal(termNotConverting.buffer.lines.get(0)!.translateToString(false), 'Hello '); assert.equal(termNotConverting.buffer.lines.get(1)!.translateToString(false), ' World '); @@ -1425,128 +1425,13 @@ describe('Terminal', () => { assert.equal(termNotConverting.buffer.lines.get(1)!.translateToString(true), ' World'); // converting - const termConverting = new TestTerminal({cols: 15, rows: 10, convertEol: true}); + const termConverting = new TestTerminal({ cols: 15, rows: 10, convertEol: true }); await termConverting.writeP('Hello\nWorld'); assert.equal(termConverting.buffer.lines.get(0)!.translateToString(false), 'Hello '); assert.equal(termConverting.buffer.lines.get(1)!.translateToString(false), 'World '); assert.equal(termConverting.buffer.lines.get(0)!.translateToString(true), 'Hello'); assert.equal(termConverting.buffer.lines.get(1)!.translateToString(true), 'World'); }); - describe('Terminal InputHandler integration', () => { - function getLines(term: TestTerminal, limit: number = term.rows): string[] { - const res: string[] = []; - for (let i = 0; i < limit; ++i) { - res.push(term.buffer.lines.get(i)!.translateToString(true)); - } - return res; - } - - // This suite cannot live in InputHandler unless Terminal.scroll moved into IBufferService - describe('SL/SR/DECIC/DECDC', () => { - let term: TestTerminal; - beforeEach(() => { - term = new TestTerminal({cols: 5, rows: 5, scrollback: 1}); - }); - it('SL (scrollLeft)', async () => { - await term.writeP('12345'.repeat(6)); - await term.writeP('\x1b[ @'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '2345', '2345', '2345', '2345', '2345']); - await term.writeP('\x1b[0 @'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '345', '345', '345', '345', '345']); - await term.writeP('\x1b[2 @'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '5', '5', '5', '5', '5']); - }); - it('SR (scrollRight)', async () => { - await term.writeP('12345'.repeat(6)); - await term.writeP('\x1b[ A'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', ' 1234', ' 1234', ' 1234', ' 1234', ' 1234']); - await term.writeP('\x1b[0 A'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', ' 123', ' 123', ' 123', ' 123', ' 123']); - await term.writeP('\x1b[2 A'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', ' 1', ' 1', ' 1', ' 1', ' 1']); - }); - it('insertColumns (DECIC)', async () => { - await term.writeP('12345'.repeat(6)); - await term.writeP('\x1b[3;3H'); - await term.writeP('\x1b[\'}'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '12 34', '12 34', '12 34', '12 34', '12 34']); - term.reset(); - await term.writeP('12345'.repeat(6)); - await term.writeP('\x1b[3;3H'); - await term.writeP('\x1b[1\'}'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '12 34', '12 34', '12 34', '12 34', '12 34']); - term.reset(); - await term.writeP('12345'.repeat(6)); - await term.writeP('\x1b[3;3H'); - await term.writeP('\x1b[2\'}'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '12 3', '12 3', '12 3', '12 3', '12 3']); - }); - it('deleteColumns (DECDC)', async () => { - await term.writeP('12345'.repeat(6)); - await term.writeP('\x1b[3;3H'); - await term.writeP('\x1b[\'~'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '1245', '1245', '1245', '1245', '1245']); - term.reset(); - await term.writeP('12345'.repeat(6)); - await term.writeP('\x1b[3;3H'); - await term.writeP('\x1b[1\'~'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '1245', '1245', '1245', '1245', '1245']); - term.reset(); - await term.writeP('12345'.repeat(6)); - await term.writeP('\x1b[3;3H'); - await term.writeP('\x1b[2\'~'); - assert.deepEqual(getLines(term, term.rows + 1), ['12345', '125', '125', '125', '125', '125']); - }); - }); - - describe('BS with reverseWraparound set/unset', () => { - const ttyBS = '\x08 \x08'; // tty ICANON sends on pressing BS - - beforeEach(() => { - term = new TestTerminal({cols: 5, rows: 5, scrollback: 1}); - }); - - describe('reverseWraparound set', () => { - it('should not reverse outside of scroll margins', async () => { - // prepare buffer content - await term.writeP('#####abcdefghijklmnopqrstuvwxy'); - assert.deepEqual(getLines(term, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', 'uvwxy']); - assert.equal(term.buffer.ydisp, 1); - assert.equal(term.buffer.x, 5); - assert.equal(term.buffer.y, 4); - await term.writeP(ttyBS.repeat(100)); - assert.deepEqual(getLines(term, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', ' y']); - - await term.writeP('\x1b[?45h'); - await term.writeP('uvwxy'); - - // set top/bottom to 1/3 (0-based) - await term.writeP('\x1b[2;4r'); - // place cursor below scroll bottom - term.buffer.x = 5; - term.buffer.y = 4; - await term.writeP(ttyBS.repeat(100)); - assert.deepEqual(getLines(term, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', ' ']); - - await term.writeP('uvwxy'); - // place cursor within scroll margins - term.buffer.x = 5; - term.buffer.y = 3; - await term.writeP(ttyBS.repeat(100)); - assert.deepEqual(getLines(term, 6), ['#####', 'abcde', ' ', ' ', ' ', 'uvwxy']); - assert.equal(term.buffer.x, 0); - assert.equal(term.buffer.y, term.buffer.scrollTop); // stops at 0, scrollTop - - await term.writeP('fghijklmnopqrst'); - // place cursor above scroll top - term.buffer.x = 5; - term.buffer.y = 0; - await term.writeP(ttyBS.repeat(100)); - assert.deepEqual(getLines(term, 6), ['#####', ' ', 'fghij', 'klmno', 'pqrst', 'uvwxy']); - }); - }); - }); - }); // FIXME: move to common/CoreTerminal.test once the trimming is moved over describe('marker lifecycle', () => { diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index b0964c21..f14bffe0 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -39,7 +39,7 @@ import { MouseZoneManager } from 'browser/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider } from 'xterm'; import { DomRenderer } from 'browser/renderer/dom/DomRenderer'; -import { IKeyboardEvent, KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, IAnsiColorChangeEvent } from 'common/Types'; +import { IKeyboardEvent, KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IAnsiColorChangeEvent } from 'common/Types'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; @@ -147,7 +147,6 @@ export class Terminal extends CoreTerminal implements ITerminal { this.register(this._inputHandler.onRequestBell(() => this.bell())); this.register(this._inputHandler.onRequestRefreshRows((start, end) => this.refresh(start, end))); this.register(this._inputHandler.onRequestReset(() => this.reset())); - this.register(this._inputHandler.onRequestScroll((eraseAttr, isWrapped) => this.scroll(eraseAttr, isWrapped || undefined))); this.register(this._inputHandler.onRequestWindowsOptionsReport(type => this._reportWindowsOptions(type))); this.register(this._inputHandler.onAnsiColorChange((event) => this._changeAnsiColor(event))); this.register(forwardEvent(this._inputHandler.onCursorMove, this._onCursorMove)); @@ -162,11 +161,11 @@ export class Terminal extends CoreTerminal implements ITerminal { private _changeAnsiColor(event: IAnsiColorChangeEvent): void { if (!this._colorManager) { return; } - event.colors.forEach(ansiColor => { + for (const ansiColor of event.colors) { const color = rgba.toColor(ansiColor.red, ansiColor.green, ansiColor.blue); this._colorManager!.colors.ansi[ansiColor.colorIndex] = color; - }); + } this._renderService?.setColors(this._colorManager!.colors); this.viewport?.onThemeChange(this._colorManager!.colors); @@ -297,19 +296,26 @@ export class Terminal extends CoreTerminal implements ITerminal { } private _syncTextArea(): void { - if (!this.textarea || !this.buffer.isCursorInViewport || this._compositionHelper!.isComposing) { + if (!this.textarea || !this.buffer.isCursorInViewport || this._compositionHelper!.isComposing || !this._renderService) { return; } - - const cellHeight = Math.ceil(this._charSizeService!.height * this.optionsService.options.lineHeight); - const cursorTop = this._bufferService.buffer.y * cellHeight; - const cursorLeft = this._bufferService.buffer.x * this._charSizeService!.width; + const cursorY = this.buffer.ybase + this.buffer.y; + const bufferLine = this.buffer.lines.get(cursorY); + if (!bufferLine) { + return; + } + const cursorX = Math.min(this.buffer.x, this.cols - 1); + const cellHeight = this._renderService.dimensions.actualCellHeight; + const width = bufferLine.getWidth(cursorX); + const cellWidth = this._renderService.dimensions.actualCellWidth * width; + const cursorTop = this.buffer.y * this._renderService.dimensions.actualCellHeight; + const cursorLeft = cursorX * this._renderService.dimensions.actualCellWidth; // Sync the textarea to the exact position of the composition view so the IME knows where the // text is. this.textarea.style.left = cursorLeft + 'px'; this.textarea.style.top = cursorTop + 'px'; - this.textarea.style.width = this._charSizeService!.width + 'px'; + this.textarea.style.width = cellWidth + 'px'; this.textarea.style.height = cellHeight + 'px'; this.textarea.style.lineHeight = cellHeight + 'px'; this.textarea.style.zIndex = '-5'; @@ -438,14 +444,6 @@ export class Terminal extends CoreTerminal implements ITerminal { this._charSizeService = this._instantiationService.createInstance(CharSizeService, this._document, this._helperContainer); this._instantiationService.setService(ICharSizeService, this._charSizeService); - this._compositionView = document.createElement('div'); - this._compositionView.classList.add('composition-view'); - this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView); - this._helperContainer.appendChild(this._compositionView); - - // Performance: Add viewport and helper elements from the fragment - this.element.appendChild(fragment); - this._theme = this.options.theme || this._theme; this._colorManager = new ColorManager(document, this.options.allowTransparency); this.register(this.optionsService.onOptionChange(e => this._colorManager!.onOptionsChange(e))); @@ -457,13 +455,21 @@ export class Terminal extends CoreTerminal implements ITerminal { this.register(this._renderService.onRenderedBufferChange(e => this._onRender.fire(e))); this.onResize(e => this._renderService!.resize(e.cols, e.rows)); + this._compositionView = document.createElement('div'); + this._compositionView.classList.add('composition-view'); + this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView); + this._helperContainer.appendChild(this._compositionView); + + // Performance: Add viewport and helper elements from the fragment + this.element.appendChild(fragment); + this._soundService = this._instantiationService.createInstance(SoundService); this._instantiationService.setService(ISoundService, this._soundService); this._mouseService = this._instantiationService.createInstance(MouseService); this._instantiationService.setService(IMouseService, this._mouseService); this.viewport = this._instantiationService.createInstance(Viewport, - (amount: number, suppressEvent: boolean) => this.scrollLines(amount, suppressEvent), + (amount: number) => this.scrollLines(amount, false, ScrollSource.VIEWPORT), this._viewportElement, this._viewportScrollArea ); @@ -482,7 +488,9 @@ export class Terminal extends CoreTerminal implements ITerminal { this._selectionService = this.register(this._instantiationService.createInstance(SelectionService, this.element, - this.screenElement)); + this.screenElement, + this.linkifier2 + )); this._instantiationService.setService(ISelectionService, this._selectionService); this.register(this._selectionService.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent))); this.register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire())); @@ -495,8 +503,10 @@ export class Terminal extends CoreTerminal implements ITerminal { this.textarea!.focus(); this.textarea!.select(); })); - this.register(this.onScroll(() => { - this.viewport!.syncScrollArea(); + this.register(this._onScroll.event(ev => { + if (ev.source !== ScrollSource.VIEWPORT) { + this.viewport!.syncScrollArea(); + } this._selectionService!.refresh(); })); this.register(addDisposableDomListener(this._viewportElement, 'scroll', () => this._selectionService!.refresh())); @@ -834,7 +844,7 @@ export class Terminal extends CoreTerminal implements ITerminal { * Change the cursor style for different selection modes */ public updateCursorStyle(ev: KeyboardEvent): void { - if (this._selectionService && this._selectionService.shouldColumnSelect(ev)) { + if (this._selectionService?.shouldColumnSelect(ev)) { this.element!.classList.add('column-select'); } else { this.element!.classList.remove('column-select'); @@ -851,8 +861,8 @@ export class Terminal extends CoreTerminal implements ITerminal { } } - public scrollLines(disp: number, suppressScrollEvent?: boolean): void { - super.scrollLines(disp, suppressScrollEvent); + public scrollLines(disp: number, suppressScrollEvent?: boolean, source = ScrollSource.TERMINAL): void { + super.scrollLines(disp, suppressScrollEvent, source); this.refresh(0, this.rows - 1); } @@ -999,7 +1009,7 @@ export class Terminal extends CoreTerminal implements ITerminal { if (!this._compositionHelper!.keydown(event)) { if (this.buffer.ybase !== this.buffer.ydisp) { - this.scrollToBottom(); + this._bufferService.scrollToBottom(); } return false; } @@ -1183,7 +1193,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this.buffer.lines.push(this.buffer.getBlankLine(DEFAULT_ATTR_DATA)); } this.refresh(0, this.rows - 1); - this._onScroll.fire(this.buffer.ydisp); + this._onScroll.fire({ position: this.buffer.ydisp, source: ScrollSource.TERMINAL }); } /** diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 1262117f..f743934e 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -205,9 +205,19 @@ export interface ILinkifier { deregisterLinkMatcher(matcherId: number): boolean; } +interface ILinkState { + decorations: ILinkDecorations; + isHovered: boolean; +} +export interface ILinkWithState { + link: ILink; + state?: ILinkState; +} + export interface ILinkifier2 { onShowLinkUnderline: IEvent; onHideLinkUnderline: IEvent; + readonly currentLink: ILinkWithState | undefined; attachToDom(element: HTMLElement, mouseService: IMouseService, renderService: IRenderService): void; registerLinkProvider(linkProvider: ILinkProvider): IDisposable; diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 29edce6f..02f74ce8 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -33,7 +33,7 @@ export class Viewport extends Disposable implements IViewport { private _ignoreNextScrollEvent: boolean = false; constructor( - private readonly _scrollLines: (amount: number, suppressEvent: boolean) => void, + private readonly _scrollLines: (amount: number) => void, private readonly _viewportElement: HTMLElement, private readonly _scrollArea: HTMLElement, @IBufferService private readonly _bufferService: IBufferService, @@ -156,7 +156,7 @@ export class Viewport extends Disposable implements IViewport { const newRow = Math.round(this._lastScrollTop / this._currentRowHeight); const diff = newRow - this._bufferService.buffer.ydisp; - this._scrollLines(diff, true); + this._scrollLines(diff); } /** diff --git a/src/browser/input/CompositionHelper.test.ts b/src/browser/input/CompositionHelper.test.ts index b9a4f668..c722570b 100644 --- a/src/browser/input/CompositionHelper.test.ts +++ b/src/browser/input/CompositionHelper.test.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { CompositionHelper } from 'browser/input/CompositionHelper'; -import { MockCharSizeService } from 'browser/TestUtils.test'; +import { MockRenderService } from 'browser/TestUtils.test'; import { MockCoreService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; describe('CompositionHelper', () => { @@ -42,7 +42,7 @@ describe('CompositionHelper', () => { }; handledText = ''; const bufferService = new MockBufferService(10, 5); - compositionHelper = new CompositionHelper(textarea, compositionView, bufferService, new MockOptionsService(), new MockCharSizeService(10, 10), coreService); + compositionHelper = new CompositionHelper(textarea, compositionView, bufferService, new MockOptionsService(), coreService, new MockRenderService()); }); describe('Input', () => { diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts index 85cfc3b6..8a204831 100644 --- a/src/browser/input/CompositionHelper.ts +++ b/src/browser/input/CompositionHelper.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { ICharSizeService } from 'browser/services/Services'; +import { IRenderService } from 'browser/services/Services'; import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services'; interface IPosition { @@ -45,8 +45,8 @@ export class CompositionHelper { private readonly _compositionView: HTMLElement, @IBufferService private readonly _bufferService: IBufferService, @IOptionsService private readonly _optionsService: IOptionsService, - @ICharSizeService private readonly _charSizeService: ICharSizeService, - @ICoreService private readonly _coreService: ICoreService + @ICoreService private readonly _coreService: ICoreService, + @IRenderService private readonly _renderService: IRenderService ) { this._isComposing = false; this._isSendingComposition = false; @@ -207,9 +207,11 @@ export class CompositionHelper { } if (this._bufferService.buffer.isCursorInViewport) { - const cellHeight = Math.ceil(this._charSizeService.height * this._optionsService.options.lineHeight); - const cursorTop = this._bufferService.buffer.y * cellHeight; - const cursorLeft = this._bufferService.buffer.x * this._charSizeService.width; + const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1); + + const cellHeight = this._renderService.dimensions.actualCellHeight; + const cursorTop = this._bufferService.buffer.y * this._renderService.dimensions.actualCellHeight; + const cursorLeft = cursorX * this._renderService.dimensions.actualCellWidth; this._compositionView.style.left = cursorLeft + 'px'; this._compositionView.style.top = cursorTop + 'px'; diff --git a/src/browser/renderer/BaseRenderLayer.ts b/src/browser/renderer/BaseRenderLayer.ts index 8afec352..b7646bee 100644 --- a/src/browser/renderer/BaseRenderLayer.ts +++ b/src/browser/renderer/BaseRenderLayer.ts @@ -242,12 +242,12 @@ export abstract class BaseRenderLayer implements IRenderLayer { */ protected _fillCharTrueColor(cell: CellData, x: number, y: number): void { this._ctx.font = this._getFont(false, false); - this._ctx.textBaseline = 'middle'; + this._ctx.textBaseline = 'ideographic'; this._clipRow(y); this._ctx.fillText( cell.getChars(), x * this._scaledCellWidth + this._scaledCharLeft, - y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight / 2); + y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight); } /** @@ -320,7 +320,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { private _drawUncachedChars(cell: ICellData, x: number, y: number, fgOverride?: IColor): void { this._ctx.save(); this._ctx.font = this._getFont(!!cell.isBold(), !!cell.isItalic()); - this._ctx.textBaseline = 'middle'; + this._ctx.textBaseline = 'ideographic'; if (cell.isInverse()) { if (fgOverride) { @@ -362,7 +362,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { this._ctx.fillText( cell.getChars(), x * this._scaledCellWidth + this._scaledCharLeft, - y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight / 2); + y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight); this._ctx.restore(); } diff --git a/src/browser/renderer/atlas/DynamicCharAtlas.ts b/src/browser/renderer/atlas/DynamicCharAtlas.ts index 696fb63c..bf90bab6 100644 --- a/src/browser/renderer/atlas/DynamicCharAtlas.ts +++ b/src/browser/renderer/atlas/DynamicCharAtlas.ts @@ -256,7 +256,7 @@ export class DynamicCharAtlas extends BaseCharAtlas { const fontStyle = glyph.italic ? 'italic' : ''; this._tmpCtx.font = `${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`; - this._tmpCtx.textBaseline = 'middle'; + this._tmpCtx.textBaseline = 'ideographic'; this._tmpCtx.fillStyle = this._getForegroundColor(glyph).css; @@ -265,7 +265,7 @@ export class DynamicCharAtlas extends BaseCharAtlas { this._tmpCtx.globalAlpha = DIM_OPACITY; } // Draw the character - this._tmpCtx.fillText(glyph.chars, 0, this._config.scaledCharHeight / 2); + this._tmpCtx.fillText(glyph.chars, 0, this._config.scaledCharHeight); this._tmpCtx.restore(); // clear the background from the character to avoid issues with drawing over the previous diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index eb90ba44..514d5803 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -21,7 +21,7 @@ class TestSelectionService extends SelectionService { optionsService: IOptionsService, renderService: IRenderService ) { - super(null!, null!, bufferService, new MockCoreService(), new MockMouseService(), optionsService, renderService); + super(null!, null!, null!, bufferService, new MockCoreService(), new MockMouseService(), optionsService, renderService); } public get model(): SelectionModel { return this._model; } diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 3b993876..4806ef91 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -11,6 +11,7 @@ import { SelectionModel } from 'browser/selection/SelectionModel'; import { CellData } from 'common/buffer/CellData'; import { EventEmitter, IEvent } from 'common/EventEmitter'; import { ICharSizeService, IMouseService, ISelectionService, IRenderService } from 'browser/services/Services'; +import { ILinkifier2 } from 'browser/Types'; import { IBufferService, IOptionsService, ICoreService } from 'common/services/Services'; import { getCoordsRelativeToElement } from 'browser/input/Mouse'; import { moveToCellSequence } from 'browser/input/MoveToCell'; @@ -121,6 +122,7 @@ export class SelectionService extends Disposable implements ISelectionService { constructor( private readonly _element: HTMLElement, private readonly _screenElement: HTMLElement, + private readonly _linkifier: ILinkifier2, @IBufferService private readonly _bufferService: IBufferService, @ICoreService private readonly _coreService: ICoreService, @IMouseService private readonly _mouseService: IMouseService, @@ -316,13 +318,22 @@ export class SelectionService extends Disposable implements ISelectionService { * Selects word at the current mouse event coordinates. * @param event The mouse event. */ - private _selectWordAtCursor(event: MouseEvent): void { + private _selectWordAtCursor(event: MouseEvent, allowWhitespaceOnlySelection: boolean): boolean { + // Check if there is a link under the cursor first and select that if so + const range = this._linkifier.currentLink?.link?.range; + if (range) { + this._model.selectionStart = [range.start.x - 1, range.start.y - 1]; + this._model.selectionEnd = [range.end.x, range.end.y - 1]; + return true; + } + const coords = this._getMouseBufferCoords(event); if (coords) { - this._selectWordAt(coords, false); + this._selectWordAt(coords, allowWhitespaceOnlySelection); this._model.selectionEnd = undefined; - this.refresh(true); + return true; } + return false; } /** @@ -527,14 +538,12 @@ export class SelectionService extends Disposable implements ISelectionService { } /** - * Performs a double click, selecting the current work. + * Performs a double click, selecting the current word. * @param event The mouse event. */ private _onDoubleClick(event: MouseEvent): void { - const coords = this._getMouseBufferCoords(event); - if (coords) { + if (this._selectWordAtCursor(event, true)) { this._activeSelectionMode = SelectionMode.WORD; - this._selectWordAt(coords, true); } } @@ -764,7 +773,9 @@ export class SelectionService extends Disposable implements ISelectionService { public rightClickSelect(ev: MouseEvent): void { if (!this._isClickInSelection(ev)) { - this._selectWordAtCursor(ev); + if (this._selectWordAtCursor(ev, false)) { + this.refresh(true); + } this._fireEventIfSelectionChanged(); } } diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 0236d39f..699a1b1c 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -27,7 +27,7 @@ import { InstantiationService } from 'common/services/InstantiationService'; import { LogService } from 'common/services/LogService'; import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService'; import { OptionsService } from 'common/services/OptionsService'; -import { ITerminalOptions, IDisposable, IBufferLine, IAttributeData, ICoreTerminal } from 'common/Types'; +import { ITerminalOptions, IDisposable, IBufferLine, IAttributeData, ICoreTerminal, IKeyboardEvent, IScrollEvent, ScrollSource } from 'common/Types'; import { CoreService } from 'common/services/CoreService'; import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { CoreMouseService } from 'common/services/CoreMouseService'; @@ -41,7 +41,7 @@ import { InputHandler } from 'common/InputHandler'; import { WriteBuffer } from 'common/input/WriteBuffer'; // Only trigger this warning a single time per session -let hasWriteSyncWarnHappened: boolean = false; +let hasWriteSyncWarnHappened = false; export abstract class CoreTerminal extends Disposable implements ICoreTerminal { protected readonly _instantiationService: IInstantiationService; @@ -58,8 +58,6 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { protected _inputHandler: InputHandler; private _writeBuffer: WriteBuffer; private _windowsMode: IDisposable | undefined; - /** An IBufferline to clone/copy from for new blank lines */ - private _cachedBlankLine: IBufferLine | undefined; private _onBinary = new EventEmitter(); public get onBinary(): IEvent { return this._onBinary.event; } @@ -69,8 +67,21 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { public get onLineFeed(): IEvent { return this._onLineFeed.event; } private _onResize = new EventEmitter<{ cols: number, rows: number }>(); public get onResize(): IEvent<{ cols: number, rows: number }> { return this._onResize.event; } - protected _onScroll = new EventEmitter(); - public get onScroll(): IEvent { return this._onScroll.event; } + protected _onScroll = new EventEmitter(); + /** + * Internally we track the source of the scroll but this is meaningless outside the library so + * it's filtered out. + */ + protected _onScrollApi?: EventEmitter; + public get onScroll(): IEvent { + if (!this._onScrollApi) { + this._onScrollApi = new EventEmitter(); + this.register(this._onScroll.event(ev => { + this._onScrollApi?.fire(ev.position); + })); + } + return this._onScrollApi.event; + } public get cols(): number { return this._bufferService.cols; } public get rows(): number { return this._bufferService.rows; } @@ -110,6 +121,10 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(forwardEvent(this._coreService.onData, this._onData)); this.register(forwardEvent(this._coreService.onBinary, this._onBinary)); this.register(this.optionsService.onOptionChange(key => this._updateOptions(key))); + this.register(this._bufferService.onScroll(event => { + this._onScroll.fire({position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL}); + this._dirtyRowService.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom); + })); // Setup WriteBuffer this._writeBuffer = new WriteBuffer((data, promiseResult) => this._inputHandler.parse(data, promiseResult)); @@ -137,12 +152,12 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { * * @deprecated Unreliable, will be removed soon. */ - public writeSync(data: string | Uint8Array): void { + public writeSync(data: string | Uint8Array, maxSubsequentCalls?: number): void { if (this._logService.logLevel <= LogLevelEnum.WARN && !hasWriteSyncWarnHappened) { this._logService.warn('writeSync is unreliable and will be removed soon.'); hasWriteSyncWarnHappened = true; } - this._writeBuffer.writeSync(data); + this._writeBuffer.writeSync(data, maxSubsequentCalls); } public resize(x: number, y: number): void { @@ -161,66 +176,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { * @param isWrapped Whether the new line is wrapped from the previous line. */ public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void { - const buffer = this._bufferService.buffer; - - let newLine: IBufferLine | undefined; - newLine = this._cachedBlankLine; - if (!newLine || newLine.length !== this.cols || newLine.getFg(0) !== eraseAttr.fg || newLine.getBg(0) !== eraseAttr.bg) { - newLine = buffer.getBlankLine(eraseAttr, isWrapped); - this._cachedBlankLine = newLine; - } - newLine.isWrapped = isWrapped; - - const topRow = buffer.ybase + buffer.scrollTop; - const bottomRow = buffer.ybase + buffer.scrollBottom; - - if (buffer.scrollTop === 0) { - // Determine whether the buffer is going to be trimmed after insertion. - const willBufferBeTrimmed = buffer.lines.isFull; - - // Insert the line using the fastest method - if (bottomRow === buffer.lines.length - 1) { - if (willBufferBeTrimmed) { - buffer.lines.recycle().copyFrom(newLine); - } else { - buffer.lines.push(newLine.clone()); - } - } else { - buffer.lines.splice(bottomRow + 1, 0, newLine.clone()); - } - - // Only adjust ybase and ydisp when the buffer is not trimmed - if (!willBufferBeTrimmed) { - buffer.ybase++; - // Only scroll the ydisp with ybase if the user has not scrolled up - if (!this._bufferService.isUserScrolling) { - buffer.ydisp++; - } - } else { - // When the buffer is full and the user has scrolled up, keep the text - // stable unless ydisp is right at the top - if (this._bufferService.isUserScrolling) { - buffer.ydisp = Math.max(buffer.ydisp - 1, 0); - } - } - } else { - // scrollTop is non-zero which means no line will be going to the - // scrollback, instead we can just shift them in-place. - const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */; - buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1); - buffer.lines.set(bottomRow, newLine.clone()); - } - - // Move the viewport to the bottom of the buffer unless the user is - // scrolling. - if (!this._bufferService.isUserScrolling) { - buffer.ydisp = buffer.ybase; - } - - // Flag rows that need updating - this._dirtyRowService.markRangeDirty(buffer.scrollTop, buffer.scrollBottom); - - this._onScroll.fire(buffer.ydisp); + this._bufferService.scroll(eraseAttr, isWrapped); } /** @@ -230,28 +186,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { * to avoid unwanted events being handled by the viewport when the event was triggered from the * viewport originally. */ - public scrollLines(disp: number, suppressScrollEvent?: boolean): void { - const buffer = this._bufferService.buffer; - if (disp < 0) { - if (buffer.ydisp === 0) { - return; - } - this._bufferService.isUserScrolling = true; - } else if (disp + buffer.ydisp >= buffer.ybase) { - this._bufferService.isUserScrolling = false; - } - - const oldYdisp = buffer.ydisp; - buffer.ydisp = Math.max(Math.min(buffer.ydisp + disp, buffer.ybase), 0); - - // No change occurred, don't trigger scroll/refresh - if (oldYdisp === buffer.ydisp) { - return; - } - - if (!suppressScrollEvent) { - this._onScroll.fire(buffer.ydisp); - } + public scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void { + this._bufferService.scrollLines(disp, suppressScrollEvent, source); } /** @@ -259,28 +195,25 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { * @param pageCount The number of pages to scroll (negative scrolls up). */ public scrollPages(pageCount: number): void { - this.scrollLines(pageCount * (this.rows - 1)); + this._bufferService.scrollPages(pageCount); } /** * Scrolls the display of the terminal to the top. */ public scrollToTop(): void { - this.scrollLines(-this._bufferService.buffer.ydisp); + this._bufferService.scrollToTop(); } /** * Scrolls the display of the terminal to the bottom. */ public scrollToBottom(): void { - this.scrollLines(this._bufferService.buffer.ybase - this._bufferService.buffer.ydisp); + this._bufferService.scrollToBottom(); } public scrollToLine(line: number): void { - const scrollAmount = line - this._bufferService.buffer.ydisp; - if (scrollAmount !== 0) { - this.scrollLines(scrollAmount); - } + this._bufferService.scrollToLine(line); } /** Add handler for ESC escape sequence. See xterm.d.ts for details. */ diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 0402f262..cb60aec3 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -66,11 +66,117 @@ describe('InputHandler', () => { optionsService = new MockOptionsService(); bufferService = new BufferService(optionsService); bufferService.resize(80, 30); - coreService = new CoreService(() => {}, bufferService, new MockLogService(), optionsService); + coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService); inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService()); }); + describe('SL/SR/DECIC/DECDC', () => { + beforeEach(() => { + bufferService.resize(5, 5); + optionsService.options.scrollback = 1; + bufferService.reset(); + }); + it('SL (scrollLeft)', async () => { + inputHandler.parseP('12345'.repeat(6)); + inputHandler.parseP('\x1b[ @'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '2345', '2345', '2345', '2345', '2345']); + inputHandler.parseP('\x1b[0 @'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '345', '345', '345', '345', '345']); + inputHandler.parseP('\x1b[2 @'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '5', '5', '5', '5', '5']); + }); + it('SR (scrollRight)', async () => { + inputHandler.parseP('12345'.repeat(6)); + inputHandler.parseP('\x1b[ A'); + assert.deepEqual(getLines(bufferService, 6), ['12345', ' 1234', ' 1234', ' 1234', ' 1234', ' 1234']); + inputHandler.parseP('\x1b[0 A'); + assert.deepEqual(getLines(bufferService, 6), ['12345', ' 123', ' 123', ' 123', ' 123', ' 123']); + inputHandler.parseP('\x1b[2 A'); + assert.deepEqual(getLines(bufferService, 6), ['12345', ' 1', ' 1', ' 1', ' 1', ' 1']); + }); + it('insertColumns (DECIC)', async () => { + inputHandler.parseP('12345'.repeat(6)); + inputHandler.parseP('\x1b[3;3H'); + inputHandler.parseP('\x1b[\'}'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '12 34', '12 34', '12 34', '12 34', '12 34']); + bufferService.reset(); + inputHandler.parseP('12345'.repeat(6)); + inputHandler.parseP('\x1b[3;3H'); + inputHandler.parseP('\x1b[1\'}'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '12 34', '12 34', '12 34', '12 34', '12 34']); + bufferService.reset(); + inputHandler.parseP('12345'.repeat(6)); + inputHandler.parseP('\x1b[3;3H'); + inputHandler.parseP('\x1b[2\'}'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '12 3', '12 3', '12 3', '12 3', '12 3']); + }); + it('deleteColumns (DECDC)', async () => { + inputHandler.parseP('12345'.repeat(6)); + inputHandler.parseP('\x1b[3;3H'); + inputHandler.parseP('\x1b[\'~'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '1245', '1245', '1245', '1245', '1245']); + bufferService.reset(); + inputHandler.parseP('12345'.repeat(6)); + inputHandler.parseP('\x1b[3;3H'); + inputHandler.parseP('\x1b[1\'~'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '1245', '1245', '1245', '1245', '1245']); + bufferService.reset(); + inputHandler.parseP('12345'.repeat(6)); + inputHandler.parseP('\x1b[3;3H'); + inputHandler.parseP('\x1b[2\'~'); + assert.deepEqual(getLines(bufferService, 6), ['12345', '125', '125', '125', '125', '125']); + }); + }); + + describe('BS with reverseWraparound set/unset', () => { + const ttyBS = '\x08 \x08'; // tty ICANON sends on pressing BS + beforeEach(() => { + bufferService.resize(5, 5); + optionsService.options.scrollback = 1; + bufferService.reset(); + }); + describe('reverseWraparound set', () => { + it('should not reverse outside of scroll margins', async () => { + // prepare buffer content + inputHandler.parseP('#####abcdefghijklmnopqrstuvwxy'); + assert.deepEqual(getLines(bufferService, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', 'uvwxy']); + assert.equal(bufferService.buffers.active.ydisp, 1); + assert.equal(bufferService.buffers.active.x, 5); + assert.equal(bufferService.buffers.active.y, 4); + inputHandler.parseP(ttyBS.repeat(100)); + assert.deepEqual(getLines(bufferService, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', ' y']); + + inputHandler.parseP('\x1b[?45h'); + inputHandler.parseP('uvwxy'); + + // set top/bottom to 1/3 (0-based) + inputHandler.parseP('\x1b[2;4r'); + // place cursor below scroll bottom + bufferService.buffers.active.x = 5; + bufferService.buffers.active.y = 4; + inputHandler.parseP(ttyBS.repeat(100)); + assert.deepEqual(getLines(bufferService, 6), ['#####', 'abcde', 'fghij', 'klmno', 'pqrst', ' ']); + + inputHandler.parseP('uvwxy'); + // place cursor within scroll margins + bufferService.buffers.active.x = 5; + bufferService.buffers.active.y = 3; + inputHandler.parseP(ttyBS.repeat(100)); + assert.deepEqual(getLines(bufferService, 6), ['#####', 'abcde', ' ', ' ', ' ', 'uvwxy']); + assert.equal(bufferService.buffers.active.x, 0); + assert.equal(bufferService.buffers.active.y, bufferService.buffers.active.scrollTop); // stops at 0, scrollTop + + inputHandler.parseP('fghijklmnopqrst'); + // place cursor above scroll top + bufferService.buffers.active.x = 5; + bufferService.buffers.active.y = 0; + inputHandler.parseP(ttyBS.repeat(100)); + assert.deepEqual(getLines(bufferService, 6), ['#####', ' ', 'fghij', 'klmno', 'pqrst', 'uvwxy']); + }); + }); + }); + it('save and restore cursor', () => { bufferService.buffer.x = 1; bufferService.buffer.y = 2; @@ -140,7 +246,7 @@ describe('InputHandler', () => { assert.equal(coreService.decPrivateModes.bracketedPasteMode, false); }); }); - describe('regression tests', function(): void { + describe('regression tests', function (): void { function termContent(bufferService: IBufferService, trim: boolean): string[] { const result = []; for (let i = 0; i < bufferService.rows; ++i) result.push(bufferService.buffer.lines.get(i)!.translateToString(trim)); @@ -1240,7 +1346,7 @@ describe('InputHandler', () => { await inputHandler.parseP('\x1b[6H\x1b[2Mm'); assert.deepEqual(getLines(bufferService), ['0', '1', '2', '3', '4', 'm', '6', '7', '8', '9']); await inputHandler.parseP('\x1b[3H\x1b[2Mn'); - assert.deepEqual(getLines(bufferService), ['0', '1', 'n', 'm', '', '', '6', '7', '8', '9']); + assert.deepEqual(getLines(bufferService), ['0', '1', 'n', 'm', '', '', '6', '7', '8', '9']); }); }); it('should parse big chunks in smaller subchunks', async () => { @@ -1759,7 +1865,8 @@ describe('InputHandler', () => { assert.isNotNull(event); assert.deepEqual(event!.colors[0], { colorIndex: 19, red: 0xa1, green: 0xb2, blue: 0xc3 }); - }), + }); + it('4: should ignore incorrect Ansi color change data', () => { // this is testing a private method assert.isNull(inputHandler.parseAnsiColorChange('17;rgb:a/b/c')); @@ -1767,6 +1874,7 @@ describe('InputHandler', () => { assert.isNull(inputHandler.parseAnsiColorChange('17;rgba:aa/bb/cc')); assert.isNull(inputHandler.parseAnsiColorChange('rgb:aa/bb/cc')); }); + it('4: should parse a list of Ansi color changes', () => { // this is testing a private method const event = inputHandler.parseAnsiColorChange('19;rgb:a1/b2/c3;17;rgb:00/11/22;255;rgb:01/ef/2d'); @@ -1818,7 +1926,7 @@ describe('InputHandler - async handlers', () => { optionsService = new MockOptionsService(); bufferService = new BufferService(optionsService); bufferService.resize(80, 30); - coreService = new CoreService(() => {}, bufferService, new MockLogService(), optionsService); + coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService); coreService.onData(data => { console.log(data); }); inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService()); @@ -1827,7 +1935,7 @@ describe('InputHandler - async handlers', () => { it('async CUP with CPR check', async () => { const cup: number[][] = []; const cpr: number[][] = []; - inputHandler.registerCsiHandler({final: 'H'}, async params => { + inputHandler.registerCsiHandler({ final: 'H' }, async params => { cup.push(params.toArray() as number[]); await new Promise(res => setTimeout(res, 50)); // late call of real repositioning @@ -1853,7 +1961,7 @@ describe('InputHandler - async handlers', () => { assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']); }); it('async DCS between', async () => { - inputHandler.registerDcsHandler({final: 'a'}, async (data, params) => { + inputHandler.registerDcsHandler({ final: 'a' }, async (data, params) => { await new Promise(res => setTimeout(res, 50)); assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']); assert.equal(data, 'some data'); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index a35f7981..3af2f9d7 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -240,8 +240,6 @@ export class InputHandler extends Disposable implements IInputHandler { public get onRequestRefreshRows(): IEvent { return this._onRequestRefreshRows.event; } private _onRequestReset = new EventEmitter(); public get onRequestReset(): IEvent { return this._onRequestReset.event; } - private _onRequestScroll = new EventEmitter(); - public get onRequestScroll(): IEvent { return this._onRequestScroll.event; } private _onRequestSyncScrollBar = new EventEmitter(); public get onRequestSyncScrollBar(): IEvent { return this._onRequestSyncScrollBar.event; } private _onRequestWindowsOptionsReport = new EventEmitter(); @@ -651,7 +649,7 @@ export class InputHandler extends Disposable implements IInputHandler { buffer.y++; if (buffer.y === buffer.scrollBottom + 1) { buffer.y--; - this._onRequestScroll.fire(this._eraseAttrData(), true); + this._bufferService.scroll(this._eraseAttrData(), true); } else { if (buffer.y >= this._bufferService.rows) { buffer.y = this._bufferService.rows - 1; @@ -791,7 +789,7 @@ export class InputHandler extends Disposable implements IInputHandler { buffer.y++; if (buffer.y === buffer.scrollBottom + 1) { buffer.y--; - this._onRequestScroll.fire(this._eraseAttrData()); + this._bufferService.scroll(this._eraseAttrData()); } else if (buffer.y >= this._bufferService.rows) { buffer.y = this._bufferService.rows - 1; } @@ -2856,7 +2854,7 @@ export class InputHandler extends Disposable implements IInputHandler { protected _parseAnsiColorChange(data: string): IAnsiColorChangeEvent | null { const result: IAnsiColorChangeEvent = { colors: [] }; // example data: 5;rgb:aa/bb/cc - const regex = /(\d+);rgb:([0-9a-f]{2})\/([0-9a-f]{2})\/([0-9a-f]{2})/gi; + const regex = /(\d+);rgb:([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})/gi; let match; while ((match = regex.exec(data)) !== null) { @@ -2987,7 +2985,7 @@ export class InputHandler extends Disposable implements IInputHandler { this._bufferService.buffer.y++; if (buffer.y === buffer.scrollBottom + 1) { buffer.y--; - this._onRequestScroll.fire(this._eraseAttrData()); + this._bufferService.scroll(this._eraseAttrData()); } else if (buffer.y >= this._bufferService.rows) { buffer.y = this._bufferService.rows - 1; } diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 01ceacbd..dce0f570 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -9,7 +9,7 @@ import { clone } from 'common/Clone'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { IBufferSet, IBuffer } from 'common/buffer/Types'; import { BufferSet } from 'common/buffer/BufferSet'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData } from 'common/Types'; import { UnicodeV6 } from 'common/input/UnicodeV6'; export class MockBufferService implements IBufferService { @@ -17,6 +17,7 @@ export class MockBufferService implements IBufferService { public get buffer(): IBuffer { return this.buffers.active; } public buffers: IBufferSet = {} as any; public onResize: IEvent<{ cols: number, rows: number }> = new EventEmitter<{ cols: number, rows: number }>().event; + public onScroll: IEvent = new EventEmitter().event; public isUserScrolling: boolean = false; constructor( public cols: number, @@ -25,23 +26,41 @@ export class MockBufferService implements IBufferService { ) { this.buffers = new BufferSet(optionsService, this); } + public scrollPages(pageCount: number): void { + throw new Error('Method not implemented.'); + } + public scrollToTop(): void { + throw new Error('Method not implemented.'); + } + public scrollToLine(line: number): void { + throw new Error('Method not implemented.'); + } + public scroll(eraseAttr: IAttributeData, isWrapped: boolean): void { + throw new Error('Method not implemented.'); + } + public scrollToBottom(): void { + throw new Error('Method not implemented.'); + } + public scrollLines(disp: number, suppressScrollEvent?: boolean): void { + throw new Error('Method not implemented.'); + } public resize(cols: number, rows: number): void { this.cols = cols; this.rows = rows; } - public reset(): void {} + public reset(): void { } } export class MockCoreMouseService implements ICoreMouseService { public areMouseEventsActive: boolean = false; public activeEncoding: string = ''; public activeProtocol: string = ''; - public addEncoding(name: string): void {} - public addProtocol(name: string): void {} - public reset(): void {} + public addEncoding(name: string): void { } + public addProtocol(name: string): void { } + public reset(): void { } public triggerMouseEvent(event: ICoreMouseEvent): boolean { return false; } public onProtocolChange: IEvent = new EventEmitter().event; - public explainEvents(events: CoreMouseEventType): {[event: string]: boolean} { + public explainEvents(events: CoreMouseEventType): { [event: string]: boolean } { throw new Error('Method not implemented.'); } } @@ -50,9 +69,9 @@ export class MockCharsetService implements ICharsetService { public serviceBrand: any; public charset: ICharset | undefined; public glevel: number = 0; - public reset(): void {} - public setgLevel(g: number): void {} - public setgCharset(g: number, charset: ICharset): void {} + public reset(): void { } + public setgLevel(g: number): void { } + public setgCharset(g: number, charset: ICharset): void { } } export class MockCoreService implements ICoreService { @@ -75,28 +94,28 @@ export class MockCoreService implements ICoreService { public onData: IEvent = new EventEmitter().event; public onUserInput: IEvent = new EventEmitter().event; public onBinary: IEvent = new EventEmitter().event; - public reset(): void {} - public triggerDataEvent(data: string, wasUserInput?: boolean): void {} - public triggerBinaryEvent(data: string): void {} + public reset(): void { } + public triggerDataEvent(data: string, wasUserInput?: boolean): void { } + public triggerBinaryEvent(data: string): void { } } export class MockDirtyRowService implements IDirtyRowService { public serviceBrand: any; public start: number = 0; public end: number = 0; - public clearRange(): void {} - public markDirty(y: number): void {} - public markRangeDirty(y1: number, y2: number): void {} - public markAllDirty(): void {} + public clearRange(): void { } + public markDirty(y: number): void { } + public markRangeDirty(y1: number, y2: number): void { } + public markAllDirty(): void { } } export class MockLogService implements ILogService { public serviceBrand: any; public logLevel = LogLevelEnum.DEBUG; - public debug(message: any, ...optionalParams: any[]): void {} - public info(message: any, ...optionalParams: any[]): void {} - public warn(message: any, ...optionalParams: any[]): void {} - public error(message: any, ...optionalParams: any[]): void {} + public debug(message: any, ...optionalParams: any[]): void { } + public info(message: any, ...optionalParams: any[]): void { } + public warn(message: any, ...optionalParams: any[]): void { } + public error(message: any, ...optionalParams: any[]): void { } } export class MockOptionsService implements IOptionsService { diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 108d81ac..df299195 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -42,6 +42,16 @@ export interface IKeyboardEvent { type: string; } +export interface IScrollEvent { + position: number; + source: ScrollSource; +} + +export const enum ScrollSource { + TERMINAL, + VIEWPORT, +} + export interface ICircularList { length: number; maxLength: number; @@ -347,7 +357,6 @@ export interface IAnsiColorChangeEvent { */ export interface IInputHandler { onTitleChange: IEvent; - onRequestScroll: IEvent; parse(data: string | Uint8Array, promiseResult?: boolean): void | Promise; print(data: Uint32Array, start: number, end: number): void; diff --git a/src/common/input/WriteBuffer.test.ts b/src/common/input/WriteBuffer.test.ts index d3dfebd0..89106423 100644 --- a/src/common/input/WriteBuffer.test.ts +++ b/src/common/input/WriteBuffer.test.ts @@ -85,5 +85,26 @@ describe('WriteBuffer', () => { done(); }); }); + it('writeSync called from action does not overflow callstack - issue #3265', () => { + wb = new WriteBuffer(data => { + const num = parseInt(data as string); + if (num < 1000000) { + wb.writeSync('' + (num + 1)); + } + }); + wb.writeSync('1'); + }); + it('writeSync maxSubsequentCalls argument', () => { + let last: string = ''; + wb = new WriteBuffer(data => { + last = data as string; + const num = parseInt(data as string); + if (num < 1000000) { + wb.writeSync('' + (num + 1), 10); + } + }); + wb.writeSync('1', 10); + assert.equal(last, '11'); // 1 + 10 sub calls = 11 + }); }); }); diff --git a/src/common/input/WriteBuffer.ts b/src/common/input/WriteBuffer.ts index d22de912..cc84c9ab 100644 --- a/src/common/input/WriteBuffer.ts +++ b/src/common/input/WriteBuffer.ts @@ -42,31 +42,55 @@ export class WriteBuffer { private _callbacks: ((() => void) | undefined)[] = []; private _pendingData = 0; private _bufferOffset = 0; + private _isSyncWriting = false; + private _syncCalls = 0; constructor(private _action: (data: string | Uint8Array, promiseResult?: boolean) => void | Promise) { } /** * @deprecated Unreliable, to be removed soon. */ - public writeSync(data: string | Uint8Array): void { + public writeSync(data: string | Uint8Array, maxSubsequentCalls?: number): void { + // stop writeSync recursions with maxSubsequentCalls argument + // This is dangerous to use as it will lose the current data chunk + // and return immediately. + if (maxSubsequentCalls !== undefined && this._syncCalls > maxSubsequentCalls) { + // comment next line if a whole loop block should only contain x `writeSync` calls + // (total flat vs. deep nested limit) + this._syncCalls = 0; + return; + } + // append chunk to buffer + this._pendingData += data.length; + this._writeBuffer.push(data); + this._callbacks.push(undefined); + + // increase recursion counter + this._syncCalls++; + // exit early if another writeSync loop is active + if (this._isSyncWriting) { + return; + } + this._isSyncWriting = true; + // force sync processing on pending data chunks to avoid in-band data scrambling // does the same as innerWrite but without event loop - if (this._writeBuffer.length) { - for (let i = this._bufferOffset; i < this._writeBuffer.length; ++i) { - const data = this._writeBuffer[i]; - const cb = this._callbacks[i]; - this._action(data); - if (cb) cb(); - } - // reset all to avoid reprocessing of chunks with scheduled innerWrite call - this._writeBuffer = []; - this._callbacks = []; - this._pendingData = 0; - // stop scheduled innerWrite by offset > length condition - this._bufferOffset = 0x7FFFFFFF; + // we have to do it here as single loop steps to not corrupt loop subject + // by another writeSync call triggered from _action + let chunk: string | Uint8Array | undefined; + while (chunk = this._writeBuffer.shift()) { + this._action(chunk); + const cb = this._callbacks.shift(); + if (cb) cb(); } - // handle current data chunk - this._action(data); + // reset to avoid reprocessing of chunks with scheduled innerWrite call + // stopping scheduled innerWrite by offset > length condition + this._pendingData = 0; + this._bufferOffset = 0x7FFFFFFF; + + // allow another writeSync to loop + this._isSyncWriting = false; + this._syncCalls = 0; } public write(data: string | Uint8Array, callback?: () => void): void { @@ -191,8 +215,8 @@ export class WriteBuffer { } setTimeout(() => this._innerWrite()); } else { - this._writeBuffer = []; - this._callbacks = []; + this._writeBuffer.length = 0; + this._callbacks.length = 0; this._pendingData = 0; this._bufferOffset = 0; } diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 47e54729..99594d22 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -8,6 +8,7 @@ import { BufferSet } from 'common/buffer/BufferSet'; import { IBufferSet, IBuffer } from 'common/buffer/Types'; import { EventEmitter, IEvent } from 'common/EventEmitter'; import { Disposable } from 'common/Lifecycle'; +import { IAttributeData, IBufferLine, ScrollSource } from 'common/Types'; export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars export const MINIMUM_ROWS = 1; @@ -23,9 +24,14 @@ export class BufferService extends Disposable implements IBufferService { private _onResize = new EventEmitter<{ cols: number, rows: number }>(); public get onResize(): IEvent<{ cols: number, rows: number }> { return this._onResize.event; } + private _onScroll = new EventEmitter(); + public get onScroll(): IEvent { return this._onScroll.event; } public get buffer(): IBuffer { return this.buffers.active; } + /** An IBufferline to clone/copy from for new blank lines */ + private _cachedBlankLine: IBufferLine | undefined; + constructor( @IOptionsService private _optionsService: IOptionsService ) { @@ -52,4 +58,128 @@ export class BufferService extends Disposable implements IBufferService { this.buffers.reset(); this.isUserScrolling = false; } + + /** + * Scroll the terminal down 1 row, creating a blank line. + * @param isWrapped Whether the new line is wrapped from the previous line. + */ + public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void { + const buffer = this.buffer; + + let newLine: IBufferLine | undefined; + newLine = this._cachedBlankLine; + if (!newLine || newLine.length !== this.cols || newLine.getFg(0) !== eraseAttr.fg || newLine.getBg(0) !== eraseAttr.bg) { + newLine = buffer.getBlankLine(eraseAttr, isWrapped); + this._cachedBlankLine = newLine; + } + newLine.isWrapped = isWrapped; + + const topRow = buffer.ybase + buffer.scrollTop; + const bottomRow = buffer.ybase + buffer.scrollBottom; + + if (buffer.scrollTop === 0) { + // Determine whether the buffer is going to be trimmed after insertion. + const willBufferBeTrimmed = buffer.lines.isFull; + + // Insert the line using the fastest method + if (bottomRow === buffer.lines.length - 1) { + if (willBufferBeTrimmed) { + buffer.lines.recycle().copyFrom(newLine); + } else { + buffer.lines.push(newLine.clone()); + } + } else { + buffer.lines.splice(bottomRow + 1, 0, newLine.clone()); + } + + // Only adjust ybase and ydisp when the buffer is not trimmed + if (!willBufferBeTrimmed) { + buffer.ybase++; + // Only scroll the ydisp with ybase if the user has not scrolled up + if (!this.isUserScrolling) { + buffer.ydisp++; + } + } else { + // When the buffer is full and the user has scrolled up, keep the text + // stable unless ydisp is right at the top + if (this.isUserScrolling) { + buffer.ydisp = Math.max(buffer.ydisp - 1, 0); + } + } + } else { + // scrollTop is non-zero which means no line will be going to the + // scrollback, instead we can just shift them in-place. + const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */; + buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1); + buffer.lines.set(bottomRow, newLine.clone()); + } + + // Move the viewport to the bottom of the buffer unless the user is + // scrolling. + if (!this.isUserScrolling) { + buffer.ydisp = buffer.ybase; + } + + this._onScroll.fire(buffer.ydisp); + } + + /** + * Scroll the display of the terminal + * @param disp The number of lines to scroll down (negative scroll up). + * @param suppressScrollEvent Don't emit the scroll event as scrollLines. This is used + * to avoid unwanted events being handled by the viewport when the event was triggered from the + * viewport originally. + */ + public scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void { + const buffer = this.buffer; + if (disp < 0) { + if (buffer.ydisp === 0) { + return; + } + this.isUserScrolling = true; + } else if (disp + buffer.ydisp >= buffer.ybase) { + this.isUserScrolling = false; + } + + const oldYdisp = buffer.ydisp; + buffer.ydisp = Math.max(Math.min(buffer.ydisp + disp, buffer.ybase), 0); + + // No change occurred, don't trigger scroll/refresh + if (oldYdisp === buffer.ydisp) { + return; + } + + if (!suppressScrollEvent) { + this._onScroll.fire(buffer.ydisp); + } + } + + /** + * Scroll the display of the terminal by a number of pages. + * @param pageCount The number of pages to scroll (negative scrolls up). + */ + public scrollPages(pageCount: number): void { + this.scrollLines(pageCount * (this.rows - 1)); + } + + /** + * Scrolls the display of the terminal to the top. + */ + public scrollToTop(): void { + this.scrollLines(-this.buffer.ydisp); + } + + /** + * Scrolls the display of the terminal to the bottom. + */ + public scrollToBottom(): void { + this.scrollLines(this.buffer.ybase - this.buffer.ydisp); + } + + public scrollToLine(line: number): void { + const scrollAmount = line - this.buffer.ydisp; + if (scrollAmount !== 0) { + this.scrollLines(scrollAmount); + } + } } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 1fbf57fb..8b21f100 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -5,7 +5,7 @@ import { IEvent } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; export const IBufferService = createDecorator('BufferService'); @@ -17,9 +17,14 @@ export interface IBufferService { readonly buffer: IBuffer; readonly buffers: IBufferSet; isUserScrolling: boolean; - onResize: IEvent<{ cols: number, rows: number }>; - + onScroll: IEvent; + scroll(eraseAttr: IAttributeData, isWrapped?: boolean): void; + scrollToBottom(): void; + scrollToTop(): void; + scrollToLine(line: number): void; + scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void; + scrollPages(pageCount: number): void; resize(cols: number, rows: number): void; reset(): void; } diff --git a/yarn.lock b/yarn.lock index 8d8c9dd3..9b49f8db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4820,9 +4820,9 @@ xterm-benchmark@^0.1.3: typescript "^3.5.1" y18n@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" - integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + version "4.0.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" + integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== yargs-parser@13.1.2, yargs-parser@^13.1.2: version "13.1.2"