/** * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ import { DIM_OPACITY, INVERTED_DEFAULT_COLOR, TEXT_BASELINE } from 'browser/renderer/atlas/Constants'; import { IGlyphIdentifier, ICharAtlasConfig } from 'browser/renderer/atlas/Types'; import { BaseCharAtlas } from 'browser/renderer/atlas/BaseCharAtlas'; import { DEFAULT_ANSI_COLORS } from 'browser/ColorManager'; import { LRUMap } from 'browser/renderer/atlas/LRUMap'; import { isFirefox, isSafari } from 'common/Platform'; import { IColor } from 'browser/Types'; import { throwIfFalsy } from 'browser/renderer/RendererUtils'; import { color } from 'browser/Color'; // 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; /** * The number of milliseconds to wait before generating the ImageBitmap, this is to debounce/batch * the operation as window.createImageBitmap is asynchronous. */ const GLYPH_BITMAP_COMMIT_DELAY = 100; interface IGlyphCacheValue { index: number; isEmpty: boolean; inBitmap: boolean; } export function getGlyphCacheKey(glyph: IGlyphIdentifier): number { // Note that this only returns a valid key when code < 256 // Layout: // 0b00000000000000000000000000000001: italic (1) // 0b00000000000000000000000000000010: dim (1) // 0b00000000000000000000000000000100: bold (1) // 0b00000000000000000000111111111000: fg (9) // 0b00000000000111111111000000000000: bg (9) // 0b00011111111000000000000000000000: code (8) // 0b11100000000000000000000000000000: unused (3) return glyph.code << 21 | glyph.bg << 12 | glyph.fg << 3 | (glyph.bold ? 0 : 4) + (glyph.dim ? 0 : 2) + (glyph.italic ? 0 : 1); } export 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; // An array of glyph keys that are waiting on the bitmap to be generated. private _glyphsWaitingOnBitmap: IGlyphCacheValue[] = []; // The timeout that is used to batch bitmap generation so it's not requested for every new glyph. private _bitmapCommitTimeout: number | null = null; // The bitmap to draw from, this is much faster on other browsers than others. private _bitmap: ImageBitmap | null = null; 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 = throwIfFalsy(this._cacheCanvas.getContext('2d', { alpha: true })); const tmpCanvas = document.createElement('canvas'); tmpCanvas.width = this._config.scaledCharWidth; tmpCanvas.height = this._config.scaledCharHeight; this._tmpCtx = throwIfFalsy(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 dispose(): void { if (this._bitmapCommitTimeout !== null) { window.clearTimeout(this._bitmapCommitTimeout); this._bitmapCommitTimeout = null; } } public beginFrame(): void { this._drawToCacheCount = 0; } public clear(): void { if (this._cacheMap.size > 0) { const capacity = this._width * this._height; this._cacheMap = new LRUMap(capacity); this._cacheMap.prealloc(capacity); } this._cacheCtx.clearRect(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT); this._tmpCtx.clearRect(0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight); } public draw( ctx: CanvasRenderingContext2D, glyph: IGlyphIdentifier, x: number, y: number ): boolean { // Space is always an empty cell, special case this as it's so common if (glyph.code === 32) { return true; } // Exit early for uncachable glyphs if (!this._canCache(glyph)) { return false; } const glyphKey = getGlyphCacheKey(glyph); const cacheValue = this._cacheMap.get(glyphKey); if (cacheValue !== null && cacheValue !== undefined) { this._drawFromCache(ctx, cacheValue, x, y); return true; } if (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 _toCoordinateX(index: number): number { return (index % this._width) * this._config.scaledCharWidth; } private _toCoordinateY(index: number): number { return 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 = this._toCoordinateX(cacheValue.index); const cacheY = this._toCoordinateY(cacheValue.index); ctx.drawImage( cacheValue.inBitmap ? this._bitmap! : 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; } if (glyph.bg === INVERTED_DEFAULT_COLOR) { return this._config.colors.foreground; } 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 color.opaque(this._config.colors.background); } 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 = TEXT_BASELINE; 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.chars, 0, this._config.scaledCharHeight); // clear the background from the character to avoid issues with drawing over the previous // character if it extends past it's bounds let imageData = this._tmpCtx.getImageData( 0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight ); let isEmpty = false; if (!this._config.allowTransparency) { isEmpty = clearColor(imageData, backgroundColor); } // If this charcater is underscore and empty, shift it up until it is visible, try for a maximum // of 5 pixels. if (isEmpty && glyph.chars === '_' && !this._config.allowTransparency) { for (let offset = 1; offset <= 5; offset++) { // Draw the character this._tmpCtx.fillText(glyph.chars, 0, this._config.scaledCharHeight - offset); // clear the background from the character to avoid issues with drawing over the previous // character if it extends past it's bounds imageData = this._tmpCtx.getImageData( 0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight ); isEmpty = clearColor(imageData, backgroundColor); if (!isEmpty) { break; } } } this._tmpCtx.restore(); // copy the data from imageData to _cacheCanvas const x = this._toCoordinateX(index); const y = this._toCoordinateY(index); // putImageData doesn't do any blending, so it will overwrite any existing cache entry for us this._cacheCtx.putImageData(imageData, x, y); // Add the glyph and queue it to the bitmap (if the browser supports it) const cacheValue = { index, isEmpty, inBitmap: false }; this._addGlyphToBitmap(cacheValue); return cacheValue; } private _addGlyphToBitmap(cacheValue: IGlyphCacheValue): void { // Support is patchy for createImageBitmap at the moment, pass a canvas back // if support is lacking as drawImage works there too. Firefox is also // included here as ImageBitmap appears both buggy and has horrible // performance (tested on v55). if (!('createImageBitmap' in window) || isFirefox || isSafari) { return; } // Add the glyph to the queue this._glyphsWaitingOnBitmap.push(cacheValue); // Check if bitmap generation timeout already exists if (this._bitmapCommitTimeout !== null) { return; } this._bitmapCommitTimeout = window.setTimeout(() => this._generateBitmap(), GLYPH_BITMAP_COMMIT_DELAY); } private _generateBitmap(): void { const glyphsMovingToBitmap = this._glyphsWaitingOnBitmap; this._glyphsWaitingOnBitmap = []; window.createImageBitmap(this._cacheCanvas).then(bitmap => { // Set bitmap this._bitmap = bitmap; // Mark all new glyphs as in bitmap, excluding glyphs that came in after // the bitmap was requested for (let i = 0; i < glyphsMovingToBitmap.length; i++) { const value = glyphsMovingToBitmap[i]; // It doesn't matter if the value was already evicted, it will be // released from memory after this block if so. value.inBitmap = true; } }); this._bitmapCommitTimeout = null; } } // This is used for debugging the renderer, just swap out `new DynamicCharAtlas` with // `new NoneCharAtlas`. export class NoneCharAtlas extends BaseCharAtlas { constructor(document: Document, config: ICharAtlasConfig) { super(); } public draw( ctx: CanvasRenderingContext2D, glyph: IGlyphIdentifier, x: number, y: number ): boolean { return false; } } /** * 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, 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; }