mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
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:
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ScriptHost",
|
||||
"ES2015.Promise"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"outDir": "lib",
|
||||
"sourceMap": true,
|
||||
|
||||
Vendored
+18
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user