diff --git a/demo/index.html b/demo/index.html
index e18614a4..e4db4118 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -58,6 +58,7 @@
experimentalCharAtlas
diff --git a/src/renderer/atlas/CharAtlasCache.ts b/src/renderer/atlas/CharAtlasCache.ts
index 14037acf..c44f718a 100644
--- a/src/renderer/atlas/CharAtlasCache.ts
+++ b/src/renderer/atlas/CharAtlasCache.ts
@@ -8,12 +8,14 @@ import { IColorSet } from '../Types';
import { ICharAtlasConfig } from '../../shared/atlas/Types';
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 {
diff --git a/src/renderer/atlas/CharAtlasUtils.ts b/src/renderer/atlas/CharAtlasUtils.ts
index c0c54783..ded04085 100644
--- a/src/renderer/atlas/CharAtlasUtils.ts
+++ b/src/renderer/atlas/CharAtlasUtils.ts
@@ -8,13 +8,16 @@ 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,
- ansi: colors.ansi.slice(0, 16)
+ // 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,
};
return {
type: terminal.options.experimentalCharAtlas,
diff --git a/src/renderer/atlas/DynamicCharAtlas.ts b/src/renderer/atlas/DynamicCharAtlas.ts
new file mode 100644
index 00000000..8992fa8a
--- /dev/null
+++ b/src/renderer/atlas/DynamicCharAtlas.ts
@@ -0,0 +1,195 @@
+/**
+ * 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 BaseCharAtlas from './BaseCharAtlas';
+import { clearColor } from '../../shared/atlas/CharAtlasGenerator';
+
+// 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;
+
+type GlyphCacheKey = string;
+
+/**
+ * Removes and returns the oldest element in a map.
+ */
+function mapShift(map: Map): [K, V] {
+ // Map guarantees insertion-order iteration.
+ const entry = map.entries().next().value;
+ if (entry === undefined) {
+ return undefined;
+ }
+ map.delete(entry[0]);
+ return entry;
+}
+
+function getGlyphCacheKey(glyph: IGlyphIdentifier): GlyphCacheKey {
+ return `${glyph.bg}_${glyph.fg}_${glyph.bold ? 0 : 1}${glyph.dim ? 0 : 1}${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: Map = new Map();
+
+ // The texture that the atlas is drawn to
+ private _cacheCanvas: HTMLCanvasElement;
+ private _cacheCtx: CanvasRenderingContext2D;
+
+ // A temporary canvas that glyphs are drawn to before being transfered over to the atlas.
+ private _tmpCanvas: HTMLCanvasElement;
+ private _tmpCtx: CanvasRenderingContext2D;
+
+ // The number of characters stored in the atlas by width/height
+ private _capacity: number;
+ private _width: number;
+ private _height: number;
+
+ 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.
+ this._cacheCtx = this._cacheCanvas.getContext('2d', {alpha: true});
+
+ this._tmpCanvas = document.createElement('canvas');
+ this._tmpCanvas.width = this._config.scaledCharWidth;
+ this._tmpCanvas.height = this._config.scaledCharHeight;
+ this._tmpCtx = this._tmpCanvas.getContext('2d', {alpha: true});
+
+ this._width = Math.floor(TEXTURE_WIDTH / this._config.scaledCharWidth);
+ this._height = Math.floor(TEXTURE_HEIGHT / this._config.scaledCharHeight);
+ this._capacity = this._width * this._height;
+
+ // This is useful for debugging
+ // document.body.appendChild(this._cacheCanvas);
+ }
+
+ public draw(
+ ctx: CanvasRenderingContext2D,
+ glyph: IGlyphIdentifier,
+ x: number,
+ y: number,
+ ): boolean {
+ const glyphKey = getGlyphCacheKey(glyph);
+ const index = this._cacheMap.get(glyphKey);
+ if (index != null) {
+ // move to end of insertion order, so this can behave like an LRU cache
+ this._cacheMap.delete(glyphKey);
+ this._cacheMap.set(glyphKey, index);
+ this._drawFromCache(ctx, index, x, y);
+ return true;
+ } else if (this._canCache(glyph)) {
+ let index;
+ if (this._cacheMap.size < this._capacity) {
+ index = this._cacheMap.size;
+ } else {
+ index = mapShift(this._cacheMap)[1];
+ }
+ this._drawToCache(glyph, index);
+ this._cacheMap.set(glyphKey, index);
+ this._drawFromCache(ctx, index, x, y);
+ return true;
+ } else {
+ 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.char.charCodeAt(0) < 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,
+ index: number,
+ x: number,
+ y: number
+ ): void {
+ const [cacheX, cacheY] = this._toCoordinates(index);
+ ctx.drawImage(
+ this._cacheCanvas,
+ cacheX,
+ cacheY,
+ this._config.scaledCharWidth,
+ this._config.scaledCharHeight,
+ x,
+ y,
+ this._config.scaledCharWidth,
+ this._config.scaledCharHeight,
+ );
+ }
+
+ // 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): void {
+ this._tmpCtx.save();
+ // no need to clear _tmpCtx, since we're going to draw a fully opaque background
+
+ // draw the background
+ let backgroundColor = this._config.colors.background;
+ if (glyph.bg === INVERTED_DEFAULT_COLOR) {
+ backgroundColor = this._config.colors.foreground;
+ } else if (glyph.bg < 256) {
+ backgroundColor = this._config.colors.ansi[glyph.bg];
+ }
+ this._tmpCtx.fillStyle = backgroundColor.css;
+ this._tmpCtx.fillRect(0, 0, this._config.scaledCharWidth, this._config.scaledCharHeight);
+
+ // draw the foreground/glyph
+ this._tmpCtx.font =
+ `${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`;
+ if (glyph.bold) {
+ this._tmpCtx.font = `bold ${this._tmpCtx.font}`;
+ }
+ this._tmpCtx.textBaseline = 'top';
+
+ if (glyph.fg === INVERTED_DEFAULT_COLOR) {
+ this._tmpCtx.fillStyle = this._config.colors.background.css;
+ } else if (glyph.fg < 256) {
+ // 256 color support
+ this._tmpCtx.fillStyle = this._config.colors.ansi[glyph.fg].css;
+ } else {
+ this._tmpCtx.fillStyle = this._config.colors.foreground.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,
+ );
+ clearColor(imageData, backgroundColor);
+
+ // copy the data from _tmpCanvas 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);
+ }
+}
diff --git a/src/renderer/atlas/StaticCharAtlas.ts b/src/renderer/atlas/StaticCharAtlas.ts
index de808716..746369fc 100644
--- a/src/renderer/atlas/StaticCharAtlas.ts
+++ b/src/renderer/atlas/StaticCharAtlas.ts
@@ -6,7 +6,7 @@
import { DIM_OPACITY, IGlyphIdentifier } from './Types';
import { ICharAtlasConfig } from '../../shared/atlas/Types';
import { CHAR_ATLAS_CELL_SPACING } from '../../shared/atlas/Types';
-import { generateCharAtlas } from '../../shared/atlas/CharAtlasGenerator';
+import { generateStaticCharAtlasTexture } from '../../shared/atlas/CharAtlasGenerator';
import BaseCharAtlas from './BaseCharAtlas';
export default class StaticCharAtlas extends BaseCharAtlas {
@@ -24,7 +24,7 @@ export default class StaticCharAtlas extends BaseCharAtlas {
}
public async _doWarmUp(): Promise {
- const result = generateCharAtlas(window, this._canvasFactory, this._config);
+ const result = generateStaticCharAtlasTexture(window, this._canvasFactory, this._config);
if (result instanceof Promise) {
this._texture = await result;
} else {
diff --git a/src/shared/atlas/CharAtlasGenerator.ts b/src/shared/atlas/CharAtlasGenerator.ts
index fc83c7ce..874020c3 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(
@@ -100,10 +101,7 @@ 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);
}
@@ -111,7 +109,10 @@ export function generateCharAtlas(context: Window, canvasFactory: (width: number
/**
* Makes a partiicular rgb color in an ImageData completely transparent.
*/
-function clearColor(imageData: ImageData, r: number, g: number, b: number): void {
+export function clearColor(imageData: ImageData, color: IColor): void {
+ 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 &&
diff --git a/src/shared/atlas/Types.ts b/src/shared/atlas/Types.ts
index 2bec4194..25eaa716 100644
--- a/src/shared/atlas/Types.ts
+++ b/src/shared/atlas/Types.ts
@@ -9,7 +9,7 @@ import { IColorSet } from '../Types';
export const CHAR_ATLAS_CELL_SPACING = 1;
export interface ICharAtlasConfig {
- type: 'none' | 'static';
+ type: 'none' | 'static' | 'dynamic';
devicePixelRatio: number;
fontSize: number;
fontFamily: string;
diff --git a/tsconfig.json b/tsconfig.json
index 6d329658..7525c3de 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,14 @@
"compilerOptions": {
"module": "commonjs",
"target": "es5",
- "lib": ["DOM", "ES5", "ScriptHost", "ES2015.Promise"],
+ "lib": [
+ "DOM",
+ "ES5",
+ "ScriptHost",
+ "ES2015.Promise",
+ "ES2015.Collection",
+ "ES2015.Iterable"
+ ],
"rootDir": "src",
"outDir": "lib",
"sourceMap": true,
diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts
index a208b7ed..f7898d6e 100644
--- a/typings/xterm.d.ts
+++ b/typings/xterm.d.ts
@@ -69,11 +69,15 @@ declare module 'xterm' {
* - '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';
+ experimentalCharAtlas?: 'none' | 'static' | 'dynamic';
/**
* The font size used to render text.