Merge pull request #1327 from bgw/dynamic-char-atlas

Modularize the character atlas system, add a LRU-cache based dynamic character atlas
This commit is contained in:
Benjamin Woodruff
2018-05-17 22:59:03 -07:00
committed by GitHub
19 changed files with 729 additions and 75 deletions
+13
View File
@@ -26,6 +26,9 @@
<p>
<label><input type="checkbox" id="option-mac-option-is-meta"> macOptionIsMeta</label>
</p>
<p>
<label><input type="checkbox" id="option-transparency"> transparency</label>
</p>
<p>
<label>
cursorStyle
@@ -53,6 +56,16 @@
<p>
<label>tabStopWidth <input type="number" id="option-tabstopwidth" value="8" /></label>
</p>
<p>
<label>
experimentalCharAtlas
<select id="option-experimental-char-atlas">
<option value="static" selected>static</option>
<option value="dynamic">dynamic</option>
<option value="none">none</option>
</select>
</label>
</p>
<div>
<h3>Size</h3>
<div>
+13 -3
View File
@@ -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);
});
+3 -1
View File
@@ -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':
+15 -48
View File
@@ -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);
}
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
);
// 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);
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);
}
/**
+1
View File
@@ -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));
}
+2
View File
@@ -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);
+53
View File
@@ -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;
}
@@ -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<ImageBitmap>;
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<ImageBitmap> {
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;
}
/**
+6 -1
View File
@@ -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 &&
+242
View File
@@ -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<IGlyphCacheValue>;
// 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
};
}
}
+65
View File
@@ -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');
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
interface ILinkedListNode<T> {
prev: ILinkedListNode<T>;
next: ILinkedListNode<T>;
key: string;
value: T;
}
export default class LRUMap<T> {
private _map = {};
private _head: ILinkedListNode<T> = null;
private _tail: ILinkedListNode<T> = null;
private _nodePool: ILinkedListNode<T>[] = [];
public size: number = 0;
constructor(public capacity: number) { }
private _unlinkNode(node: ILinkedListNode<T>): 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<T>): 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);
}
}
+25
View File
@@ -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;
}
}
+91
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
+13 -7
View File
@@ -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<ImageBitmap> {
export function generateStaticCharAtlasTexture(context: Window, canvasFactory: (width: number, height: number) => HTMLCanvasElement | IOffscreenCanvas, config: ICharAtlasConfig): HTMLCanvasElement | Promise<ImageBitmap> {
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 {
+1
View File
@@ -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;
+6
View File
@@ -2,6 +2,12 @@
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"lib": [
"DOM",
"ES5",
"ScriptHost",
"ES2015.Promise"
],
"rootDir": "src",
"outDir": "lib",
"sourceMap": true,
+18
View File
@@ -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.
*/