mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
Add a basic dynamic character atlas implementation
This adds a very minimal implementation of DynamicCharAtlas using a LRU cache. I've got some optimizations that I'll add on top of this, but this proves the concept.
This commit is contained in:
@@ -58,6 +58,7 @@
|
||||
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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<K, V>(map: Map<K, V>): [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<GlyphCacheKey, number> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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(
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
Vendored
+5
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user