diff --git a/README.md b/README.md index 2c450e42..5c4d2084 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,26 @@ The full API for xterm.js is contained within the [TypeScript declaration file]( Note that some APIs are marked *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs. +## Releases + +Xterm.js follows a monthly release cycle roughly. + +All current and past releases are available on this repo's [Releases page](https://github.com/sourcelair/xterm.js/releases), you can view the [high-level roadmap on the wiki](https://github.com/xtermjs/xterm.js/wiki/Roadmap) and see what we're working on now by looking through [Milestones](https://github.com/sourcelair/xterm.js/milestones). + +### Beta builds + +Our CI releases beta builds to npm for every change that goes into master. Install the latest beta build with: + +```bash +npm install -S xterm@beta +``` + +These should generally be stable, but some bugs may slip in. We recommend using the beta build primarily to test out new features and to verify bug fixes. + +## Contributing + +You can read the [guide on the wiki](https://github.com/xtermjs/xterm.js/wiki/Contributing) to learn how to contribute and set up xterm.js for development. + ## Real-world uses Xterm.js is used in several world-class applications to provide great terminal experiences. @@ -197,26 +217,6 @@ Xterm.js is used in several world-class applications to provide great terminal e Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Note: Please add any new contributions to the end of the list only. -## Releases - -Xterm.js follows a monthly release cycle roughly. - -All current and past releases are available on this repo's [Releases page](https://github.com/sourcelair/xterm.js/releases), you can view the [high-level roadmap on the wiki](https://github.com/xtermjs/xterm.js/wiki/Roadmap) and see what we're working on now by looking through [Milestones](https://github.com/sourcelair/xterm.js/milestones). - -### Beta builds - -Our CI releases beta builds to npm for every change that goes into master. Install the latest beta build with: - -```bash -npm install -S xterm@beta -``` - -These should generally be stable, but some bugs may slip in. We recommend using the beta build primarily to test out new features and to verify bug fixes. - -## Contributing - -You can read the [guide on the wiki](https://github.com/xtermjs/xterm.js/wiki/Contributing) to learn how to contribute and set up xterm.js for development. - ## License Agreement If you contribute code to this project, you implicitly allow your code to be distributed under the MIT license. You are also implicitly verifying that all code is your original work. diff --git a/addons/xterm-addon-attach/package.json b/addons/xterm-addon-attach/package.json index c6225b46..43a80322 100644 --- a/addons/xterm-addon-attach/package.json +++ b/addons/xterm-addon-attach/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-attach", - "version": "0.6.0", + "version": "0.7.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-canvas/package.json b/addons/xterm-addon-canvas/package.json index fdc2d93a..2555e75d 100644 --- a/addons/xterm-addon-canvas/package.json +++ b/addons/xterm-addon-canvas/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-canvas", - "version": "0.1.0", + "version": "0.2.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-fit/package.json b/addons/xterm-addon-fit/package.json index 1d2f6dd5..ddff6e47 100644 --- a/addons/xterm-addon-fit/package.json +++ b/addons/xterm-addon-fit/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-fit", - "version": "0.5.0", + "version": "0.6.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-ligatures/package.json b/addons/xterm-addon-ligatures/package.json index 525f377c..e62be196 100644 --- a/addons/xterm-addon-ligatures/package.json +++ b/addons/xterm-addon-ligatures/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-ligatures", - "version": "0.5.3", + "version": "0.6.0", "description": "Add support for programming ligatures to xterm.js", "author": { "name": "The xterm.js authors", diff --git a/addons/xterm-addon-search/package.json b/addons/xterm-addon-search/package.json index 8a75c812..f8267f52 100644 --- a/addons/xterm-addon-search/package.json +++ b/addons/xterm-addon-search/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-search", - "version": "0.9.0", + "version": "0.10.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-serialize/package.json b/addons/xterm-addon-serialize/package.json index 94562688..1ba3a34f 100644 --- a/addons/xterm-addon-serialize/package.json +++ b/addons/xterm-addon-serialize/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-serialize", - "version": "0.7.0", + "version": "0.8.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.test.ts b/addons/xterm-addon-serialize/src/SerializeAddon.test.ts index 05f2c61c..df4a72da 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.test.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.test.ts @@ -78,7 +78,7 @@ describe('xterm-addon-serialize', () => { terminal.loadAddon(serializeAddon); selectionService = new TestSelectionService((terminal as any)._core._bufferService); - cm = new ColorManager(document, false); + cm = new ColorManager(); (terminal as any)._core._colorManager = cm; (terminal as any)._core._selectionService = selectionService; }); diff --git a/addons/xterm-addon-unicode11/package.json b/addons/xterm-addon-unicode11/package.json index 275cca4a..488077e7 100644 --- a/addons/xterm-addon-unicode11/package.json +++ b/addons/xterm-addon-unicode11/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-unicode11", - "version": "0.3.0", + "version": "0.4.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-web-links/package.json b/addons/xterm-addon-web-links/package.json index 2cfbae6c..d3e6f863 100644 --- a/addons/xterm-addon-web-links/package.json +++ b/addons/xterm-addon-web-links/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-web-links", - "version": "0.6.0", + "version": "0.7.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-webgl/package.json b/addons/xterm-addon-webgl/package.json index 45b04897..47dcac23 100644 --- a/addons/xterm-addon-webgl/package.json +++ b/addons/xterm-addon-webgl/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-webgl", - "version": "0.12.0", + "version": "0.13.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index b4593d90..4764ede4 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -16,6 +16,7 @@ import { tryDrawCustomChar } from 'browser/renderer/CustomGlyphs'; import { excludeFromContrastRatioDemands, isPowerlineGlyph, isRestrictedPowerlineGlyph } from 'browser/renderer/RendererUtils'; import { IUnicodeService } from 'common/services/Services'; import { FourKeyMap } from 'common/MultiKeyMap'; +import { IdleTaskQueue } from 'common/TaskQueue'; // For debugging purposes, it can be useful to set this to a really tiny value, // to verify that LRU eviction works. @@ -110,7 +111,10 @@ export class WebglCharAtlas implements IDisposable { this._tmpCanvas = document.createElement('canvas'); this._tmpCanvas.width = this._config.scaledCellWidth * 4 + TMP_CANVAS_GLYPH_PADDING * 2; this._tmpCanvas.height = this._config.scaledCellHeight + TMP_CANVAS_GLYPH_PADDING * 2; - this._tmpCtx = throwIfFalsy(this._tmpCanvas.getContext('2d', { alpha: this._config.allowTransparency })); + this._tmpCtx = throwIfFalsy(this._tmpCanvas.getContext('2d', { + alpha: this._config.allowTransparency, + willReadFrequently: true + })); } public dispose(): void { @@ -127,10 +131,15 @@ export class WebglCharAtlas implements IDisposable { } private _doWarmUp(): void { - // Pre-fill with ASCII 33-126 + // Pre-fill with ASCII 33-126, this is not urgent and done in idle callbacks + const queue = new IdleTaskQueue(); for (let i = 33; i < 126; i++) { - const rasterizedGlyph = this._drawToCache(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT); - this._cacheMap.set(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT, rasterizedGlyph); + queue.enqueue(() => { + if (!this._cacheMap.get(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT)) { + const rasterizedGlyph = this._drawToCache(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT); + this._cacheMap.set(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT, rasterizedGlyph); + } + }); } } diff --git a/bin/publish.js b/bin/publish.js index 6b5abcd9..91c6d09a 100644 --- a/bin/publish.js +++ b/bin/publish.js @@ -98,13 +98,8 @@ function getNextBetaVersion(packageJson) { process.exit(1); } const tag = 'beta'; - let nextStableVersion; - if (packageJson.name === 'xterm') { - nextStableVersion = `5.0.0`; - } else { - const stableVersion = packageJson.version.split('.'); - nextStableVersion = `${stableVersion[0]}.${parseInt(stableVersion[1]) + 1}.0`; - } + const stableVersion = packageJson.version.split('.'); + const nextStableVersion = `${stableVersion[0]}.${parseInt(stableVersion[1]) + 1}.0`; const publishedVersions = getPublishedVersions(packageJson, nextStableVersion, tag); if (publishedVersions.length === 0) { return `${nextStableVersion}-${tag}.1`; diff --git a/demo/server.js b/demo/server.js index 8d295942..8bb684a2 100644 --- a/demo/server.js +++ b/demo/server.js @@ -86,7 +86,7 @@ function startServer() { return (data) => { s += data; if (!sender) { - sender = setTimeout(() => { + sender = queueMicrotask(() => { socket.send(s); s = ''; sender = null; @@ -103,7 +103,7 @@ function startServer() { buffer.push(data); length += data.length; if (!sender) { - sender = setTimeout(() => { + sender = queueMicrotask(() => { socket.send(Buffer.concat(buffer, length)); buffer = []; sender = null; diff --git a/package.json b/package.json index a270a52f..a36ad5c9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xterm", "description": "Full xterm terminal, in your browser", - "version": "4.19.0", + "version": "5.0.0", "main": "lib/xterm.js", "style": "css/xterm.css", "types": "typings/xterm.d.ts", diff --git a/src/browser/ColorManager.test.ts b/src/browser/ColorManager.test.ts index 019bf42a..cf60a1f5 100644 --- a/src/browser/ColorManager.test.ts +++ b/src/browser/ColorManager.test.ts @@ -28,7 +28,7 @@ describe('ColorManager', () => { return {data: [0, 0, 0, 0xFF]}; } }); - cm = new ColorManager(document, false); + cm = new ColorManager(); }); describe('constructor', () => { diff --git a/src/browser/ColorManager.ts b/src/browser/ColorManager.ts index d8dcde6f..a22a423f 100644 --- a/src/browser/ColorManager.ts +++ b/src/browser/ColorManager.ts @@ -80,22 +80,11 @@ export const DEFAULT_ANSI_COLORS = Object.freeze((() => { */ export class ColorManager implements IColorManager { public colors: IColorSet; - private _ctx: CanvasRenderingContext2D; - private _litmusColor: CanvasGradient; + private _contrastCache: IColorContrastCache; private _restoreColors!: IRestoreColorSet; - constructor(document: Document, public allowTransparency: boolean) { - const canvas = document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Could not get rendering context'); - } - this._ctx = ctx; - this._ctx.globalCompositeOperation = 'copy'; - this._litmusColor = this._ctx.createLinearGradient(0, 0, 1, 1); + constructor() { this._contrastCache = new ColorContrastCache(); this.colors = { foreground: DEFAULT_FOREGROUND, @@ -118,9 +107,6 @@ export class ColorManager implements IColorManager { case 'minimumContrastRatio': this._contrastCache.clear(); break; - case 'allowTransparency': - this.allowTransparency = value; - break; } } @@ -132,11 +118,11 @@ export class ColorManager implements IColorManager { public setTheme(theme: ITheme = {}): void { this.colors.foreground = this._parseColor(theme.foreground, DEFAULT_FOREGROUND); this.colors.background = this._parseColor(theme.background, DEFAULT_BACKGROUND); - this.colors.cursor = this._parseColor(theme.cursor, DEFAULT_CURSOR, true); - this.colors.cursorAccent = this._parseColor(theme.cursorAccent, DEFAULT_CURSOR_ACCENT, true); - this.colors.selectionBackgroundTransparent = this._parseColor(theme.selectionBackground, DEFAULT_SELECTION, true); + this.colors.cursor = this._parseColor(theme.cursor, DEFAULT_CURSOR); + this.colors.cursorAccent = this._parseColor(theme.cursorAccent, DEFAULT_CURSOR_ACCENT); + this.colors.selectionBackgroundTransparent = this._parseColor(theme.selectionBackground, DEFAULT_SELECTION); this.colors.selectionBackgroundOpaque = color.blend(this.colors.background, this.colors.selectionBackgroundTransparent); - this.colors.selectionInactiveBackgroundTransparent = this._parseColor(theme.selectionInactiveBackground, this.colors.selectionBackgroundTransparent, true); + this.colors.selectionInactiveBackgroundTransparent = this._parseColor(theme.selectionInactiveBackground, this.colors.selectionBackgroundTransparent); this.colors.selectionInactiveBackgroundOpaque = color.blend(this.colors.background, this.colors.selectionInactiveBackgroundTransparent); const nullColor: IColor = { css: '', @@ -220,69 +206,16 @@ export class ColorManager implements IColorManager { } private _parseColor( - css: string | undefined, - fallback: IColor, - allowTransparency: boolean = this.allowTransparency + cssString: string | undefined, + fallback: IColor ): IColor { - if (css === undefined) { - return fallback; - } - - // If parsing the value results in failure, then it must be ignored, and the attribute must - // retain its previous value. - // -- https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles - this._ctx.fillStyle = this._litmusColor; - this._ctx.fillStyle = css; - if (typeof this._ctx.fillStyle !== 'string') { - console.warn(`Color: ${css} is invalid using fallback ${fallback.css}`); - return fallback; - } - - this._ctx.fillRect(0, 0, 1, 1); - const data = this._ctx.getImageData(0, 0, 1, 1).data; - - // Check if the printed color was transparent - if (data[3] !== 0xFF) { - if (!allowTransparency) { - // Ideally we'd just ignore the alpha channel, but... - // - // Browsers may not give back exactly the same RGB values we put in, because most/all - // convert the color to a pre-multiplied representation. getImageData converts that back to - // a un-premultipled representation, but the precision loss may make the RGB channels unuable - // on their own. - // - // E.g. In Chrome #12345610 turns into #10305010, and in the extreme case, 0xFFFFFF00 turns - // into 0x00000000. - // - // "Note: Due to the lossy nature of converting to and from premultiplied alpha color values, - // pixels that have just been set using putImageData() might be returned to an equivalent - // getImageData() as different values." - // -- https://html.spec.whatwg.org/multipage/canvas.html#pixel-manipulation - // - // So let's just use the fallback color in this case instead. - console.warn( - `Color: ${css} is using transparency, but allowTransparency is false. ` + - `Using fallback ${fallback.css}.` - ); - return fallback; + if (cssString !== undefined) { + try { + return css.toColor(cssString); + } catch { + // no-op } - - // https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color - // the color value has alpha less than 1.0, and the string is the color value in the CSS rgba() - const [r, g, b, a] = this._ctx.fillStyle.substring(5, this._ctx.fillStyle.length - 1).split(',').map(component => Number(component)); - const alpha = Math.round(a * 255); - const rgba: number = channels.toRgba(r, g, b, alpha); - return { - rgba, - css - }; } - - return { - // https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color - // if it has alpha equal to 1.0, then the string is a lowercase six-digit hex value, prefixed with a "#" character - css: this._ctx.fillStyle, - rgba: channels.toRgba(data[0], data[1], data[2], data[3]) - }; + return fallback; } } diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 9aba3818..afdac748 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -502,7 +502,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this._instantiationService.setService(ICharSizeService, this._charSizeService); this._theme = this.options.theme || this._theme; - this._colorManager = new ColorManager(document, this.options.allowTransparency); + this._colorManager = new ColorManager(); this.register(this.optionsService.onOptionChange(e => this._colorManager!.onOptionsChange(e, this.optionsService.rawOptions[e]))); this._colorManager.setTheme(this._theme); diff --git a/src/browser/renderer/CustomGlyphs.ts b/src/browser/renderer/CustomGlyphs.ts index 4c3874c7..32256cf4 100644 --- a/src/browser/renderer/CustomGlyphs.ts +++ b/src/browser/renderer/CustomGlyphs.ts @@ -33,7 +33,7 @@ export const blockElementDefinitions: { [index: string]: IBlockVector[] | undefi '▐': [{ x: 4, y: 0, w: 4, h: 8 }], // RIGHT HALF BLOCK // Block elements (0x2594-0x2595) - '▔': [{ x: 0, y: 0, w: 9, h: 1 }], // UPPER ONE EIGHTH BLOCK + '▔': [{ x: 0, y: 0, w: 8, h: 1 }], // UPPER ONE EIGHTH BLOCK '▕': [{ x: 7, y: 0, w: 1, h: 8 }], // RIGHT ONE EIGHTH BLOCK // Terminal graphic characters (0x2596-0x259F) diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index d3eb9e8e..b6ee7bf1 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -166,7 +166,7 @@ export class DomRendererRowFactory { if (cell.isUnderline()) { charElement.classList.add(`${UNDERLINE_CLASS}-${cell.extended.underlineStyle}`); if (charElement.textContent === ' ') { - charElement.innerHTML = ' '; + charElement.textContent = '\xa0'; // =   } if (!cell.isUnderlineColorDefault()) { if (cell.isUnderlineColorRGB()) { diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 7db0e5cd..97258609 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -12,6 +12,7 @@ import { addDisposableDomListener } from 'browser/Lifecycle'; import { IColorSet, IRenderDebouncerWithCallback } from 'browser/Types'; import { IOptionsService, IBufferService, IDecorationService } from 'common/services/Services'; import { ICharSizeService, ICoreBrowserService, IRenderService } from 'browser/services/Services'; +import { DebouncedIdleTask } from 'common/TaskQueue'; interface ISelectionState { start: [number, number] | undefined; @@ -24,6 +25,7 @@ export class RenderService extends Disposable implements IRenderService { private _renderDebouncer: IRenderDebouncerWithCallback; private _screenDprMonitor: ScreenDprMonitor; + private _pausedResizeTask = new DebouncedIdleTask(); private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; @@ -105,6 +107,7 @@ export class RenderService extends Disposable implements IRenderService { } if (!this._isPaused && this._needsFullRefresh) { + this._pausedResizeTask.flush(); this.refreshRows(0, this._rowCount - 1); this._needsFullRefresh = false; } @@ -204,7 +207,11 @@ export class RenderService extends Disposable implements IRenderService { } public onResize(cols: number, rows: number): void { - this._renderer.onResize(cols, rows); + if (this._isPaused) { + this._pausedResizeTask.set(() => this._renderer.onResize(cols, rows)); + } else { + this._renderer.onResize(cols, rows); + } this._fullRefresh(); } diff --git a/src/common/Color.ts b/src/common/Color.ts index a66e39c1..6e70671b 100644 --- a/src/common/Color.ts +++ b/src/common/Color.ts @@ -3,8 +3,14 @@ * @license MIT */ +import { isNode } from 'common/Platform'; import { IColor, IColorRGB } from 'common/Types'; +let $r = 0; +let $g = 0; +let $b = 0; +let $a = 0; + /** * Helper functions where the source type is "channels" (individual color channels as numbers). */ @@ -29,8 +35,8 @@ export namespace channels { */ export namespace color { export function blend(bg: IColor, fg: IColor): IColor { - const a = (fg.rgba & 0xFF) / 255; - if (a === 1) { + $a = (fg.rgba & 0xFF) / 255; + if ($a === 1) { return { css: fg.css, rgba: fg.rgba @@ -42,11 +48,11 @@ export namespace color { const bgR = (bg.rgba >> 24) & 0xFF; const bgG = (bg.rgba >> 16) & 0xFF; const bgB = (bg.rgba >> 8) & 0xFF; - const r = bgR + Math.round((fgR - bgR) * a); - const g = bgG + Math.round((fgG - bgG) * a); - const b = bgB + Math.round((fgB - bgB) * a); - const css = channels.toCss(r, g, b); - const rgba = channels.toRgba(r, g, b); + $r = bgR + Math.round((fgR - bgR) * $a); + $g = bgG + Math.round((fgG - bgG) * $a); + $b = bgB + Math.round((fgB - bgB) * $a); + const css = channels.toCss($r, $g, $b); + const rgba = channels.toRgba($r, $g, $b); return { css, rgba }; } @@ -68,25 +74,25 @@ export namespace color { export function opaque(color: IColor): IColor { const rgbaColor = (color.rgba | 0xFF) >>> 0; - const [r, g, b] = rgba.toChannels(rgbaColor); + [$r, $g, $b] = rgba.toChannels(rgbaColor); return { - css: channels.toCss(r, g, b), + css: channels.toCss($r, $g, $b), rgba: rgbaColor }; } export function opacity(color: IColor, opacity: number): IColor { - const a = Math.round(opacity * 0xFF); - const [r, g, b] = rgba.toChannels(color.rgba); + $a = Math.round(opacity * 0xFF); + [$r, $g, $b] = rgba.toChannels(color.rgba); return { - css: channels.toCss(r, g, b, a), - rgba: channels.toRgba(r, g, b, a) + css: channels.toCss($r, $g, $b, $a), + rgba: channels.toRgba($r, $g, $b, $a) }; } export function multiplyOpacity(color: IColor, factor: number): IColor { - const a = color.rgba & 0xFF; - return opacity(color, (a * factor) / 0xFF); + $a = color.rgba & 0xFF; + return opacity(color, ($a * factor) / 0xFF); } export function toColorRGB(color: IColor): IColorRGB { @@ -98,21 +104,45 @@ export namespace color { * Helper functions where the source type is "css" (string: '#rgb', '#rgba', '#rrggbb', '#rrggbbaa'). */ export namespace css { + let $ctx: CanvasRenderingContext2D | undefined; + let $litmusColor: CanvasGradient | undefined; + if (!isNode) { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d', { + willReadFrequently: true + }); + if (ctx) { + $ctx = ctx; + $ctx.globalCompositeOperation = 'copy'; + $litmusColor = $ctx.createLinearGradient(0, 0, 1, 1); + } + } + + /** + * Converts a css string to an IColor, this should handle all valid CSS color strings and will + * throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse. + * + * Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node + * environment. + */ export function toColor(css: string): IColor { + // Formats: #rgb[a] and #rrggbb[aa] if (css.match(/#[0-9a-f]{3,8}/i)) { switch (css.length) { case 4: { // #rgb - const r = parseInt(css.slice(1, 2).repeat(2), 16); - const g = parseInt(css.slice(2, 3).repeat(2), 16); - const b = parseInt(css.slice(3, 4).repeat(2), 16); - return rgba.toColor(r, g, b); + $r = parseInt(css.slice(1, 2).repeat(2), 16); + $g = parseInt(css.slice(2, 3).repeat(2), 16); + $b = parseInt(css.slice(3, 4).repeat(2), 16); + return rgba.toColor($r, $g, $b); } case 5: { // #rgba - const r = parseInt(css.slice(1, 2).repeat(2), 16); - const g = parseInt(css.slice(2, 3).repeat(2), 16); - const b = parseInt(css.slice(3, 4).repeat(2), 16); - const a = parseInt(css.slice(4, 5).repeat(2), 16); - return rgba.toColor(r, g, b, a); + $r = parseInt(css.slice(1, 2).repeat(2), 16); + $g = parseInt(css.slice(2, 3).repeat(2), 16); + $b = parseInt(css.slice(3, 4).repeat(2), 16); + $a = parseInt(css.slice(4, 5).repeat(2), 16); + return rgba.toColor($r, $g, $b, $a); } case 7: // #rrggbb return { @@ -126,15 +156,45 @@ export namespace css { }; } } + + // Formats: rgb() or rgba() const rgbaMatch = css.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/); - if (rgbaMatch) { // rgb() or rgba() - const r = parseInt(rgbaMatch[1]); - const g = parseInt(rgbaMatch[2]); - const b = parseInt(rgbaMatch[3]); - const a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF); - return rgba.toColor(r, g, b, a); + if (rgbaMatch) { + $r = parseInt(rgbaMatch[1]); + $g = parseInt(rgbaMatch[2]); + $b = parseInt(rgbaMatch[3]); + $a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF); + return rgba.toColor($r, $g, $b, $a); } - throw new Error('css.toColor: Unsupported css format'); + + // Validate the context is available for canvas-based color parsing + if (!$ctx || !$litmusColor) { + throw new Error('css.toColor: Unsupported css format'); + } + + // Validate the color using canvas fillStyle + // See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles + $ctx.fillStyle = $litmusColor; + $ctx.fillStyle = css; + if (typeof $ctx.fillStyle !== 'string') { + throw new Error('css.toColor: Unsupported css format'); + } + + $ctx.fillRect(0, 0, 1, 1); + [$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data; + + // Validate the color is non-transparent as color hue gets lost when drawn to the canvas + if ($a !== 0xFF) { + throw new Error('css.toColor: Unsupported css format'); + } + + // Extract the color from the canvas' fillStyle property which exposes the color value in rgba() + // format + // See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color + return { + rgba: channels.toRgba($r, $g, $b, $a), + css + }; } } diff --git a/src/common/Platform.ts b/src/common/Platform.ts index 7b823b12..6be0584f 100644 --- a/src/common/Platform.ts +++ b/src/common/Platform.ts @@ -13,7 +13,7 @@ interface INavigator { // we want this module to live in common. declare const navigator: INavigator; -const isNode = (typeof navigator === 'undefined') ? true : false; +export const isNode = (typeof navigator === 'undefined') ? true : false; const userAgent = (isNode) ? 'node' : navigator.userAgent; const platform = (isNode) ? 'node' : navigator.platform; diff --git a/src/common/TaskQueue.ts b/src/common/TaskQueue.ts new file mode 100644 index 00000000..94c5c53b --- /dev/null +++ b/src/common/TaskQueue.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { isNode } from 'common/Platform'; + +interface ITaskQueue { + /** + * Adds a task to the queue which will run in a future idle callback. + */ + enqueue(task: () => void): void; + + /** + * Flushes the queue, running all remaining tasks synchronously. + */ + flush(): void; + + /** + * Clears any remaining tasks from the queue, these will not be run. + */ + clear(): void; +} + +interface ITaskDeadline { + timeRemaining(): number; +} +type CallbackWithDeadline = (deadline: ITaskDeadline) => void; + +abstract class TaskQueue implements ITaskQueue { + private _tasks: (() => void)[] = []; + private _idleCallback?: number; + private _i = 0; + + protected abstract _requestCallback(callback: CallbackWithDeadline): number; + protected abstract _cancelCallback(identifier: number): void; + + public enqueue(task: () => void): void { + this._tasks.push(task); + this._start(); + } + + public flush(): void { + while (this._i < this._tasks.length) { + this._tasks[this._i++](); + } + this.clear(); + } + + public clear(): void { + if (this._idleCallback) { + this._cancelCallback(this._idleCallback); + this._idleCallback = undefined; + } + this._i = 0; + this._tasks.length = 0; + } + + private _start(): void { + if (!this._idleCallback) { + this._idleCallback = this._requestCallback(this._process.bind(this)); + } + } + + private _process(deadline: ITaskDeadline): void { + this._idleCallback = undefined; + let taskDuration = 0; + let longestTask = 0; + while (this._i < this._tasks.length) { + taskDuration = performance.now(); + this._tasks[this._i++](); + taskDuration = performance.now() - taskDuration; + longestTask = Math.max(taskDuration, longestTask); + // Guess the following task will take a similar time to the longest task in this batch, allow + // additional room to try avoid exceeding the deadline + if (longestTask * 1.5 > deadline.timeRemaining()) { + this._start(); + return; + } + } + this.clear(); + } +} + +/** + * A queue of that runs tasks over several tasks via setTimeout, trying to maintain above 60 frames + * per second. The tasks will run in the order they are enqueued, but they will run some time later, + * and care should be taken to ensure they're non-urgent and will not introduce race conditions. + */ +export class PriorityTaskQueue extends TaskQueue { + protected _requestCallback(callback: CallbackWithDeadline): number { + return setTimeout(() => callback(this._createDeadline(16))); + } + + protected _cancelCallback(identifier: number): void { + clearTimeout(identifier); + } + + private _createDeadline(duration: number): ITaskDeadline { + const end = performance.now() + duration; + return { + timeRemaining: () => Math.max(0, end - performance.now()) + }; + } +} + +class IdleTaskQueueInternal extends TaskQueue { + protected _requestCallback(callback: IdleRequestCallback): number { + return requestIdleCallback(callback); + } + + protected _cancelCallback(identifier: number): void { + cancelIdleCallback(identifier); + } +} + +/** + * A queue of that runs tasks over several idle callbacks, trying to respect the idle callback's + * deadline given by the environment. The tasks will run in the order they are enqueued, but they + * will run some time later, and care should be taken to ensure they're non-urgent and will not + * introduce race conditions. + * + * This reverts to a {@link PriorityTaskQueue} if the environment does not support idle callbacks. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +export const IdleTaskQueue = (!isNode && 'requestIdleCallback' in window) ? IdleTaskQueueInternal : PriorityTaskQueue; + +/** + * An object that tracks a single debounced task that will run on the next idle frame. When called + * multiple times, only the last set task will run. + */ +export class DebouncedIdleTask { + private _queue: ITaskQueue; + + constructor() { + this._queue = new IdleTaskQueue(); + } + + public set(task: () => void): void { + this._queue.clear(); + this._queue.enqueue(task); + } + + public flush(): void { + this._queue.flush(); + } +} diff --git a/src/common/input/WriteBuffer.ts b/src/common/input/WriteBuffer.ts index 67fd751e..2cdf4e3c 100644 --- a/src/common/input/WriteBuffer.ts +++ b/src/common/input/WriteBuffer.ts @@ -33,12 +33,6 @@ const WRITE_TIMEOUT_MS = 12; */ const WRITE_BUFFER_LENGTH_THRESHOLD = 50; -// queueMicrotask polyfill for nodejs < v11 -const qmt: (cb: () => void) => void = (typeof queueMicrotask === 'undefined') - ? (cb: () => void) => { Promise.resolve().then(cb); } - : queueMicrotask; - - export class WriteBuffer { private _writeBuffer: (string | Uint8Array)[] = []; private _callbacks: ((() => void) | undefined)[] = []; @@ -105,7 +99,7 @@ export class WriteBuffer { // schedule chunk processing for next event loop run if (!this._writeBuffer.length) { this._bufferOffset = 0; - setTimeout(() => this._innerWrite()); + queueMicrotask(() => this._innerWrite()); } this._pendingData += data.length; @@ -194,7 +188,7 @@ export class WriteBuffer { // 2. spawn a promise immediately resolving to `true` // (executed on the same queue, thus properly aligned before continuation happens) result.catch(err => { - qmt(() => {throw err;}); + queueMicrotask(() => {throw err;}); return Promise.resolve(false); }).then(continuation); return; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 1c0a986a..7453b7e2 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -19,8 +19,7 @@ declare module 'xterm-headless' { export interface ITerminalOptions { /** * Whether to allow the use of proposed API. When false, any usage of APIs - * marked as experimental/proposed will throw an error. This defaults to - * true currently, but will change to false in v5.0. + * marked as experimental/proposed will throw an error. The default is false. */ allowProposedApi?: boolean; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index d66e3b03..0e2b0357 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -26,8 +26,7 @@ declare module 'xterm' { export interface ITerminalOptions { /** * Whether to allow the use of proposed API. When false, any usage of APIs - * marked as experimental/proposed will throw an error. This defaults to - * true currently, but will change to false in v5.0. + * marked as experimental/proposed will throw an error. The default is false. */ allowProposedApi?: boolean;