diff --git a/AUTHORS b/AUTHORS index 41f3a52349..0fed60b310 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Benjamin Woodruff Bill Church Bob Reid bottleofwater +Brandon Bayer Brian Mock Bruno Ribeiro Bruno Ribeito @@ -62,12 +63,16 @@ Jianhui Zhao Joao Moreno Joao Moreno Johannes Zellner +Jon Austin Jon Masters Jörg Breitbart +jpoth Justin Luk Justin Mecham Kirill Merkushev Krasimir Tsonev +Ledion Bitincka +Linus Unnebäck Luca Lucian Buzzo Lukas Drgon @@ -88,8 +93,11 @@ npezza93 Oleksandr Andriienko Paris Kasidiaris Paris Kasidiaris +Peng Xiao Peter Baumgarten Philip Olson +pro-src <34285059+pro-src@users.noreply.github.com> +pro-src Rick Baker runarberg Saad Malik diff --git a/demo/index.html b/demo/index.html index 9c19ea8366..168f56e459 100644 --- a/demo/index.html +++ b/demo/index.html @@ -26,6 +26,9 @@

Options

+

+ +

Options

+

+ +

Size

diff --git a/demo/main.js b/demo/main.js index d9c0151ad1..682fc5a072 100644 --- a/demo/main.js +++ b/demo/main.js @@ -31,7 +31,9 @@ var terminalContainer = document.getElementById('terminal-container'), cursorStyle: document.querySelector('#option-cursor-style'), macOptionIsMeta: document.querySelector('#option-mac-option-is-meta'), scrollback: document.querySelector('#option-scrollback'), + transparency: document.querySelector('#option-transparency'), tabstopwidth: document.querySelector('#option-tabstopwidth'), + experimentalCharAtlas: document.querySelector('#option-experimental-char-atlas'), bellStyle: document.querySelector('#option-bell-style'), screenReaderMode: document.querySelector('#option-screen-reader-mode') }, @@ -74,21 +76,29 @@ actionElements.findPrevious.addEventListener('keypress', function (e) { optionElements.cursorBlink.addEventListener('change', function () { term.setOption('cursorBlink', optionElements.cursorBlink.checked); }); +optionElements.macOptionIsMeta.addEventListener('change', function () { + term.setOption('macOptionIsMeta', optionElements.macOptionIsMeta.checked); +}); +optionElements.transparency.addEventListener('change', function () { + var checked = optionElements.transparency.checked; + term.setOption('allowTransparency', checked); + term.setOption('theme', checked ? {background: 'rgba(0, 0, 0, .5)'} : {}); +}); optionElements.cursorStyle.addEventListener('change', function () { term.setOption('cursorStyle', optionElements.cursorStyle.value); }); optionElements.bellStyle.addEventListener('change', function () { term.setOption('bellStyle', optionElements.bellStyle.value); }); -optionElements.macOptionIsMeta.addEventListener('change', function () { - term.setOption('macOptionIsMeta', optionElements.macOptionIsMeta.checked); -}); optionElements.scrollback.addEventListener('change', function () { term.setOption('scrollback', parseInt(optionElements.scrollback.value, 10)); }); optionElements.tabstopwidth.addEventListener('change', function () { term.setOption('tabStopWidth', parseInt(optionElements.tabstopwidth.value, 10)); }); +optionElements.experimentalCharAtlas.addEventListener('change', function () { + term.setOption('experimentalCharAtlas', optionElements.experimentalCharAtlas.value); +}); optionElements.screenReaderMode.addEventListener('change', function () { term.setOption('screenReaderMode', optionElements.screenReaderMode.checked); }); diff --git a/package.json b/package.json index 5f3d835eee..44cc2e88d2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xterm", "description": "Full xterm terminal, in your browser", - "version": "3.3.0", + "version": "3.4.0", "main": "lib/Terminal.js", "types": "typings/xterm.d.ts", "repository": "https://github.com/xtermjs/xterm.js", diff --git a/src/Terminal.ts b/src/Terminal.ts index 05781b9670..fc3998d802 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -48,7 +48,7 @@ import { MouseZoneManager } from './input/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; import { ScreenDprMonitor } from './utils/ScreenDprMonitor'; import { ITheme, ILocalizableStrings, IMarker, IDisposable } from 'xterm'; -import { removeTerminalFromCache } from './renderer/atlas/CharAtlas'; +import { removeTerminalFromCache } from './renderer/atlas/CharAtlasCache'; // reg + shift key mappings for digits and special chars const KEYCODE_KEY_MAPPINGS = { @@ -105,6 +105,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = { bellStyle: 'none', drawBoldTextInBrightColors: true, enableBold: true, + experimentalCharAtlas: 'static', fontFamily: 'courier-new, courier, monospace', fontSize: 15, fontWeight: 'normal', @@ -478,6 +479,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.charMeasure.measure(this.options); } break; + case 'experimentalCharAtlas': case 'enableBold': case 'letterSpacing': case 'lineHeight': diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index c2b65e169f..b2a402906d 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -6,8 +6,8 @@ import { IRenderLayer, IColorSet, IRenderDimensions } from './Types'; import { CharData, ITerminal } from '../Types'; import { DIM_OPACITY, INVERTED_DEFAULT_COLOR } from './atlas/Types'; -import { CHAR_ATLAS_CELL_SPACING } from '../shared/atlas/Types'; -import { acquireCharAtlas } from './atlas/CharAtlas'; +import BaseCharAtlas from './atlas/BaseCharAtlas'; +import { acquireCharAtlas } from './atlas/CharAtlasCache'; import { CHAR_DATA_CHAR_INDEX } from '../Buffer'; export abstract class BaseRenderLayer implements IRenderLayer { @@ -20,7 +20,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { private _scaledCharLeft: number = 0; private _scaledCharTop: number = 0; - private _charAtlas: HTMLCanvasElement | ImageBitmap; + protected _charAtlas: BaseCharAtlas; constructor( private _container: HTMLElement, @@ -83,13 +83,8 @@ export abstract class BaseRenderLayer implements IRenderLayer { if (this._scaledCharWidth <= 0 && this._scaledCharHeight <= 0) { return; } - this._charAtlas = null; - const result = acquireCharAtlas(terminal, colorSet, this._scaledCharWidth, this._scaledCharHeight); - if (result instanceof HTMLCanvasElement) { - this._charAtlas = result; - } else { - result.then(bitmap => this._charAtlas = bitmap); - } + this._charAtlas = acquireCharAtlas(terminal, colorSet, this._scaledCharWidth, this._scaledCharHeight); + this._charAtlas.warmUp(); } public resize(terminal: ITerminal, dim: IRenderDimensions): void { @@ -243,46 +238,18 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param bold Whether the text is bold. */ protected drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bg: number, bold: boolean, dim: boolean, italic: boolean): void { - const isAscii = code < 256; - // A color is basic if it is one of the 4 bit ANSI colors. - const isBasicColor = fg < 16; - const isDefaultColor = fg >= 256; - const isDefaultBackground = bg >= 256; - const drawInBrightColor = (terminal.options.drawBoldTextInBrightColors && bold && fg < 8); - if (this._charAtlas && isAscii && (isBasicColor || isDefaultColor) && isDefaultBackground && !italic) { - this._ctx.save(); // we may set globalAlpha, so we need to be able to restore - let colorIndex: number; - if (isDefaultColor) { - colorIndex = (bold && terminal.options.enableBold ? 1 : 0); - } else { - colorIndex = 2 + fg + (bold && terminal.options.enableBold ? 16 : 0) + (drawInBrightColor ? 8 : 0); - } - - // ImageBitmap's draw about twice as fast as from a canvas - const charAtlasCellWidth = this._scaledCharWidth + CHAR_ATLAS_CELL_SPACING; - const charAtlasCellHeight = this._scaledCharHeight + CHAR_ATLAS_CELL_SPACING; - - // Apply alpha to dim the character - if (dim) { - this._ctx.globalAlpha = DIM_OPACITY; - } - - this._ctx.drawImage(this._charAtlas, - code * charAtlasCellWidth, - colorIndex * charAtlasCellHeight, - charAtlasCellWidth, - this._scaledCharHeight, - x * this._scaledCellWidth + this._scaledCharLeft, - y * this._scaledCellHeight + this._scaledCharTop, - charAtlasCellWidth, - this._scaledCharHeight); - this._ctx.restore(); - } else { - this._drawUncachedChar(terminal, char, width, fg + (drawInBrightColor ? 8 : 0), x, y, bold && terminal.options.enableBold, dim, italic); + const drawInBrightColor = terminal.options.drawBoldTextInBrightColors && bold && fg < 8; + fg += drawInBrightColor ? 8 : 0; + const atlasDidDraw = this._charAtlas && this._charAtlas.draw( + this._ctx, + {char, code, bg, fg, bold: bold && terminal.options.enableBold, dim, italic}, + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop + ); + + if (!atlasDidDraw) { + this._drawUncachedChar(terminal, char, width, fg, x, y, bold && terminal.options.enableBold, dim, italic); } - // This draws the atlas (for debugging purposes) - // this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); - // this._ctx.drawImage(this._charAtlas, 0, 0); } /** diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 9404a461a8..c41dece551 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -150,6 +150,7 @@ export class Renderer extends EventEmitter implements IRenderer { } public onOptionsChanged(): void { + this.colorManager.allowTransparency = this._terminal.options.allowTransparency; this._runOperation(l => l.onOptionsChanged(this._terminal)); } diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index 0dca11b20a..d6f9693667 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -208,6 +208,8 @@ export class TextRenderLayer extends BaseRenderLayer { return; } + this._charAtlas.beginFrame(); + this.clearCells(0, firstRow, terminal.cols, lastRow - firstRow + 1); this._drawBackground(terminal, firstRow, lastRow); this._drawForeground(terminal, firstRow, lastRow); diff --git a/src/renderer/atlas/BaseCharAtlas.ts b/src/renderer/atlas/BaseCharAtlas.ts new file mode 100644 index 0000000000..50d35faa6f --- /dev/null +++ b/src/renderer/atlas/BaseCharAtlas.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IGlyphIdentifier } from './Types'; + +export default abstract class BaseCharAtlas { + private _didWarmUp: boolean = false; + + /** + * Perform any work needed to warm the cache before it can be used. May be called multiple times. + * Implement _doWarmUp instead if you only want to get called once. + */ + public warmUp(): void { + if (!this._didWarmUp) { + this._doWarmUp(); + this._didWarmUp = true; + } + } + + /** + * Perform any work needed to warm the cache before it can be used. Used by the default + * implementation of warmUp(), and will only be called once. + */ + protected _doWarmUp(): void { } + + /** + * Called when we start drawing a new frame. + * + * TODO: We rely on this getting called by TextRenderLayer. This should really be called by + * Renderer instead, but we need to make Renderer the source-of-truth for the char atlas, instead + * of BaseRenderLayer. + */ + public beginFrame(): void { } + + /** + * May be called before warmUp finishes, however it is okay for the implementation to + * do nothing and return false in that case. + * + * @param ctx Where to draw the character onto. + * @param glyph Information about what to draw + * @param x The position on the context to start drawing at + * @param y The position on the context to start drawing at + * @returns The success state. True if we drew the character. + */ + public abstract draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean; +} diff --git a/src/renderer/atlas/CharAtlas.ts b/src/renderer/atlas/CharAtlasCache.ts similarity index 73% rename from src/renderer/atlas/CharAtlas.ts rename to src/renderer/atlas/CharAtlasCache.ts index db5c92f63a..eee93d6cba 100644 --- a/src/renderer/atlas/CharAtlas.ts +++ b/src/renderer/atlas/CharAtlasCache.ts @@ -6,16 +6,27 @@ import { ITerminal } from '../../Types'; import { IColorSet } from '../Types'; import { ICharAtlasConfig } from '../../shared/atlas/Types'; -import { generateCharAtlas } from '../../shared/atlas/CharAtlasGenerator'; import { generateConfig, configEquals } from './CharAtlasUtils'; +import BaseCharAtlas from './BaseCharAtlas'; +import DynamicCharAtlas from './DynamicCharAtlas'; +import NoneCharAtlas from './NoneCharAtlas'; +import StaticCharAtlas from './StaticCharAtlas'; + +const charAtlasImplementations = { + 'none': NoneCharAtlas, + 'static': StaticCharAtlas, + 'dynamic': DynamicCharAtlas +}; interface ICharAtlasCacheEntry { - bitmap: HTMLCanvasElement | Promise; + atlas: BaseCharAtlas; config: ICharAtlasConfig; + // N.B. This implementation potentially holds onto copies of the terminal forever, so + // this may cause memory leaks. ownedBy: ITerminal[]; } -let charAtlasCache: ICharAtlasCacheEntry[] = []; +const charAtlasCache: ICharAtlasCacheEntry[] = []; /** * Acquires a char atlas, either generating a new one or returning an existing @@ -23,7 +34,12 @@ let charAtlasCache: ICharAtlasCacheEntry[] = []; * @param terminal The terminal. * @param colors The colors to use. */ -export function acquireCharAtlas(terminal: ITerminal, colors: IColorSet, scaledCharWidth: number, scaledCharHeight: number): HTMLCanvasElement | Promise { +export function acquireCharAtlas( + terminal: ITerminal, + colors: IColorSet, + scaledCharWidth: number, + scaledCharHeight: number +): BaseCharAtlas { const newConfig = generateConfig(scaledCharWidth, scaledCharHeight, terminal, colors); // TODO: Currently if a terminal changes configs it will not free the entry reference (until it's disposed) @@ -34,7 +50,7 @@ export function acquireCharAtlas(terminal: ITerminal, colors: IColorSet, scaledC const ownedByIndex = entry.ownedBy.indexOf(terminal); if (ownedByIndex >= 0) { if (configEquals(entry.config, newConfig)) { - return entry.bitmap; + return entry.atlas; } // The configs differ, release the terminal from the entry if (entry.ownedBy.length === 1) { @@ -52,24 +68,20 @@ export function acquireCharAtlas(terminal: ITerminal, colors: IColorSet, scaledC if (configEquals(entry.config, newConfig)) { // Add the terminal to the cache entry and return entry.ownedBy.push(terminal); - return entry.bitmap; + return entry.atlas; } } - const canvasFactory = (width: number, height: number) => { - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - return canvas; - }; - const newEntry: ICharAtlasCacheEntry = { - bitmap: generateCharAtlas(window, canvasFactory, newConfig), + atlas: new charAtlasImplementations[terminal.options.experimentalCharAtlas]( + document, + newConfig + ), config: newConfig, ownedBy: [terminal] }; charAtlasCache.push(newEntry); - return newEntry.bitmap; + return newEntry.atlas; } /** diff --git a/src/renderer/atlas/CharAtlasUtils.ts b/src/renderer/atlas/CharAtlasUtils.ts index 24ed5a488f..39284d3232 100644 --- a/src/renderer/atlas/CharAtlasUtils.ts +++ b/src/renderer/atlas/CharAtlasUtils.ts @@ -8,15 +8,19 @@ import { IColorSet } from '../Types'; import { ICharAtlasConfig } from '../../shared/atlas/Types'; export function generateConfig(scaledCharWidth: number, scaledCharHeight: number, terminal: ITerminal, colors: IColorSet): ICharAtlasConfig { + // null out some fields that don't matter const clonedColors = { foreground: colors.foreground, background: colors.background, cursor: null, cursorAccent: null, selection: null, + // For the static char atlas, we only use the first 16 colors, but we need all 256 for the + // dynamic character atlas. ansi: colors.ansi.slice(0, 16) }; return { + type: terminal.options.experimentalCharAtlas, devicePixelRatio: window.devicePixelRatio, scaledCharWidth, scaledCharHeight, @@ -35,7 +39,8 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean return false; } } - return a.devicePixelRatio === b.devicePixelRatio && + return a.type === b.type && + a.devicePixelRatio === b.devicePixelRatio && a.fontFamily === b.fontFamily && a.fontSize === b.fontSize && a.fontWeight === b.fontWeight && diff --git a/src/renderer/atlas/DynamicCharAtlas.ts b/src/renderer/atlas/DynamicCharAtlas.ts new file mode 100644 index 0000000000..bf2d6f90f4 --- /dev/null +++ b/src/renderer/atlas/DynamicCharAtlas.ts @@ -0,0 +1,242 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { DIM_OPACITY, IGlyphIdentifier, INVERTED_DEFAULT_COLOR } from './Types'; +import { ICharAtlasConfig } from '../../shared/atlas/Types'; +import { IColor } from '../../shared/Types'; +import BaseCharAtlas from './BaseCharAtlas'; +import { DEFAULT_ANSI_COLORS } from '../ColorManager'; +import { clearColor } from '../../shared/atlas/CharAtlasGenerator'; +import LRUMap from './LRUMap'; + +// In practice we're probably never going to exhaust a texture this large. For debugging purposes, +// however, it can be useful to set this to a really tiny value, to verify that LRU eviction works. +const TEXTURE_WIDTH = 1024; +const TEXTURE_HEIGHT = 1024; + +const TRANSPARENT_COLOR = { + css: 'rgba(0, 0, 0, 0)', + rgba: 0 +}; + +// Drawing to the cache is expensive: If we have to draw more than this number of glyphs to the +// cache in a single frame, give up on trying to cache anything else, and try to finish the current +// frame ASAP. +// +// This helps to limit the amount of damage a program can do when it would otherwise thrash the +// cache. +const FRAME_CACHE_DRAW_LIMIT = 100; + +interface IGlyphCacheValue { + index: number; + isEmpty: boolean; +} + +function getGlyphCacheKey(glyph: IGlyphIdentifier): string { + const styleFlags = (glyph.bold ? 0 : 4) + (glyph.dim ? 0 : 2) + (glyph.italic ? 0 : 1); + return `${glyph.bg}_${glyph.fg}_${styleFlags}${glyph.char}`; +} + +export default class DynamicCharAtlas extends BaseCharAtlas { + // An ordered map that we're using to keep track of where each glyph is in the atlas texture. + // It's ordered so that we can determine when to remove the old entries. + private _cacheMap: LRUMap; + + // The texture that the atlas is drawn to + private _cacheCanvas: HTMLCanvasElement; + private _cacheCtx: CanvasRenderingContext2D; + + // A temporary context that glyphs are drawn to before being transfered to the atlas. + private _tmpCtx: CanvasRenderingContext2D; + + // The number of characters stored in the atlas by width/height + private _width: number; + private _height: number; + + private _drawToCacheCount: number = 0; + + constructor(document: Document, private _config: ICharAtlasConfig) { + super(); + this._cacheCanvas = document.createElement('canvas'); + this._cacheCanvas.width = TEXTURE_WIDTH; + this._cacheCanvas.height = TEXTURE_HEIGHT; + // The canvas needs alpha because we use clearColor to convert the background color to alpha. + // It might also contain some characters with transparent backgrounds if allowTransparency is + // set. + this._cacheCtx = this._cacheCanvas.getContext('2d', {alpha: true}); + + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = this._config.scaledCharWidth; + tmpCanvas.height = this._config.scaledCharHeight; + this._tmpCtx = tmpCanvas.getContext('2d', {alpha: this._config.allowTransparency}); + + this._width = Math.floor(TEXTURE_WIDTH / this._config.scaledCharWidth); + this._height = Math.floor(TEXTURE_HEIGHT / this._config.scaledCharHeight); + const capacity = this._width * this._height; + this._cacheMap = new LRUMap(capacity); + this._cacheMap.prealloc(capacity); + + // This is useful for debugging + // document.body.appendChild(this._cacheCanvas); + } + + public beginFrame(): void { + this._drawToCacheCount = 0; + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + const glyphKey = getGlyphCacheKey(glyph); + const cacheValue = this._cacheMap.get(glyphKey); + if (cacheValue != null) { + this._drawFromCache(ctx, cacheValue, x, y); + return true; + } else if (this._canCache(glyph) && this._drawToCacheCount < FRAME_CACHE_DRAW_LIMIT) { + let index; + if (this._cacheMap.size < this._cacheMap.capacity) { + index = this._cacheMap.size; + } else { + // we're out of space, so our call to set will delete this item + index = this._cacheMap.peek().index; + } + const cacheValue = this._drawToCache(glyph, index); + this._cacheMap.set(glyphKey, cacheValue); + this._drawFromCache(ctx, cacheValue, x, y); + return true; + } + return false; + } + + private _canCache(glyph: IGlyphIdentifier): boolean { + // Only cache ascii and extended characters for now, to be safe. In the future, we could do + // something more complicated to determine the expected width of a character. + // + // If we switch the renderer over to webgl at some point, we may be able to use blending modes + // to draw overlapping glyphs from the atlas: + // https://github.com/servo/webrender/issues/464#issuecomment-255632875 + // https://webglfundamentals.org/webgl/lessons/webgl-text-texture.html + return glyph.code < 256; + } + + private _toCoordinates(index: number): [number, number] { + return [ + (index % this._width) * this._config.scaledCharWidth, + Math.floor(index / this._width) * this._config.scaledCharHeight + ]; + } + + private _drawFromCache( + ctx: CanvasRenderingContext2D, + cacheValue: IGlyphCacheValue, + x: number, + y: number + ): void { + // We don't actually need to do anything if this is whitespace. + if (cacheValue.isEmpty) { + return; + } + const [cacheX, cacheY] = this._toCoordinates(cacheValue.index); + ctx.drawImage( + this._cacheCanvas, + cacheX, + cacheY, + this._config.scaledCharWidth, + this._config.scaledCharHeight, + x, + y, + this._config.scaledCharWidth, + this._config.scaledCharHeight + ); + } + + private _getColorFromAnsiIndex(idx: number): IColor { + if (idx < this._config.colors.ansi.length) { + return this._config.colors.ansi[idx]; + } + return DEFAULT_ANSI_COLORS[idx]; + } + + private _getBackgroundColor(glyph: IGlyphIdentifier): IColor { + if (this._config.allowTransparency) { + // The background color might have some transparency, so we need to render it as fully + // transparent in the atlas. Otherwise we'd end up drawing the transparent background twice + // around the anti-aliased edges of the glyph, and it would look too dark. + return TRANSPARENT_COLOR; + } else if (glyph.bg === INVERTED_DEFAULT_COLOR) { + return this._config.colors.foreground; + } else if (glyph.bg < 256) { + return this._getColorFromAnsiIndex(glyph.bg); + } + return this._config.colors.background; + } + + private _getForegroundColor(glyph: IGlyphIdentifier): IColor { + if (glyph.fg === INVERTED_DEFAULT_COLOR) { + return this._config.colors.background; + } else if (glyph.fg < 256) { + // 256 color support + return this._getColorFromAnsiIndex(glyph.fg); + } + return this._config.colors.foreground; + } + + // TODO: We do this (or something similar) in multiple places. We should split this off + // into a shared function. + private _drawToCache(glyph: IGlyphIdentifier, index: number): IGlyphCacheValue { + this._drawToCacheCount++; + + this._tmpCtx.save(); + + // draw the background + const backgroundColor = this._getBackgroundColor(glyph); + // Use a 'copy' composite operation to clear any existing glyph out of _tmpCtxWithAlpha, regardless of + // transparency in backgroundColor + this._tmpCtx.globalCompositeOperation = 'copy'; + this._tmpCtx.fillStyle = backgroundColor.css; + this._tmpCtx.fillRect(0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight); + this._tmpCtx.globalCompositeOperation = 'source-over'; + + // draw the foreground/glyph + const fontWeight = glyph.bold ? this._config.fontWeightBold : this._config.fontWeight; + const fontStyle = glyph.italic ? 'italic' : ''; + this._tmpCtx.font = + `${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`; + this._tmpCtx.textBaseline = 'top'; + + this._tmpCtx.fillStyle = this._getForegroundColor(glyph).css; + + // Apply alpha to dim the character + if (glyph.dim) { + this._tmpCtx.globalAlpha = DIM_OPACITY; + } + // Draw the character + this._tmpCtx.fillText(glyph.char, 0, 0); + this._tmpCtx.restore(); + + // clear the background from the character to avoid issues with drawing over the previous + // character if it extends past it's bounds + const imageData = this._tmpCtx.getImageData( + 0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight + ); + let isEmpty = false; + if (!this._config.allowTransparency) { + isEmpty = clearColor(imageData, backgroundColor); + } + + // copy the data from imageData to _cacheCanvas + const [x, y] = this._toCoordinates(index); + // putImageData doesn't do any blending, so it will overwrite any existing cache entry for us + this._cacheCtx.putImageData(imageData, x, y); + + return { + index, + isEmpty + }; + } +} diff --git a/src/renderer/atlas/LRUMap.test.ts b/src/renderer/atlas/LRUMap.test.ts new file mode 100644 index 0000000000..ba01e410d1 --- /dev/null +++ b/src/renderer/atlas/LRUMap.test.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import LRUMap from './LRUMap'; + +describe('LRUMap', () => { + it('can be used to store and retrieve values', () => { + const map = new LRUMap(10); + map.set('keya', 'valuea'); + map.set('keyb', 'valueb'); + map.set('keyc', 'valuec'); + assert.strictEqual(map.get('keya'), 'valuea'); + assert.strictEqual(map.get('keyb'), 'valueb'); + assert.strictEqual(map.get('keyc'), 'valuec'); + }); + + it('maintains a size from insertions', () => { + const map = new LRUMap(10); + assert.strictEqual(map.size, 0); + map.set('a', 'value'); + assert.strictEqual(map.size, 1); + map.set('b', 'value'); + assert.strictEqual(map.size, 2); + }); + + it('deletes the oldest entry when the capacity is exceeded', () => { + const map = new LRUMap(4); + map.set('a', 'value'); + map.set('b', 'value'); + map.set('c', 'value'); + map.set('d', 'value'); + map.set('e', 'value'); + assert.isNull(map.get('a')); + assert.isNotNull(map.get('b')); + assert.isNotNull(map.get('c')); + assert.isNotNull(map.get('d')); + assert.isNotNull(map.get('e')); + assert.strictEqual(map.size, 4); + }); + + it('prevents a recently accessed entry from getting deleted', () => { + const map = new LRUMap(2); + map.set('a', 'value'); + map.set('b', 'value'); + map.get('a'); + // a would normally get deleted here, except that we called get() + map.set('c', 'value'); + assert.isNotNull(map.get('a')); + // b got deleted instead of a + assert.isNull(map.get('b')); + assert.isNotNull(map.get('c')); + }); + + it('supports mutation', () => { + const map = new LRUMap(10); + map.set('keya', 'oldvalue'); + map.set('keya', 'newvalue'); + // mutation doesn't change the size + assert.strictEqual(map.size, 1); + assert.strictEqual(map.get('keya'), 'newvalue'); + }); +}); diff --git a/src/renderer/atlas/LRUMap.ts b/src/renderer/atlas/LRUMap.ts new file mode 100644 index 0000000000..4a03f2aafb --- /dev/null +++ b/src/renderer/atlas/LRUMap.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +interface ILinkedListNode { + prev: ILinkedListNode; + next: ILinkedListNode; + key: string; + value: T; +} + +export default class LRUMap { + private _map = {}; + private _head: ILinkedListNode = null; + private _tail: ILinkedListNode = null; + private _nodePool: ILinkedListNode[] = []; + public size: number = 0; + + constructor(public capacity: number) { } + + private _unlinkNode(node: ILinkedListNode): void { + const prev = node.prev; + const next = node.next; + if (node === this._head) { + this._head = next; + } + if (node === this._tail) { + this._tail = prev; + } + if (prev !== null) { + prev.next = next; + } + if (next !== null) { + next.prev = prev; + } + } + + private _appendNode(node: ILinkedListNode): void { + const tail = this._tail; + if (tail !== null) { + tail.next = node; + } + node.prev = tail; + node.next = null; + this._tail = node; + if (this._head === null) { + this._head = node; + } + } + + /** + * Preallocate a bunch of linked-list nodes. Allocating these nodes ahead of time means that + * they're more likely to live next to each other in memory, which seems to improve performance. + * + * Each empty object only consumes about 60 bytes of memory, so this is pretty cheap, even for + * large maps. + */ + public prealloc(count: number): void { + const nodePool = this._nodePool; + for (let i = 0; i < count; i++) { + nodePool.push({ + prev: null, + next: null, + key: null, + value: null + }); + } + } + + public get(key: string): T | null { + // This is unsafe: We're assuming our keyspace doesn't overlap with Object.prototype. However, + // it's faster than calling hasOwnProperty, and in our case, it would never overlap. + const node = this._map[key]; + if (node !== undefined) { + this._unlinkNode(node); + this._appendNode(node); + return node.value; + } + return null; + } + + public peek(): T | null { + const head = this._head; + return head === null ? null : head.value; + } + + public set(key: string, value: T): void { + // This is unsafe: See note above. + let node = this._map[key]; + if (node !== undefined) { + // already exists, we just need to mutate it and move it to the end of the list + node = this._map[key]; + this._unlinkNode(node); + node.value = value; + } else if (this.size >= this.capacity) { + // we're out of space: recycle the head node, move it to the tail + node = this._head; + this._unlinkNode(node); + delete this._map[node.key]; + node.key = key; + node.value = value; + this._map[key] = node; + } else { + // make a new element + const nodePool = this._nodePool; + if (nodePool.length > 0) { + // use a preallocated node if we can + node = nodePool.pop(); + node.key = key; + node.value = value; + } else { + node = { + prev: null, + next: null, + key, + value + }; + } + this._map[key] = node; + this.size++; + } + this._appendNode(node); + } +} diff --git a/src/renderer/atlas/NoneCharAtlas.ts b/src/renderer/atlas/NoneCharAtlas.ts new file mode 100644 index 0000000000..1cbc9eea83 --- /dev/null +++ b/src/renderer/atlas/NoneCharAtlas.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + * + * A dummy CharAtlas implementation that always fails to draw characters. + */ + +import { IGlyphIdentifier } from './Types'; +import { ICharAtlasConfig } from '../../shared/atlas/Types'; +import BaseCharAtlas from './BaseCharAtlas'; + +export default class NoneCharAtlas extends BaseCharAtlas { + constructor(document: Document, config: ICharAtlasConfig) { + super(); + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + return false; + } +} diff --git a/src/renderer/atlas/StaticCharAtlas.ts b/src/renderer/atlas/StaticCharAtlas.ts new file mode 100644 index 0000000000..b19074b8d6 --- /dev/null +++ b/src/renderer/atlas/StaticCharAtlas.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { DIM_OPACITY, IGlyphIdentifier } from './Types'; +import { CHAR_ATLAS_CELL_SPACING, ICharAtlasConfig } from '../../shared/atlas/Types'; +import { generateStaticCharAtlasTexture } from '../../shared/atlas/CharAtlasGenerator'; +import BaseCharAtlas from './BaseCharAtlas'; + +export default class StaticCharAtlas extends BaseCharAtlas { + private _texture: HTMLCanvasElement | ImageBitmap; + + constructor(private _document: Document, private _config: ICharAtlasConfig) { + super(); + } + + private _canvasFactory = (width: number, height: number) => { + const canvas = this._document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; + } + + public _doWarmUp(): void { + const result = generateStaticCharAtlasTexture(window, this._canvasFactory, this._config); + if (result instanceof HTMLCanvasElement) { + this._texture = result; + } else { + result.then(texture => { + this._texture = texture; + }); + } + } + + private _isCached(glyph: IGlyphIdentifier, colorIndex: number): boolean { + const isAscii = glyph.code < 256; + // A color is basic if it is one of the 4 bit ANSI colors. + const isBasicColor = glyph.fg < 16; + const isDefaultColor = glyph.fg >= 256; + const isDefaultBackground = glyph.bg >= 256; + return isAscii && (isBasicColor || isDefaultColor) && isDefaultBackground && !glyph.italic; + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + // we're not warmed up yet + if (this._texture == null) { + return false; + } + + let colorIndex = 0; + if (glyph.fg < 256) { + colorIndex = 2 + glyph.fg + (glyph.bold ? 16 : 0); + } else { + // If default color and bold + if (glyph.bold) { + colorIndex = 1; + } + } + if (!this._isCached(glyph, colorIndex)) { + return false; + } + // ImageBitmap's draw about twice as fast as from a canvas + const charAtlasCellWidth = this._config.scaledCharWidth + CHAR_ATLAS_CELL_SPACING; + const charAtlasCellHeight = this._config.scaledCharHeight + CHAR_ATLAS_CELL_SPACING; + + // Apply alpha to dim the character + if (glyph.dim) { + ctx.globalAlpha = DIM_OPACITY; + } + + ctx.drawImage( + this._texture, + glyph.code * charAtlasCellWidth, + colorIndex * charAtlasCellHeight, + charAtlasCellWidth, + this._config.scaledCharHeight, + x, + y, + charAtlasCellWidth, + this._config.scaledCharHeight + ); + + return true; + } +} diff --git a/src/renderer/atlas/Types.ts b/src/renderer/atlas/Types.ts index 34f01d39cd..46e4c9aa80 100644 --- a/src/renderer/atlas/Types.ts +++ b/src/renderer/atlas/Types.ts @@ -5,3 +5,13 @@ export const INVERTED_DEFAULT_COLOR = -1; export const DIM_OPACITY = 0.5; + +export interface IGlyphIdentifier { + char: string; + code: number; + bg: number; + fg: number; + bold: boolean; + dim: boolean; + italic: boolean; +} diff --git a/src/shared/atlas/CharAtlasGenerator.ts b/src/shared/atlas/CharAtlasGenerator.ts index ffb736ddec..276da78d33 100644 --- a/src/shared/atlas/CharAtlasGenerator.ts +++ b/src/shared/atlas/CharAtlasGenerator.ts @@ -5,6 +5,7 @@ import { FontWeight } from 'xterm'; import { CHAR_ATLAS_CELL_SPACING, ICharAtlasConfig } from './Types'; +import { IColor } from '../Types'; import { isFirefox } from '../utils/Browser'; declare const Promise: any; @@ -20,9 +21,9 @@ export interface IOffscreenCanvas { * Generates a char atlas. * @param context The window or worker context. * @param canvasFactory A function to generate a canvas with a width or height. - * @param request The config for the new char atlas. + * @param config The config for the new char atlas. */ -export function generateCharAtlas(context: Window, canvasFactory: (width: number, height: number) => HTMLCanvasElement | IOffscreenCanvas, config: ICharAtlasConfig): HTMLCanvasElement | Promise { +export function generateStaticCharAtlasTexture(context: Window, canvasFactory: (width: number, height: number) => HTMLCanvasElement | IOffscreenCanvas, config: ICharAtlasConfig): HTMLCanvasElement | Promise { const cellWidth = config.scaledCharWidth + CHAR_ATLAS_CELL_SPACING; const cellHeight = config.scaledCharHeight + CHAR_ATLAS_CELL_SPACING; const canvas = canvasFactory( @@ -111,25 +112,30 @@ export function generateCharAtlas(context: Window, canvasFactory: (width: number const charAtlasImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); // Remove the background color from the image so characters may overlap - const r = config.colors.background.rgba >>> 24; - const g = config.colors.background.rgba >>> 16 & 0xFF; - const b = config.colors.background.rgba >>> 8 & 0xFF; - clearColor(charAtlasImageData, r, g, b); + clearColor(charAtlasImageData, config.colors.background); return context.createImageBitmap(charAtlasImageData); } /** * Makes a partiicular rgb color in an ImageData completely transparent. + * @returns True if the result is "empty", meaning all pixels are fully transparent. */ -function clearColor(imageData: ImageData, r: number, g: number, b: number): void { +export function clearColor(imageData: ImageData, color: IColor): boolean { + let isEmpty = true; + const r = color.rgba >>> 24; + const g = color.rgba >>> 16 & 0xFF; + const b = color.rgba >>> 8 & 0xFF; for (let offset = 0; offset < imageData.data.length; offset += 4) { if (imageData.data[offset] === r && imageData.data[offset + 1] === g && imageData.data[offset + 2] === b) { imageData.data[offset + 3] = 0; + } else { + isEmpty = false; } } + return isEmpty; } function getFont(fontWeight: FontWeight, config: ICharAtlasConfig): string { diff --git a/src/shared/atlas/Types.ts b/src/shared/atlas/Types.ts index 4a66d55428..25eaa716e4 100644 --- a/src/shared/atlas/Types.ts +++ b/src/shared/atlas/Types.ts @@ -9,6 +9,7 @@ import { IColorSet } from '../Types'; export const CHAR_ATLAS_CELL_SPACING = 1; export interface ICharAtlasConfig { + type: 'none' | 'static' | 'dynamic'; devicePixelRatio: number; fontSize: number; fontFamily: string; diff --git a/tsconfig.json b/tsconfig.json index ffd9ab6862..e56930e639 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,12 @@ "compilerOptions": { "module": "commonjs", "target": "es5", + "lib": [ + "DOM", + "ES5", + "ScriptHost", + "ES2015.Promise" + ], "rootDir": "src", "outDir": "lib", "sourceMap": true, diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 3107589e70..a8724922e4 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -66,6 +66,24 @@ declare module 'xterm' { */ enableBold?: boolean; + /** + * What character atlas implementation to use. The character atlas caches drawn characters, + * speeding up rendering significantly. However, it can introduce some minor rendering + * artifacts. + * + * - 'none': Don't use an atlas. + * - 'static': Generate an atlas when the terminal starts or is reconfigured. This atlas will + * only contain ASCII characters in 16 colors. + * - 'dynamic': Generate an atlas using a LRU cache as characters are requested. Limited to + * ASCII characters (for now), but supports 256 colors. For characters covered by the static + * cache, it's slightly slower in comparison, since there's more overhead involved in + * managing the cache. + * + * Currently defaults to 'static'. This option may be removed in the future. If it is, passed + * parameters will be ignored. + */ + experimentalCharAtlas?: 'none' | 'static' | 'dynamic'; + /** * The font size used to render text. */