diff --git a/addons/xterm-addon-webgl/.gitignore b/addons/xterm-addon-webgl/.gitignore new file mode 100644 index 0000000000..a9f4ed5456 --- /dev/null +++ b/addons/xterm-addon-webgl/.gitignore @@ -0,0 +1,2 @@ +lib +node_modules \ No newline at end of file diff --git a/addons/xterm-addon-webgl/.npmignore b/addons/xterm-addon-webgl/.npmignore new file mode 100644 index 0000000000..1c79444565 --- /dev/null +++ b/addons/xterm-addon-webgl/.npmignore @@ -0,0 +1,5 @@ +**/*.api.js +**/*.api.ts +tsconfig.json +.yarnrc +webpack.config.js diff --git a/addons/xterm-addon-webgl/LICENSE b/addons/xterm-addon-webgl/LICENSE new file mode 100644 index 0000000000..b9dc26fe39 --- /dev/null +++ b/addons/xterm-addon-webgl/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/addons/xterm-addon-webgl/package.json b/addons/xterm-addon-webgl/package.json new file mode 100644 index 0000000000..e4f3414762 --- /dev/null +++ b/addons/xterm-addon-webgl/package.json @@ -0,0 +1,20 @@ +{ + "name": "xterm-addon-webgl", + "version": "0.1.0", + "author": { + "name": "The xterm.js authors", + "url": "https://xtermjs.org/" + }, + "main": "lib/xterm-addon-webgl.js", + "types": "typings/xterm-addon-webgl.d.ts", + "license": "MIT", + "scripts": { + "build": "../../node_modules/.bin/tsc -p src", + "prepackage": "npm run build", + "package": "../../node_modules/.bin/webpack", + "prepublishOnly": "npm run package" + }, + "peerDependencies": { + "xterm": "^3.14.0" + } +} diff --git a/addons/xterm-addon-webgl/src/CharDataCompat.ts b/addons/xterm-addon-webgl/src/CharDataCompat.ts new file mode 100644 index 0000000000..13613c8844 --- /dev/null +++ b/addons/xterm-addon-webgl/src/CharDataCompat.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { CellData } from 'common/buffer/CellData'; +import { FLAGS } from './Constants'; +import { IBufferLine } from 'common/Types'; + +export function getCompatAttr(bufferLine: IBufferLine, index: number): number { + // TODO: Need to move WebGL over to the new system and remove this block + const cell = new CellData(); + bufferLine.loadCell(index, cell); + const oldBg = cell.getBgColor() === -1 ? 256 : cell.getBgColor(); + const oldFg = cell.getFgColor() === -1 ? 256 : cell.getFgColor(); + const oldAttr = + (cell.isBold() ? FLAGS.BOLD : 0) | + (cell.isUnderline() ? FLAGS.UNDERLINE : 0) | + (cell.isBlink() ? FLAGS.BLINK : 0) | + (cell.isInverse() ? FLAGS.INVERSE : 0) | + (cell.isDim() ? FLAGS.DIM : 0) | + (cell.isItalic() ? FLAGS.ITALIC : 0); + const attrCompat = + oldBg | + (oldFg << 9) | + (oldAttr << 18); + return attrCompat; +} diff --git a/addons/xterm-addon-webgl/src/ColorUtils.ts b/addons/xterm-addon-webgl/src/ColorUtils.ts new file mode 100644 index 0000000000..80372e6503 --- /dev/null +++ b/addons/xterm-addon-webgl/src/ColorUtils.ts @@ -0,0 +1,14 @@ +/** + * @license MIT + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + */ + +import { IColor } from 'browser/Types'; + +export function getLuminance(color: IColor): number { + // Coefficients taken from: https://www.w3.org/TR/AERT/#color-contrast + const r = color.rgba >> 24 & 0xff; + const g = color.rgba >> 16 & 0xff; + const b = color.rgba >> 8 & 0xff; + return (0.299 * r + 0.587 * g + 0.114 * b) / 255; +} diff --git a/addons/xterm-addon-webgl/src/Constants.ts b/addons/xterm-addon-webgl/src/Constants.ts new file mode 100644 index 0000000000..ab18b97ab1 --- /dev/null +++ b/addons/xterm-addon-webgl/src/Constants.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +// TODO: Should be removed after chardata workaround is fixed +export const enum FLAGS { + BOLD = 1, + UNDERLINE = 2, + BLINK = 4, + INVERSE = 8, + INVISIBLE = 16, + DIM = 32, + ITALIC = 64 +} diff --git a/addons/xterm-addon-webgl/src/GlyphRenderer.ts b/addons/xterm-addon-webgl/src/GlyphRenderer.ts new file mode 100644 index 0000000000..dc37534881 --- /dev/null +++ b/addons/xterm-addon-webgl/src/GlyphRenderer.ts @@ -0,0 +1,367 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { createProgram, PROJECTION_MATRIX } from './WebglUtils'; +import { WebglCharAtlas } from './atlas/WebglCharAtlas'; +import { IWebGL2RenderingContext, IWebGLVertexArrayObject, IRenderModel, IRasterizedGlyph } from './Types'; +import { INDICIES_PER_CELL } from './WebglRenderer'; +import { COMBINED_CHAR_BIT_MASK } from './RenderModel'; +import { fill } from 'common/TypedArrayUtils'; +import { slice } from './TypedArray'; +import { NULL_CELL_CODE, WHITESPACE_CELL_CODE } from 'common/buffer/Constants'; +import { getLuminance } from './ColorUtils'; +import { Terminal, IBufferLine } from 'xterm'; +import { IColorSet } from 'browser/Types'; +import { IRenderDimensions } from 'browser/renderer/Types'; + +interface IVertices { + attributes: Float32Array; + /** + * These buffers are the ones used to bind to WebGL, the reason there are + * multiple is to allow double buffering to work as you cannot modify the + * buffer while it's being used by the GPU. Having multiple lets us start + * working on the next frame. + */ + attributesBuffers: Float32Array[]; + selectionAttributes: Float32Array; + count: number; +} + +const enum VertexAttribLocations { + UNIT_QUAD = 0, + CELL_POSITION = 1, + OFFSET = 2, + SIZE = 3, + TEXCOORD = 4, + TEXSIZE = 5 +} + +const vertexShaderSource = `#version 300 es +layout (location = ${VertexAttribLocations.UNIT_QUAD}) in vec2 a_unitquad; +layout (location = ${VertexAttribLocations.CELL_POSITION}) in vec2 a_cellpos; +layout (location = ${VertexAttribLocations.OFFSET}) in vec2 a_offset; +layout (location = ${VertexAttribLocations.SIZE}) in vec2 a_size; +layout (location = ${VertexAttribLocations.TEXCOORD}) in vec2 a_texcoord; +layout (location = ${VertexAttribLocations.TEXSIZE}) in vec2 a_texsize; + +uniform mat4 u_projection; +uniform vec2 u_resolution; + +out vec2 v_texcoord; + +void main() { + vec2 zeroToOne = (a_offset / u_resolution) + a_cellpos + (a_unitquad * a_size); + gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0); + v_texcoord = a_texcoord + a_unitquad * a_texsize; +}`; + +const fragmentShaderSource = `#version 300 es +precision lowp float; + +in vec2 v_texcoord; + +uniform sampler2D u_texture; + +out vec4 outColor; + +void main() { + outColor = texture(u_texture, v_texcoord); +}`; + +const INDICES_PER_CELL = 10; +const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT; +const CELL_POSITION_INDICES = 2; + +export class GlyphRenderer { + private _atlas: WebglCharAtlas; + + private _program: WebGLProgram; + private _vertexArrayObject: IWebGLVertexArrayObject; + private _projectionLocation: WebGLUniformLocation; + private _resolutionLocation: WebGLUniformLocation; + private _textureLocation: WebGLUniformLocation; + private _atlasTexture: WebGLTexture; + private _attributesBuffer: WebGLBuffer; + private _activeBuffer: number = 0; + + private _vertices: IVertices = { + count: 0, + attributes: new Float32Array(0), + attributesBuffers: [ + new Float32Array(0), + new Float32Array(0) + ], + selectionAttributes: new Float32Array(0) + }; + + constructor( + private _terminal: Terminal, + private _colors: IColorSet, + private _gl: IWebGL2RenderingContext, + private _dimensions: IRenderDimensions + ) { + const gl = this._gl; + + this._program = createProgram(gl, vertexShaderSource, fragmentShaderSource); + + // Uniform locations + this._projectionLocation = gl.getUniformLocation(this._program, 'u_projection'); + this._resolutionLocation = gl.getUniformLocation(this._program, 'u_resolution'); + this._textureLocation = gl.getUniformLocation(this._program, 'u_texture'); + + // Create and set the vertex array object + this._vertexArrayObject = gl.createVertexArray(); + gl.bindVertexArray(this._vertexArrayObject); + + // Setup a_unitquad, this defines the 4 vertices of a rectangle + const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); + const unitQuadVerticesBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, unitQuadVerticesBuffer); + gl.bufferData(gl.ARRAY_BUFFER, unitQuadVertices, gl.STATIC_DRAW); + gl.enableVertexAttribArray(VertexAttribLocations.UNIT_QUAD); + gl.vertexAttribPointer(VertexAttribLocations.UNIT_QUAD, 2, this._gl.FLOAT, false, 0, 0); + + // Setup the unit quad element array buffer, this points to indices in + // unitQuadVertuces to allow is to draw 2 triangles from the vertices + const unitQuadElementIndices = new Uint8Array([0, 1, 3, 0, 2, 3]); + const elementIndicesBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, elementIndicesBuffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, unitQuadElementIndices, gl.STATIC_DRAW); + + // Setup attributes + this._attributesBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer); + gl.enableVertexAttribArray(VertexAttribLocations.OFFSET); + gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, BYTES_PER_CELL, 0); + gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1); + gl.enableVertexAttribArray(VertexAttribLocations.SIZE); + gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1); + gl.enableVertexAttribArray(VertexAttribLocations.TEXCOORD); + gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribDivisor(VertexAttribLocations.TEXCOORD, 1); + gl.enableVertexAttribArray(VertexAttribLocations.TEXSIZE); + gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 6 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribDivisor(VertexAttribLocations.TEXSIZE, 1); + gl.enableVertexAttribArray(VertexAttribLocations.CELL_POSITION); + gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, BYTES_PER_CELL, 8 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1); + + // Setup empty texture atlas + this._atlasTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this._atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 255, 255])); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // Allow drawing of transparent texture + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + // Set viewport + this.onResize(); + } + + public beginFrame(): boolean { + return this._atlas.beginFrame(); + } + + public updateCell(x: number, y: number, code: number, attr: number, bg: number, fg: number, chars: string): void { + this._updateCell(this._vertices.attributes, x, y, code, attr, bg, fg, chars); + } + + private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, attr: number, bg: number, fg: number, chars?: string): void { + const terminal = this._terminal; + + const i = (y * terminal.cols + x) * INDICES_PER_CELL; + + // Exit early if this is a null/space character + if (code === NULL_CELL_CODE || code === WHITESPACE_CELL_CODE || code === undefined/* This is used for the right side of wide chars */) { + fill(array, 0, i, i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES); + return; + } + + let rasterizedGlyph: IRasterizedGlyph; + if (chars && chars.length > 1) { + rasterizedGlyph = this._atlas.getRasterizedGlyphCombinedChar(chars, attr, bg, fg); + } else { + rasterizedGlyph = this._atlas.getRasterizedGlyph(code, attr, bg, fg); + } + + // Fill empty if no glyph was found + if (!rasterizedGlyph) { + fill(array, 0, i, i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES); + return; + } + + // a_origin + array[i ] = -rasterizedGlyph.offset.x + this._dimensions.scaledCharLeft; + array[i + 1] = -rasterizedGlyph.offset.y + this._dimensions.scaledCharTop; + // a_size + array[i + 2] = rasterizedGlyph.size.x / this._dimensions.scaledCanvasWidth; + array[i + 3] = rasterizedGlyph.size.y / this._dimensions.scaledCanvasHeight; + // a_texcoord + array[i + 4] = rasterizedGlyph.texturePositionClipSpace.x; + array[i + 5] = rasterizedGlyph.texturePositionClipSpace.y; + // a_texsize + array[i + 6] = rasterizedGlyph.sizeClipSpace.x; + array[i + 7] = rasterizedGlyph.sizeClipSpace.y; + // a_cellpos only changes on resize + } + + public updateSelection(model: IRenderModel, columnSelectMode: boolean): void { + const terminal = this._terminal; + + this._vertices.selectionAttributes = slice(this._vertices.attributes, 0); + + // TODO: Make fg and bg configurable, currently since the buffer doesn't + // support truecolor the char atlas cannot store it. + const lumi = getLuminance(this._colors.background); + const fg = lumi > 0.5 ? 7 : 0; + const bg = lumi > 0.5 ? 0 : 7; + + if (columnSelectMode) { + const startCol = model.selection.startCol; + const width = model.selection.endCol - startCol; + const height = model.selection.viewportCappedEndRow - model.selection.viewportCappedStartRow + 1; + for (let y = model.selection.viewportCappedStartRow; y < model.selection.viewportCappedStartRow + height; y++) { + this._updateSelectionRange(startCol, startCol + width, y, model, bg, fg); + } + } else { + // Draw first row + const startCol = model.selection.viewportStartRow === model.selection.viewportCappedStartRow ? model.selection.startCol : 0; + const startRowEndCol = model.selection.viewportCappedStartRow === model.selection.viewportCappedEndRow ? model.selection.endCol : terminal.cols; + this._updateSelectionRange(startCol, startRowEndCol, model.selection.viewportCappedStartRow, model, bg, fg); + + // Draw middle rows + const middleRowsCount = Math.max(model.selection.viewportCappedEndRow - model.selection.viewportCappedStartRow - 1, 0); + for (let y = model.selection.viewportCappedStartRow + 1; y <= model.selection.viewportCappedStartRow + middleRowsCount; y++) { + this._updateSelectionRange(0, startRowEndCol, y, model, bg, fg); + } + + // Draw final row + if (model.selection.viewportCappedStartRow !== model.selection.viewportCappedEndRow) { + // Only draw viewportEndRow if it's not the same as viewportStartRow + const endCol = model.selection.viewportEndRow === model.selection.viewportCappedEndRow ? model.selection.endCol : terminal.cols; + this._updateSelectionRange(0, endCol, model.selection.viewportCappedEndRow, model, bg, fg); + } + } + } + + private _updateSelectionRange(startCol: number, endCol: number, y: number, model: IRenderModel, bg: number, fg: number): void { + const terminal = this._terminal; + const row = y + terminal.buffer.viewportY; + let line: IBufferLine | undefined; + for (let x = startCol; x < endCol; x++) { + const offset = (y * this._terminal.cols + x) * INDICIES_PER_CELL; + // Because the cache uses attr as a lookup key it needs to contain the selection colors as well + let attr = model.cells[offset + 1]; + attr = attr & ~0x3ffff | bg << 9 | fg; + const code = model.cells[offset]; + if (code & COMBINED_CHAR_BIT_MASK) { + if (!line) { + line = terminal.buffer.getLine(row); + } + const chars = line.getCell(x).char; + this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], attr, bg, fg, chars); + } else { + this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], attr, bg, fg); + } + } + } + + public onResize(): void { + const terminal = this._terminal; + const gl = this._gl; + + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + + // Update vertices + const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL; + if (this._vertices.count !== newCount) { + this._vertices.count = newCount; + this._vertices.attributes = new Float32Array(newCount); + for (let i = 0; i < this._vertices.attributesBuffers.length; i++) { + this._vertices.attributesBuffers[i] = new Float32Array(newCount); + } + + let i = 0; + for (let y = 0; y < terminal.rows; y++) { + for (let x = 0; x < terminal.cols; x++) { + this._vertices.attributes[i + 8] = x / terminal.cols; + this._vertices.attributes[i + 9] = y / terminal.rows; + i += INDICES_PER_CELL; + } + } + } + } + + public setColors(): void { + } + + public render(renderModel: IRenderModel, isSelectionVisible: boolean): void { + if (!this._atlas) { + return; + } + + const gl = this._gl; + + gl.useProgram(this._program); + gl.bindVertexArray(this._vertexArrayObject); + + // Alternate buffers each frame as the active buffer gets locked while it's in use by the GPU + this._activeBuffer = (this._activeBuffer + 1) % 2; + const activeBuffer = this._vertices.attributesBuffers[this._activeBuffer]; + + // Copy data for each cell of each line up to its line length (the last non-whitespace cell) + // from the attributes buffer into activeBuffer, which is the one that gets bound to the GPU. + // The reasons for this are as follows: + // - So the active buffer can be alternated so we don't get blocked on rendering finishing + // - To copy either the normal attributes buffer or the selection attributes buffer when there + // is a selection + // - So we don't send vertices for all the line-ending whitespace to the GPU + let bufferLength = 0; + for (let y = 0; y < renderModel.lineLengths.length; y++) { + const si = y * this._terminal.cols * INDICES_PER_CELL; + const sub = (isSelectionVisible ? this._vertices.selectionAttributes : this._vertices.attributes).subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL); + activeBuffer.set(sub, bufferLength); + bufferLength += sub.length; + } + + // Bind the attributes buffer + gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer); + gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW); + + // Bind the texture atlas if it's changed + if (this._atlas.hasCanvasChanged) { + this._atlas.hasCanvasChanged = false; + gl.uniform1i(this._textureLocation, 0); + gl.activeTexture(gl.TEXTURE0 + 0); + gl.bindTexture(gl.TEXTURE_2D, this._atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this._atlas.cacheCanvas); + gl.generateMipmap(gl.TEXTURE_2D); + } + + // Set uniforms + gl.uniformMatrix4fv(this._projectionLocation, false, PROJECTION_MATRIX); + gl.uniform2f(this._resolutionLocation, gl.canvas.width, gl.canvas.height); + + // Draw the viewport + gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_BYTE, 0, bufferLength / INDICES_PER_CELL); + } + + public setAtlas(atlas: WebglCharAtlas): void { + const gl = this._gl; + this._atlas = atlas; + + gl.bindTexture(gl.TEXTURE_2D, this._atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.cacheCanvas); + gl.generateMipmap(gl.TEXTURE_2D); + } + + public setDimensions(dimensions: IRenderDimensions): void { + this._dimensions = dimensions; + } +} diff --git a/addons/xterm-addon-webgl/src/RectangleRenderer.ts b/addons/xterm-addon-webgl/src/RectangleRenderer.ts new file mode 100644 index 0000000000..d4ce3b4c36 --- /dev/null +++ b/addons/xterm-addon-webgl/src/RectangleRenderer.ts @@ -0,0 +1,325 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils'; +import { IRenderModel, IWebGLVertexArrayObject, IWebGL2RenderingContext, ISelectionRenderModel } from './Types'; +import { fill } from 'common/TypedArrayUtils'; +import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; +import { is256Color } from './atlas/CharAtlasUtils'; +import { DEFAULT_COLOR } from 'common/buffer/Constants'; +import { Terminal } from 'xterm'; +import { IColorSet, IColor } from 'browser/Types'; +import { IRenderDimensions } from 'browser/renderer/Types'; + +const enum VertexAttribLocations { + POSITION = 0, + SIZE = 1, + COLOR = 2, + UNIT_QUAD = 3 +} + +const vertexShaderSource = `#version 300 es +layout (location = ${VertexAttribLocations.POSITION}) in vec2 a_position; +layout (location = ${VertexAttribLocations.SIZE}) in vec2 a_size; +layout (location = ${VertexAttribLocations.COLOR}) in vec3 a_color; +layout (location = ${VertexAttribLocations.UNIT_QUAD}) in vec2 a_unitquad; + +uniform mat4 u_projection; +uniform vec2 u_resolution; + +out vec3 v_color; + +void main() { + vec2 zeroToOne = (a_position + (a_unitquad * a_size)) / u_resolution; + gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0); + v_color = a_color; +}`; + +const fragmentShaderSource = `#version 300 es +precision lowp float; + +in vec3 v_color; + +out vec4 outColor; + +void main() { + outColor = vec4(v_color, 1); +}`; + +interface IVertices { + attributes: Float32Array; + selection: Float32Array; + count: number; +} + +const INDICES_PER_RECTANGLE = 8; +const BYTES_PER_RECTANGLE = INDICES_PER_RECTANGLE * Float32Array.BYTES_PER_ELEMENT; + +const INITIAL_BUFFER_RECTANGLE_CAPACITY = 20 * INDICES_PER_RECTANGLE; + +export class RectangleRenderer { + + private _program: WebGLProgram; + private _vertexArrayObject: IWebGLVertexArrayObject; + private _resolutionLocation: WebGLUniformLocation; + private _attributesBuffer: WebGLBuffer; + private _projectionLocation: WebGLUniformLocation; + private _bgFloat: Float32Array; + private _selectionFloat: Float32Array; + + private _vertices: IVertices = { + count: 0, + attributes: new Float32Array(INITIAL_BUFFER_RECTANGLE_CAPACITY), + selection: new Float32Array(3 * INDICES_PER_RECTANGLE) + }; + + constructor( + private _terminal: Terminal, + private _colors: IColorSet, + private _gl: IWebGL2RenderingContext, + private _dimensions: IRenderDimensions + ) { + const gl = this._gl; + + this._program = createProgram(gl, vertexShaderSource, fragmentShaderSource); + + // Uniform locations + this._resolutionLocation = gl.getUniformLocation(this._program, 'u_resolution'); + this._projectionLocation = gl.getUniformLocation(this._program, 'u_projection'); + + // Create and set the vertex array object + this._vertexArrayObject = gl.createVertexArray(); + gl.bindVertexArray(this._vertexArrayObject); + + // Setup a_unitquad, this defines the 4 vertices of a rectangle + const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); + const unitQuadVerticesBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, unitQuadVerticesBuffer); + gl.bufferData(gl.ARRAY_BUFFER, unitQuadVertices, gl.STATIC_DRAW); + gl.enableVertexAttribArray(VertexAttribLocations.UNIT_QUAD); + gl.vertexAttribPointer(VertexAttribLocations.UNIT_QUAD, 2, this._gl.FLOAT, false, 0, 0); + + // Setup the unit quad element array buffer, this points to indices in + // unitQuadVertuces to allow is to draw 2 triangles from the vertices + const unitQuadElementIndices = new Uint8Array([0, 1, 3, 0, 2, 3]); + const elementIndicesBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, elementIndicesBuffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, unitQuadElementIndices, gl.STATIC_DRAW); + + // Setup attributes + this._attributesBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer); + gl.enableVertexAttribArray(VertexAttribLocations.POSITION); + gl.vertexAttribPointer(VertexAttribLocations.POSITION, 2, gl.FLOAT, false, BYTES_PER_RECTANGLE, 0); + gl.vertexAttribDivisor(VertexAttribLocations.POSITION, 1); + gl.enableVertexAttribArray(VertexAttribLocations.SIZE); + gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, BYTES_PER_RECTANGLE, 2 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1); + gl.enableVertexAttribArray(VertexAttribLocations.COLOR); + gl.vertexAttribPointer(VertexAttribLocations.COLOR, 4, gl.FLOAT, false, BYTES_PER_RECTANGLE, 4 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribDivisor(VertexAttribLocations.COLOR, 1); + + this._updateCachedColors(); + } + + public render(): void { + const gl = this._gl; + + gl.useProgram(this._program); + + gl.bindVertexArray(this._vertexArrayObject); + + gl.uniformMatrix4fv(this._projectionLocation, false, PROJECTION_MATRIX); + gl.uniform2f(this._resolutionLocation, gl.canvas.width, gl.canvas.height); + + // Bind attributes buffer and draw + gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer); + gl.bufferData(gl.ARRAY_BUFFER, this._vertices.attributes, gl.DYNAMIC_DRAW); + gl.drawElementsInstanced(this._gl.TRIANGLES, 6, gl.UNSIGNED_BYTE, 0, this._vertices.count); + + // Bind selection buffer and draw + gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer); + gl.bufferData(gl.ARRAY_BUFFER, this._vertices.selection, gl.DYNAMIC_DRAW); + gl.drawElementsInstanced(this._gl.TRIANGLES, 6, gl.UNSIGNED_BYTE, 0, 3); + } + + public onResize(): void { + this._updateViewportRectangle(); + } + + public setColors(): void { + this._updateCachedColors(); + this._updateViewportRectangle(); + } + + private _updateCachedColors(): void { + this._bgFloat = this._colorToFloat32Array(this._colors.background); + this._selectionFloat = this._colorToFloat32Array(this._colors.selection); + } + + private _updateViewportRectangle(): void { + // Set first rectangle that clears the screen + this._addRectangleFloat( + this._vertices.attributes, + 0, + 0, + 0, + this._terminal.cols * this._dimensions.scaledCellWidth, + this._terminal.rows * this._dimensions.scaledCellHeight, + this._bgFloat + ); + } + + public updateSelection(model: ISelectionRenderModel, columnSelectMode: boolean): void { + const terminal = this._terminal; + + if (!model.hasSelection) { + fill(this._vertices.selection, 0, 0); + return; + } + + if (columnSelectMode) { + const startCol = model.startCol; + const width = model.endCol - startCol; + const height = model.viewportCappedEndRow - model.viewportCappedStartRow + 1; + this._addRectangleFloat( + this._vertices.selection, + 0, + startCol * this._dimensions.scaledCellWidth, + model.viewportCappedStartRow * this._dimensions.scaledCellHeight, + width * this._dimensions.scaledCellWidth, + height * this._dimensions.scaledCellHeight, + this._selectionFloat + ); + fill(this._vertices.selection, 0, INDICES_PER_RECTANGLE); + } else { + // Draw first row + const startCol = model.viewportStartRow === model.viewportCappedStartRow ? model.startCol : 0; + const startRowEndCol = model.viewportCappedStartRow === model.viewportCappedEndRow ? model.endCol : terminal.cols; + this._addRectangleFloat( + this._vertices.selection, + 0, + startCol * this._dimensions.scaledCellWidth, + model.viewportCappedStartRow * this._dimensions.scaledCellHeight, + (startRowEndCol - startCol) * this._dimensions.scaledCellWidth, + this._dimensions.scaledCellHeight, + this._selectionFloat + ); + + // Draw middle rows + const middleRowsCount = Math.max(model.viewportCappedEndRow - model.viewportCappedStartRow - 1, 0); + this._addRectangleFloat( + this._vertices.selection, + INDICES_PER_RECTANGLE, + 0, + (model.viewportCappedStartRow + 1) * this._dimensions.scaledCellHeight, + terminal.cols * this._dimensions.scaledCellWidth, + middleRowsCount * this._dimensions.scaledCellHeight, + this._selectionFloat + ); + + // Draw final row + if (model.viewportCappedStartRow !== model.viewportCappedEndRow) { + // Only draw viewportEndRow if it's not the same as viewportStartRow + const endCol = model.viewportEndRow === model.viewportCappedEndRow ? model.endCol : terminal.cols; + this._addRectangleFloat( + this._vertices.selection, + INDICES_PER_RECTANGLE * 2, + 0, + model.viewportCappedEndRow * this._dimensions.scaledCellHeight, + endCol * this._dimensions.scaledCellWidth, + this._dimensions.scaledCellHeight, + this._selectionFloat + ); + } else { + fill(this._vertices.selection, 0, INDICES_PER_RECTANGLE * 2); + } + } + } + + public updateBackgrounds(model: IRenderModel): void { + const terminal = this._terminal; + const vertices = this._vertices; + + let rectangleCount = 1; + + for (let y = 0; y < terminal.rows; y++) { + let currentStartX = -1; + let currentBg = DEFAULT_COLOR; + for (let x = 0; x < terminal.cols; x++) { + const modelIndex = ((y * terminal.cols) + x) * 4; + const bg = model.cells[modelIndex + 2]; + if (bg !== currentBg) { + // A rectangle needs to be drawn if going from non-default to another color + if (currentBg !== DEFAULT_COLOR) { + const offset = rectangleCount++ * INDICES_PER_RECTANGLE; + this._updateRectangle(vertices, offset, currentBg, currentStartX, x, y); + } + currentStartX = x; + currentBg = bg; + } + } + // Finish rectangle if it's still going + if (currentBg !== DEFAULT_COLOR) { + const offset = rectangleCount++ * INDICES_PER_RECTANGLE; + this._updateRectangle(vertices, offset, currentBg, currentStartX, terminal.cols, y); + } + } + vertices.count = rectangleCount; + } + + private _updateRectangle(vertices: IVertices, offset: number, bg: number, startX: number, endX: number, y: number): void { + let color: IColor | null = null; + if (bg === INVERTED_DEFAULT_COLOR) { + color = this._colors.foreground; + } else if (is256Color(bg)) { + color = this._colors.ansi[bg]; + } else { + // TODO: Add support for true color + color = this._colors.foreground; + } + if (vertices.attributes.length < offset + 4) { + vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE); + } + const x1 = startX * this._dimensions.scaledCellWidth; + const y1 = y * this._dimensions.scaledCellHeight; + const r = ((color.rgba >> 24) & 0xFF) / 255; + const g = ((color.rgba >> 16) & 0xFF) / 255; + const b = ((color.rgba >> 8 ) & 0xFF) / 255; + + this._addRectangle(vertices.attributes, offset, x1, y1, (endX - startX) * this._dimensions.scaledCellWidth, this._dimensions.scaledCellHeight, r, g, b, 1); + } + + private _addRectangle(array: Float32Array, offset: number, x1: number, y1: number, width: number, height: number, r: number, g: number, b: number, a: number): void { + array[offset ] = x1; + array[offset + 1] = y1; + array[offset + 2] = width; + array[offset + 3] = height; + array[offset + 4] = r; + array[offset + 5] = g; + array[offset + 6] = b; + array[offset + 7] = a; + } + + private _addRectangleFloat(array: Float32Array, offset: number, x1: number, y1: number, width: number, height: number, color: Float32Array): void { + array[offset ] = x1; + array[offset + 1] = y1; + array[offset + 2] = width; + array[offset + 3] = height; + array[offset + 4] = color[0]; + array[offset + 5] = color[1]; + array[offset + 6] = color[2]; + array[offset + 7] = color[3]; + } + + private _colorToFloat32Array(color: IColor): Float32Array { + return new Float32Array([ + ((color.rgba >> 24) & 0xFF) / 255, + ((color.rgba >> 16) & 0xFF) / 255, + ((color.rgba >> 8 ) & 0xFF) / 255, + ((color.rgba ) & 0xFF) / 255 + ]); + } +} diff --git a/addons/xterm-addon-webgl/src/RenderModel.ts b/addons/xterm-addon-webgl/src/RenderModel.ts new file mode 100644 index 0000000000..a9ee24e9a8 --- /dev/null +++ b/addons/xterm-addon-webgl/src/RenderModel.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderModel, ISelectionRenderModel } from './Types'; +import { fill } from 'common/TypedArrayUtils'; + +export const RENDER_MODEL_INDICIES_PER_CELL = 4; + +export const COMBINED_CHAR_BIT_MASK = 0x80000000; + +export class RenderModel implements IRenderModel { + public cells: Uint32Array; + public lineLengths: Uint32Array; + public selection: ISelectionRenderModel; + + constructor() { + this.cells = new Uint32Array(0); + this.lineLengths = new Uint32Array(0); + this.selection = { + hasSelection: false, + viewportStartRow: 0, + viewportEndRow: 0, + viewportCappedStartRow: 0, + viewportCappedEndRow: 0, + startCol: 0, + endCol: 0 + }; + } + + public resize(cols: number, rows: number): void { + const indexCount = cols * rows * RENDER_MODEL_INDICIES_PER_CELL; + if (indexCount !== this.cells.length) { + this.cells = new Uint32Array(indexCount); + this.lineLengths = new Uint32Array(rows); + } + } + + public clear(): void { + fill(this.cells, 0, 0); + fill(this.lineLengths, 0, 0); + this.clearSelection(); + } + + public clearSelection(): void { + this.selection.hasSelection = false; + this.selection.viewportStartRow = 0; + this.selection.viewportEndRow = 0; + this.selection.viewportCappedStartRow = 0; + this.selection.viewportCappedEndRow = 0; + this.selection.startCol = 0; + this.selection.endCol = 0; + } +} diff --git a/addons/xterm-addon-webgl/src/TypedArray.test.ts b/addons/xterm-addon-webgl/src/TypedArray.test.ts new file mode 100644 index 0000000000..f18c6774ba --- /dev/null +++ b/addons/xterm-addon-webgl/src/TypedArray.test.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { assert } from 'chai'; +import { sliceFallback } from './TypedArray'; + +type TypedArray = Uint8Array | Uint16Array | Uint32Array | Uint8ClampedArray + | Int8Array | Int16Array | Int32Array + | Float32Array | Float64Array; + +function deepEquals(a: TypedArray, b: TypedArray): void { + assert.equal(a.length, b.length); + for (let i = 0; i < a.length; ++i) { + assert.equal(a[i], b[i]); + } +} + +describe('polyfill conformance tests', function(): void { + describe('TypedArray.slice', () => { + describe('should work with all typed array types', () => { + it('Uint8Array', () => { + const a = new Uint8Array(5); + deepEquals(sliceFallback(a, 2), a.slice(2)); + deepEquals(sliceFallback(a, 65535), a.slice(65535)); + deepEquals(sliceFallback(a, -1), a.slice(-1)); + }); + it('Uint16Array', () => { + const u161 = new Uint16Array(5); + const u162 = new Uint16Array(5); + deepEquals(sliceFallback(u161, 2), u162.slice(2)); + deepEquals(sliceFallback(u161, 65535), u162.slice(65535)); + deepEquals(sliceFallback(u161, -1), u162.slice(-1)); + }); + it('Uint32Array', () => { + const u321 = new Uint32Array(5); + const u322 = new Uint32Array(5); + deepEquals(sliceFallback(u321, 2), u322.slice(2)); + deepEquals(sliceFallback(u321, 65537), u322.slice(65537)); + deepEquals(sliceFallback(u321, -1), u322.slice(-1)); + }); + it('Int8Array', () => { + const i81 = new Int8Array(5); + const i82 = new Int8Array(5); + deepEquals(sliceFallback(i81, 2), i82.slice(2)); + deepEquals(sliceFallback(i81, 65537), i82.slice(65537)); + deepEquals(sliceFallback(i81, -1), i82.slice(-1)); + }); + it('Int16Array', () => { + const i161 = new Int16Array(5); + const i162 = new Int16Array(5); + deepEquals(sliceFallback(i161, 2), i162.slice(2)); + deepEquals(sliceFallback(i161, 65535), i162.slice(65535)); + deepEquals(sliceFallback(i161, -1), i162.slice(-1)); + }); + it('Int32Array', () => { + const i321 = new Int32Array(5); + const i322 = new Int32Array(5); + deepEquals(sliceFallback(i321, 2), i322.slice(2)); + deepEquals(sliceFallback(i321, 65537), i322.slice(65537)); + deepEquals(sliceFallback(i321, -1), i322.slice(-1)); + }); + it('Float32Array', () => { + const f321 = new Float32Array(5); + const f322 = new Float32Array(5); + deepEquals(sliceFallback(f321, 2), f322.slice(2)); + deepEquals(sliceFallback(f321, 65537), f322.slice(65537)); + deepEquals(sliceFallback(f321, -1), f322.slice(-1)); + }); + it('Float64Array', () => { + const f641 = new Float64Array(5); + const f642 = new Float64Array(5); + deepEquals(sliceFallback(f641, 2), f642.slice(2)); + deepEquals(sliceFallback(f641, 65537), f642.slice(65537)); + deepEquals(sliceFallback(f641, -1), f642.slice(-1)); + }); + it('Uint8ClampedArray', () => { + const u8Clamped1 = new Uint8ClampedArray(5); + const u8Clamped2 = new Uint8ClampedArray(5); + deepEquals(sliceFallback(u8Clamped1, 2), u8Clamped2.slice(2)); + deepEquals(sliceFallback(u8Clamped1, 65537), u8Clamped2.slice(65537)); + deepEquals(sliceFallback(u8Clamped1, -1), u8Clamped2.slice(-1)); + }); + }); + it('start', () => { + const arr = new Uint32Array([1, 2, 3, 4, 5]); + deepEquals(sliceFallback(arr, -1), arr.slice(-1)); + deepEquals(sliceFallback(arr, 0), arr.slice(0)); + deepEquals(sliceFallback(arr, 1), arr.slice(1)); + deepEquals(sliceFallback(arr, 2), arr.slice(2)); + deepEquals(sliceFallback(arr, 3), arr.slice(3)); + deepEquals(sliceFallback(arr, 4), arr.slice(4)); + deepEquals(sliceFallback(arr, 5), arr.slice(5)); + }); + it('end', () => { + const arr = new Uint32Array([1, 2, 3, 4, 5]); + deepEquals(sliceFallback(arr, -1, -2), arr.slice(-1, -2)); + deepEquals(sliceFallback(arr, 0, -2), arr.slice(0, -2)); + deepEquals(sliceFallback(arr, 1, -2), arr.slice(1, -2)); + deepEquals(sliceFallback(arr, 2, -2), arr.slice(2, -2)); + deepEquals(sliceFallback(arr, 3, -2), arr.slice(3, -2)); + deepEquals(sliceFallback(arr, 4, -2), arr.slice(4, -2)); + deepEquals(sliceFallback(arr, 5, -2), arr.slice(5, -2)); + + deepEquals(sliceFallback(arr, -1, 3), arr.slice(-1, 3)); + deepEquals(sliceFallback(arr, 0, 3), arr.slice(0, 3)); + deepEquals(sliceFallback(arr, 1, 3), arr.slice(1, 3)); + deepEquals(sliceFallback(arr, 2, 3), arr.slice(2, 3)); + deepEquals(sliceFallback(arr, 3, 3), arr.slice(3, 3)); + deepEquals(sliceFallback(arr, 4, 3), arr.slice(4, 3)); + deepEquals(sliceFallback(arr, 5, 3), arr.slice(5, 3)); + + deepEquals(sliceFallback(arr, -1, 8), arr.slice(-1, 8)); + deepEquals(sliceFallback(arr, 0, 8), arr.slice(0, 8)); + deepEquals(sliceFallback(arr, 1, 8), arr.slice(1, 8)); + deepEquals(sliceFallback(arr, 2, 8), arr.slice(2, 8)); + deepEquals(sliceFallback(arr, 3, 8), arr.slice(3, 8)); + deepEquals(sliceFallback(arr, 4, 8), arr.slice(4, 8)); + deepEquals(sliceFallback(arr, 5, 8), arr.slice(5, 8)); + }); + }); +}); diff --git a/addons/xterm-addon-webgl/src/TypedArray.ts b/addons/xterm-addon-webgl/src/TypedArray.ts new file mode 100644 index 0000000000..ee93be35c9 --- /dev/null +++ b/addons/xterm-addon-webgl/src/TypedArray.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export type TypedArray = Uint8Array | Uint16Array | Uint32Array | Uint8ClampedArray + | Int8Array | Int16Array | Int32Array + | Float32Array | Float64Array; + +export function slice(array: T, start?: number, end?: number): T { + // all modern engines that support .slice + if (array.slice) { + return array.slice(start, end) as T; + } + return sliceFallback(array, start, end); +} + +export function sliceFallback(array: T, start: number = 0, end: number = array.length): T { + if (start < 0) { + start = (array.length + start) % array.length; + } + if (end >= array.length) { + end = array.length; + } else { + end = (array.length + end) % array.length; + } + start = Math.min(start, end); + + const result: T = new (array.constructor as any)(end - start); + for (let i = 0; i < end - start; ++i) { + result[i] = array[i + start]; + } + return result; +} diff --git a/addons/xterm-addon-webgl/src/Types.d.ts b/addons/xterm-addon-webgl/src/Types.d.ts new file mode 100644 index 0000000000..6ebc8d64df --- /dev/null +++ b/addons/xterm-addon-webgl/src/Types.d.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export interface IRasterizedGlyphSet { + [flags: number]: IRasterizedGlyph; +} + +/** + * Represents a rasterized glyph within a texture atlas. Some numbers are + * tracked in CSS pixels as well in order to reduce calculations during the + * render loop. + */ +export interface IRasterizedGlyph { + /** + * The x and y offset between the glyph's top/left and the top/left of a cell + * in pixels. + */ + offset: IVector; + /** + * the x and y position of the glyph in the texture in pixels. + */ + texturePosition: IVector; + /** + * the x and y position of the glyph in the texture in clip space coordinates. + */ + texturePositionClipSpace: IVector; + /** + * The width and height of the glyph in the texture in pixels. + */ + size: IVector; + /** + * The width and height of the glyph in the texture in clip space coordinates. + */ + sizeClipSpace: IVector; +} + +export interface IVector { + x: number; + y: number; +} + +export interface IBoundingBox { + top: number; + left: number; + right: number; + bottom: number; +} + +export interface IRenderModel { + cells: Uint32Array; + lineLengths: Uint32Array; + selection: ISelectionRenderModel; +} + +export interface ISelectionRenderModel { + hasSelection: boolean; + viewportStartRow: number; + viewportEndRow: number; + viewportCappedStartRow: number; + viewportCappedEndRow: number; + startCol: number; + endCol: number; +} + +export interface IWebGL2RenderingContext extends WebGLRenderingContext { + vertexAttribDivisor(index: number, divisor: number): void; + createVertexArray(): IWebGLVertexArrayObject; + bindVertexArray(vao: IWebGLVertexArrayObject): void; + drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void; +} + +export interface IWebGLVertexArrayObject { +} diff --git a/addons/xterm-addon-webgl/src/WebglAddon.ts b/addons/xterm-addon-webgl/src/WebglAddon.ts new file mode 100644 index 0000000000..0074f9b28f --- /dev/null +++ b/addons/xterm-addon-webgl/src/WebglAddon.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, ITerminalAddon } from 'xterm'; +import { WebglRenderer } from './WebglRenderer'; +import { IRenderService } from 'browser/services/Services'; +import { IColorSet } from 'browser/Types'; + +export class WebglAddon implements ITerminalAddon { + constructor( + private _preserveDrawingBuffer?: boolean + ) {} + + public activate(terminal: Terminal): void { + if (!terminal.element) { + throw new Error('Cannot activate WebglRendererAddon before Terminal.open'); + } + const renderService: IRenderService = (terminal)._core._renderService; + const colors: IColorSet = (terminal)._core._colorManager.colors; + renderService.setRenderer(new WebglRenderer(terminal, colors, this._preserveDrawingBuffer)); + } + + public dispose(): void { + throw new Error('WebglRendererAddon.dispose Not yet implemented'); + } +} diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.api.ts b/addons/xterm-addon-webgl/src/WebglRenderer.api.ts new file mode 100644 index 0000000000..b26790c484 --- /dev/null +++ b/addons/xterm-addon-webgl/src/WebglRenderer.api.ts @@ -0,0 +1,168 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import * as puppeteer from 'puppeteer'; +import { assert } from 'chai'; +import { ITerminalOptions } from '../../../src/Types'; +import { ITheme } from 'xterm'; + +const APP = 'http://127.0.0.1:3000/test'; + +let browser: puppeteer.Browser; +let page: puppeteer.Page; +const width = 800; +const height = 600; + +describe('WebGL Renderer Integration Tests', function(): void { + this.timeout(20000); + + before(async function(): Promise { + browser = await puppeteer.launch({ + headless: process.argv.indexOf('--headless') !== -1, + slowMo: 80, + args: [`--window-size=${width},${height}`] + }); + page = (await browser.pages())[0]; + await page.setViewport({ width, height }); + await page.goto(APP); + await openTerminal(); + await page.evaluate(`window.term.loadAddon(new WebglAddon(true));`); + }); + + after(() => { + browser.close(); + }); + + beforeEach(async () => { + await page.evaluate(`window.term.reset()`); + }); + + describe('WebGL Renderer', () => { + it('foreground colors normal', async function(): Promise { + const theme: ITheme = { + black: '#010203', + red: '#040506', + green: '#070809', + yellow: '#0a0b0c', + blue: '#0d0e0f', + magenta: '#101112', + cyan: '#131415', + white: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[30m█\\x1b[31m█\\x1b[32m█\\x1b[33m█\\x1b[34m█\\x1b[35m█\\x1b[36m█\\x1b[37m█`); + assert.deepEqual(await getCellColor(1, 1), [1, 2, 3, 255]); + assert.deepEqual(await getCellColor(2, 1), [4, 5, 6, 255]); + assert.deepEqual(await getCellColor(3, 1), [7, 8, 9, 255]); + assert.deepEqual(await getCellColor(4, 1), [10, 11, 12, 255]); + assert.deepEqual(await getCellColor(5, 1), [13, 14, 15, 255]); + assert.deepEqual(await getCellColor(6, 1), [16, 17, 18, 255]); + assert.deepEqual(await getCellColor(7, 1), [19, 20, 21, 255]); + assert.deepEqual(await getCellColor(8, 1), [22, 23, 24, 255]); + }); + + it('foreground colors bright', async function(): Promise { + const theme: ITheme = { + brightBlack: '#010203', + brightRed: '#040506', + brightGreen: '#070809', + brightYellow: '#0a0b0c', + brightBlue: '#0d0e0f', + brightMagenta: '#101112', + brightCyan: '#131415', + brightWhite: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[90m█\\x1b[91m█\\x1b[92m█\\x1b[93m█\\x1b[94m█\\x1b[95m█\\x1b[96m█\\x1b[97m█`); + assert.deepEqual(await getCellColor(1, 1), [1, 2, 3, 255]); + assert.deepEqual(await getCellColor(2, 1), [4, 5, 6, 255]); + assert.deepEqual(await getCellColor(3, 1), [7, 8, 9, 255]); + assert.deepEqual(await getCellColor(4, 1), [10, 11, 12, 255]); + assert.deepEqual(await getCellColor(5, 1), [13, 14, 15, 255]); + assert.deepEqual(await getCellColor(6, 1), [16, 17, 18, 255]); + assert.deepEqual(await getCellColor(7, 1), [19, 20, 21, 255]); + assert.deepEqual(await getCellColor(8, 1), [22, 23, 24, 255]); + }); + + it('background colors normal', async function(): Promise { + const theme: ITheme = { + black: '#010203', + red: '#040506', + green: '#070809', + yellow: '#0a0b0c', + blue: '#0d0e0f', + magenta: '#101112', + cyan: '#131415', + white: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[40m \\x1b[41m \\x1b[42m \\x1b[43m \\x1b[44m \\x1b[45m \\x1b[46m \\x1b[47m `); + assert.deepEqual(await getCellColor(1, 1), [1, 2, 3, 255]); + assert.deepEqual(await getCellColor(2, 1), [4, 5, 6, 255]); + assert.deepEqual(await getCellColor(3, 1), [7, 8, 9, 255]); + assert.deepEqual(await getCellColor(4, 1), [10, 11, 12, 255]); + assert.deepEqual(await getCellColor(5, 1), [13, 14, 15, 255]); + assert.deepEqual(await getCellColor(6, 1), [16, 17, 18, 255]); + assert.deepEqual(await getCellColor(7, 1), [19, 20, 21, 255]); + assert.deepEqual(await getCellColor(8, 1), [22, 23, 24, 255]); + }); + + it('background colors bright', async function(): Promise { + const theme: ITheme = { + brightBlack: '#010203', + brightRed: '#040506', + brightGreen: '#070809', + brightYellow: '#0a0b0c', + brightBlue: '#0d0e0f', + brightMagenta: '#101112', + brightCyan: '#131415', + brightWhite: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[100m \\x1b[101m \\x1b[102m \\x1b[103m \\x1b[104m \\x1b[105m \\x1b[106m \\x1b[107m `); + assert.deepEqual(await getCellColor(1, 1), [1, 2, 3, 255]); + assert.deepEqual(await getCellColor(2, 1), [4, 5, 6, 255]); + assert.deepEqual(await getCellColor(3, 1), [7, 8, 9, 255]); + assert.deepEqual(await getCellColor(4, 1), [10, 11, 12, 255]); + assert.deepEqual(await getCellColor(5, 1), [13, 14, 15, 255]); + assert.deepEqual(await getCellColor(6, 1), [16, 17, 18, 255]); + assert.deepEqual(await getCellColor(7, 1), [19, 20, 21, 255]); + assert.deepEqual(await getCellColor(8, 1), [22, 23, 24, 255]); + }); + }); +}); + +async function openTerminal(options: ITerminalOptions = {}): Promise { + await page.evaluate(`window.term = new Terminal(${JSON.stringify(options)})`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + if (options.rendererType === 'dom') { + await page.waitForSelector('.xterm-rows'); + } else { + await page.waitForSelector('.xterm-text-layer'); + } +} + +async function writeSync(data: string): Promise { + await page.evaluate(`window.term.write('${data}');`); + while (true) { + if (await page.evaluate(`window.term._core.writeBuffer.length === 0`)) { + break; + } + } +} + +async function getCellColor(col: number, row: number): Promise { + await page.evaluate(` + window.gl = window.term._core._renderService._renderer._gl; + window.result = new Uint8Array(4); + window.d = window.term._core._renderService.dimensions; + window.gl.readPixels( + Math.floor((${col - 0.5}) * window.d.scaledCellWidth), + Math.floor(window.gl.drawingBufferHeight - 1 - (${row - 0.5}) * window.d.scaledCellHeight), + 1, 1, window.gl.RGBA, window.gl.UNSIGNED_BYTE, window.result + ); + `); + return await page.evaluate(`Array.from(window.result)`); +} diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts new file mode 100644 index 0000000000..defed9621e --- /dev/null +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -0,0 +1,410 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal } from '../../../src/Types'; +import { GlyphRenderer } from './GlyphRenderer'; +import { LinkRenderLayer } from './renderLayer/LinkRenderLayer'; +import { CursorRenderLayer } from './renderLayer/CursorRenderLayer'; +import { acquireCharAtlas } from './atlas/CharAtlasCache'; +import { WebglCharAtlas } from './atlas/WebglCharAtlas'; +import { RectangleRenderer } from './RectangleRenderer'; +import { IWebGL2RenderingContext } from './Types'; +import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; +import { RenderModel, COMBINED_CHAR_BIT_MASK } from './RenderModel'; +import { Disposable } from 'common/Lifecycle'; +import { DEFAULT_COLOR, CHAR_DATA_CHAR_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_ATTR_INDEX, NULL_CELL_CODE } from 'common/buffer/Constants'; +import { Terminal } from 'xterm'; +import { getLuminance } from './ColorUtils'; +import { IRenderLayer } from './renderLayer/Types'; +import { IRenderDimensions, IRenderer } from 'browser/renderer/Types'; +import { IColorSet } from 'browser/Types'; +import { FLAGS } from './Constants'; +import { getCompatAttr } from './CharDataCompat'; + +export const INDICIES_PER_CELL = 4; + +export class WebglRenderer extends Disposable implements IRenderer { + private _renderLayers: IRenderLayer[]; + private _charAtlas: WebglCharAtlas; + private _devicePixelRatio: number; + + private _model: RenderModel = new RenderModel(); + + private _canvas: HTMLCanvasElement; + private _gl: IWebGL2RenderingContext; + private _rectangleRenderer: RectangleRenderer; + private _glyphRenderer: GlyphRenderer; + + public dimensions: IRenderDimensions; + + private _core: ITerminal; + + constructor( + private _terminal: Terminal, + private _colors: IColorSet, + preserveDrawingBuffer?: boolean + ) { + super(); + + this._core = (this._terminal)._core; + + this._applyBgLuminanceBasedSelection(); + + this._renderLayers = [ + new LinkRenderLayer(this._core.screenElement, 2, this._colors, this._core), + new CursorRenderLayer(this._core.screenElement, 3, this._colors) + ]; + this.dimensions = { + scaledCharWidth: null, + scaledCharHeight: null, + scaledCellWidth: null, + scaledCellHeight: null, + scaledCharLeft: null, + scaledCharTop: null, + scaledCanvasWidth: null, + scaledCanvasHeight: null, + canvasWidth: null, + canvasHeight: null, + actualCellWidth: null, + actualCellHeight: null + }; + this._devicePixelRatio = window.devicePixelRatio; + this._updateDimensions(); + + this._canvas = document.createElement('canvas'); + + const contextAttributes = { + antialias: false, + depth: false, + preserveDrawingBuffer + }; + this._gl = this._canvas.getContext('webgl2', contextAttributes) as IWebGL2RenderingContext; + if (!this._gl) { + throw new Error('WebGL2 not supported'); + } + this._core.screenElement.appendChild(this._canvas); + + this._rectangleRenderer = new RectangleRenderer(this._terminal, this._colors, this._gl, this.dimensions); + this._glyphRenderer = new GlyphRenderer(this._terminal, this._colors, this._gl, this.dimensions); + + // Update dimensions and acquire char atlas + this.onCharSizeChanged(); + } + + public dispose(): void { + this._renderLayers.forEach(l => l.dispose()); + this._core.screenElement.removeChild(this._canvas); + super.dispose(); + } + + private _applyBgLuminanceBasedSelection(): void { + // HACK: This is needed until webgl renderer adds support for selection colors + if (getLuminance(this._colors.background) > 0.5) { + this._colors.selection = { css: '#000', rgba: 255 }; + } else { + this._colors.selection = { css: '#fff', rgba: 4294967295 }; + } + } + + public setColors(colors: IColorSet): void { + this._colors = colors; + + this._applyBgLuminanceBasedSelection(); + + // Clear layers and force a full render + this._renderLayers.forEach(l => { + l.setColors(this._terminal, this._colors); + l.reset(this._terminal); + }); + + this._rectangleRenderer.setColors(); + this._glyphRenderer.setColors(); + + this._refreshCharAtlas(); + + // Force a full refresh + this._model.clear(); + } + + public onDevicePixelRatioChange(): void { + // If the device pixel ratio changed, the char atlas needs to be regenerated + // and the terminal needs to refreshed + if (this._devicePixelRatio !== window.devicePixelRatio) { + this._devicePixelRatio = window.devicePixelRatio; + this.onResize(this._terminal.cols, this._terminal.rows); + } + } + + public onResize(cols: number, rows: number): void { + // Update character and canvas dimensions + this._updateDimensions(); + + this._model.resize(this._terminal.cols, this._terminal.rows); + this._rectangleRenderer.onResize(); + + // Resize all render layers + this._renderLayers.forEach(l => l.resize(this._terminal, this.dimensions)); + + // Resize the canvas + this._canvas.width = this.dimensions.scaledCanvasWidth; + this._canvas.height = this.dimensions.scaledCanvasHeight; + this._canvas.style.width = `${this.dimensions.canvasWidth}px`; + this._canvas.style.height = `${this.dimensions.canvasHeight}px`; + + // Resize the screen + this._core.screenElement.style.width = `${this.dimensions.canvasWidth}px`; + this._core.screenElement.style.height = `${this.dimensions.canvasHeight}px`; + this._glyphRenderer.setDimensions(this.dimensions); + this._glyphRenderer.onResize(); + + this._refreshCharAtlas(); + + // Force a full refresh + this._model.clear(); + } + + public onCharSizeChanged(): void { + this.onResize(this._terminal.cols, this._terminal.rows); + } + + public onBlur(): void { + this._renderLayers.forEach(l => l.onBlur(this._terminal)); + } + + public onFocus(): void { + this._renderLayers.forEach(l => l.onFocus(this._terminal)); + } + + public onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean): void { + this._renderLayers.forEach(l => l.onSelectionChanged(this._terminal, start, end, columnSelectMode)); + + this._updateSelectionModel(start, end); + + this._rectangleRenderer.updateSelection(this._model.selection, columnSelectMode); + this._glyphRenderer.updateSelection(this._model, columnSelectMode); + + // TODO: #2102 Should this move to RenderCoordinator? + this._core.refresh(0, this._terminal.rows - 1); + } + + public onCursorMove(): void { + this._renderLayers.forEach(l => l.onCursorMove(this._terminal)); + } + + public onOptionsChanged(): void { + this._renderLayers.forEach(l => l.onOptionsChanged(this._terminal)); + this._updateDimensions(); + this._refreshCharAtlas(); + } + + /** + * Refreshes the char atlas, aquiring a new one if necessary. + * @param terminal The terminal. + * @param colorSet The color set to use for the char atlas. + */ + private _refreshCharAtlas(): void { + if (this.dimensions.scaledCharWidth <= 0 && this.dimensions.scaledCharHeight <= 0) { + return; + } + + const atlas = acquireCharAtlas(this._terminal, this._colors, this.dimensions.scaledCharWidth, this.dimensions.scaledCharHeight); + if (!('getRasterizedGlyph' in atlas)) { + throw new Error('The webgl renderer only works with the webgl char atlas'); + } + this._charAtlas = atlas as WebglCharAtlas; + this._charAtlas.warmUp(); + this._glyphRenderer.setAtlas(this._charAtlas); + } + + public clear(): void { + this._renderLayers.forEach(l => l.reset(this._terminal)); + } + + public registerCharacterJoiner(handler: (text: string) => [number, number][]): number { + return -1; + } + + public deregisterCharacterJoiner(joinerId: number): boolean { + return false; + } + + public renderRows(start: number, end: number): void { + // Update render layers + this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); + + // Tell renderer the frame is beginning + if (this._glyphRenderer.beginFrame()) { + this._model.clear(); + } + + // Update model to reflect what's drawn + this._updateModel(start, end); + + // Render + this._rectangleRenderer.render(); + this._glyphRenderer.render(this._model, this._model.selection.hasSelection); + } + + private _updateModel(start: number, end: number): void { + const terminal = this._core; + + for (let y = start; y <= end; y++) { + const row = y + terminal.buffer.ydisp; + const line = terminal.buffer.lines.get(row); + this._model.lineLengths[y] = 0; + for (let x = 0; x < terminal.cols; x++) { + const charData = line.get(x); + const chars = charData[CHAR_DATA_CHAR_INDEX]; + let code = charData[CHAR_DATA_CODE_INDEX]; + const attr = getCompatAttr(line, x); // charData[CHAR_DATA_ATTR_INDEX]; + const i = ((y * terminal.cols) + x) * INDICIES_PER_CELL; + + if (code !== NULL_CELL_CODE) { + this._model.lineLengths[y] = x + 1; + } + + // Nothing has changed, no updates needed + if (this._model.cells[i] === code && this._model.cells[i + 1] === attr) { + continue; + } + + // Resolve bg and fg and cache in the model + const flags = attr >> 18; + let bg = attr & 0x1ff; + let fg = (attr >> 9) & 0x1ff; + + // If inverse flag is on, the foreground should become the background. + if (flags & FLAGS.INVERSE) { + const temp = bg; + bg = fg; + fg = temp; + if (fg === DEFAULT_COLOR) { + fg = INVERTED_DEFAULT_COLOR; + } + if (bg === DEFAULT_COLOR) { + bg = INVERTED_DEFAULT_COLOR; + } + } + const drawInBrightColor = terminal.options.drawBoldTextInBrightColors && !!(flags & FLAGS.BOLD) && fg < 8 && fg !== INVERTED_DEFAULT_COLOR; + fg += drawInBrightColor ? 8 : 0; + + // Flag combined chars with a bit mask so they're easily identifiable + if (chars.length > 1) { + code = code | COMBINED_CHAR_BIT_MASK; + } + + this._model.cells[i ] = code; + this._model.cells[i + 1] = attr; + this._model.cells[i + 2] = bg; + this._model.cells[i + 3] = fg; + + this._glyphRenderer.updateCell(x, y, code, attr, bg, fg, chars); + } + } + this._rectangleRenderer.updateBackgrounds(this._model); + } + + private _updateSelectionModel(start: [number, number], end: [number, number]): void { + const terminal = this._terminal; + + // Selection does not exist + if (!start || !end || (start[0] === end[0] && start[1] === end[1])) { + this._model.clearSelection(); + return; + } + + // Translate from buffer position to viewport position + const viewportStartRow = start[1] - terminal.buffer.viewportY; + const viewportEndRow = end[1] - terminal.buffer.viewportY; + const viewportCappedStartRow = Math.max(viewportStartRow, 0); + const viewportCappedEndRow = Math.min(viewportEndRow, terminal.rows - 1); + + // No need to draw the selection + if (viewportCappedStartRow >= terminal.rows || viewportCappedEndRow < 0) { + this._model.clearSelection(); + return; + } + + this._model.selection.hasSelection = true; + this._model.selection.viewportStartRow = viewportStartRow; + this._model.selection.viewportEndRow = viewportEndRow; + this._model.selection.viewportCappedStartRow = viewportCappedStartRow; + this._model.selection.viewportCappedEndRow = viewportCappedEndRow; + this._model.selection.startCol = start[0]; + this._model.selection.endCol = end[0]; + } + + /** + * Recalculates the character and canvas dimensions. + */ + private _updateDimensions(): void { + // TODO: Acquire CharSizeService properly + + // Perform a new measure if the CharMeasure dimensions are not yet available + if (!(this._core)._charSizeService.width || !(this._core)._charSizeService.height) { + return; + } + + // Calculate the scaled character width. Width is floored as it must be + // drawn to an integer grid in order for the CharAtlas "stamps" to not be + // blurry. When text is drawn to the grid not using the CharAtlas, it is + // clipped to ensure there is no overlap with the next cell. + + // NOTE: ceil fixes sometime, floor does others :s + + this.dimensions.scaledCharWidth = Math.floor((this._core)._charSizeService.width * this._devicePixelRatio); + + // Calculate the scaled character height. Height is ceiled in case + // devicePixelRatio is a floating point number in order to ensure there is + // enough space to draw the character to the cell. + this.dimensions.scaledCharHeight = Math.ceil((this._core)._charSizeService.height * this._devicePixelRatio); + + // Calculate the scaled cell height, if lineHeight is not 1 then the value + // will be floored because since lineHeight can never be lower then 1, there + // is a guarentee that the scaled line height will always be larger than + // scaled char height. + this.dimensions.scaledCellHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.getOption('lineHeight')); + + // Calculate the y coordinate within a cell that text should draw from in + // order to draw in the center of a cell. + this.dimensions.scaledCharTop = this._terminal.getOption('lineHeight') === 1 ? 0 : Math.round((this.dimensions.scaledCellHeight - this.dimensions.scaledCharHeight) / 2); + + // Calculate the scaled cell width, taking the letterSpacing into account. + this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth + Math.round(this._terminal.getOption('letterSpacing')); + + // Calculate the x coordinate with a cell that text should draw from in + // order to draw in the center of a cell. + this.dimensions.scaledCharLeft = Math.floor(this._terminal.getOption('letterSpacing') / 2); + + // Recalculate the canvas dimensions; scaled* define the actual number of + // pixel in the canvas + this.dimensions.scaledCanvasHeight = this._terminal.rows * this.dimensions.scaledCellHeight; + this.dimensions.scaledCanvasWidth = this._terminal.cols * this.dimensions.scaledCellWidth; + + // The the size of the canvas on the page. It's very important that this + // rounds to nearest integer and not ceils as browsers often set + // window.devicePixelRatio as something like 1.100000023841858, when it's + // actually 1.1. Ceiling causes blurriness as the backing canvas image is 1 + // pixel too large for the canvas element size. + this.dimensions.canvasHeight = Math.round(this.dimensions.scaledCanvasHeight / this._devicePixelRatio); + this.dimensions.canvasWidth = Math.round(this.dimensions.scaledCanvasWidth / this._devicePixelRatio); + + // this.dimensions.scaledCanvasHeight = this.dimensions.canvasHeight * devicePixelRatio; + // this.dimensions.scaledCanvasWidth = this.dimensions.canvasWidth * devicePixelRatio; + + // Get the _actual_ dimensions of an individual cell. This needs to be + // derived from the canvasWidth/Height calculated above which takes into + // account window.devicePixelRatio. CharMeasure.width/height by itself is + // insufficient when the page is not at 100% zoom level as CharMeasure is + // measured in CSS pixels, but the actual char size on the canvas can + // differ. + // this.dimensions.actualCellHeight = this.dimensions.canvasHeight / this._terminal.rows; + // this.dimensions.actualCellWidth = this.dimensions.canvasWidth / this._terminal.cols; + + // This fixes 110% and 125%, not 150% or 175% though + this.dimensions.actualCellHeight = this.dimensions.scaledCellHeight / this._devicePixelRatio; + this.dimensions.actualCellWidth = this.dimensions.scaledCellWidth / this._devicePixelRatio; + } +} diff --git a/addons/xterm-addon-webgl/src/WebglUtils.ts b/addons/xterm-addon-webgl/src/WebglUtils.ts new file mode 100644 index 0000000000..8f166a23d3 --- /dev/null +++ b/addons/xterm-addon-webgl/src/WebglUtils.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +/** + * A matrix that when multiplies will translate 0-1 coordinates (left to right, + * top to bottom) to clip space. + */ +export const PROJECTION_MATRIX = new Float32Array([ + 2, 0, 0, 0, + 0, -2, 0, 0, + 0, 0, 1, 0, + -1, 1, 0, 1 +]); + +export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | undefined { + const program = gl.createProgram(); + gl.attachShader(program, createShader(gl, gl.VERTEX_SHADER, vertexSource)); + gl.attachShader(program, createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)); + gl.linkProgram(program); + const success = gl.getProgramParameter(program, gl.LINK_STATUS); + if (success) { + return program; + } + + console.log(gl.getProgramInfoLog(program)); + gl.deleteProgram(program); +} + +export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); + if (success) { + return shader; + } + + console.log(gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); +} + +export function expandFloat32Array(source: Float32Array, max: number): Float32Array { + const newLength = Math.min(source.length * 2, max); + const newArray = new Float32Array(newLength); + for (let i = 0; i < source.length; i++) { + newArray[i] = source[i]; + } + return newArray; +} diff --git a/addons/xterm-addon-webgl/src/atlas/BaseCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/BaseCharAtlas.ts new file mode 100644 index 0000000000..470736f139 --- /dev/null +++ b/addons/xterm-addon-webgl/src/atlas/BaseCharAtlas.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IGlyphIdentifier } from './Types'; +import { IDisposable } from 'xterm'; + +export abstract class BaseCharAtlas implements IDisposable { + private _didWarmUp: boolean = false; + + public dispose(): void { } + + /** + * 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; +} diff --git a/addons/xterm-addon-webgl/src/atlas/CharAtlasCache.ts b/addons/xterm-addon-webgl/src/atlas/CharAtlasCache.ts new file mode 100644 index 0000000000..647b96b4c8 --- /dev/null +++ b/addons/xterm-addon-webgl/src/atlas/CharAtlasCache.ts @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { generateConfig, configEquals } from './CharAtlasUtils'; +import { BaseCharAtlas } from './BaseCharAtlas'; +import { WebglCharAtlas } from './WebglCharAtlas'; +import { ICharAtlasConfig } from './Types'; +import { Terminal } from 'xterm'; +import { IColorSet } from 'browser/Types'; + +interface ICharAtlasCacheEntry { + atlas: BaseCharAtlas; + config: ICharAtlasConfig; + // N.B. This implementation potentially holds onto copies of the terminal forever, so + // this may cause memory leaks. + ownedBy: Terminal[]; +} + +const charAtlasCache: ICharAtlasCacheEntry[] = []; + +/** + * Acquires a char atlas, either generating a new one or returning an existing + * one that is in use by another terminal. + * @param terminal The terminal. + * @param colors The colors to use. + */ +export function acquireCharAtlas( + terminal: Terminal, + colors: IColorSet, + scaledCharWidth: number, + scaledCharHeight: number +): BaseCharAtlas { + const newConfig = generateConfig(scaledCharWidth, scaledCharHeight, terminal, colors); + + // Check to see if the terminal already owns this config + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + const ownedByIndex = entry.ownedBy.indexOf(terminal); + if (ownedByIndex >= 0) { + if (configEquals(entry.config, newConfig)) { + return entry.atlas; + } + // The configs differ, release the terminal from the entry + if (entry.ownedBy.length === 1) { + entry.atlas.dispose(); + charAtlasCache.splice(i, 1); + } else { + entry.ownedBy.splice(ownedByIndex, 1); + } + break; + } + } + + // Try match a char atlas from the cache + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + if (configEquals(entry.config, newConfig)) { + // Add the terminal to the cache entry and return + entry.ownedBy.push(terminal); + return entry.atlas; + } + } + + const newEntry: ICharAtlasCacheEntry = { + atlas: new WebglCharAtlas(document, newConfig), + config: newConfig, + ownedBy: [terminal] + }; + charAtlasCache.push(newEntry); + return newEntry.atlas; +} + +/** + * Removes a terminal reference from the cache, allowing its memory to be freed. + * @param terminal The terminal to remove. + */ +export function removeTerminalFromCache(terminal: Terminal): void { + for (let i = 0; i < charAtlasCache.length; i++) { + const index = charAtlasCache[i].ownedBy.indexOf(terminal); + if (index !== -1) { + if (charAtlasCache[i].ownedBy.length === 1) { + // Remove the cache entry if it's the only terminal + charAtlasCache[i].atlas.dispose(); + charAtlasCache.splice(i, 1); + } else { + // Remove the reference from the cache entry + charAtlasCache[i].ownedBy.splice(index, 1); + } + break; + } + } +} diff --git a/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts b/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts new file mode 100644 index 0000000000..1c43dd7005 --- /dev/null +++ b/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ICharAtlasConfig } from './Types'; +import { DEFAULT_COLOR } from 'common/buffer/Constants'; +import { Terminal, FontWeight } from 'xterm'; +import { IColorSet } from 'browser/Types'; + +export function generateConfig(scaledCharWidth: number, scaledCharHeight: number, terminal: Terminal, colors: IColorSet): ICharAtlasConfig { + // null out some fields that don't matter + const clonedColors: IColorSet = { + 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() + }; + return { + devicePixelRatio: window.devicePixelRatio, + scaledCharWidth, + scaledCharHeight, + fontFamily: terminal.getOption('fontFamily'), + fontSize: terminal.getOption('fontSize'), + fontWeight: terminal.getOption('fontWeight') as FontWeight, + fontWeightBold: terminal.getOption('fontWeightBold') as FontWeight, + allowTransparency: terminal.getOption('allowTransparency'), + colors: clonedColors + }; +} + +export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean { + for (let i = 0; i < a.colors.ansi.length; i++) { + if (a.colors.ansi[i].rgba !== b.colors.ansi[i].rgba) { + return false; + } + } + return a.devicePixelRatio === b.devicePixelRatio && + a.fontFamily === b.fontFamily && + a.fontSize === b.fontSize && + a.fontWeight === b.fontWeight && + a.fontWeightBold === b.fontWeightBold && + a.allowTransparency === b.allowTransparency && + a.scaledCharWidth === b.scaledCharWidth && + a.scaledCharHeight === b.scaledCharHeight && + a.colors.foreground === b.colors.foreground && + a.colors.background === b.colors.background; +} + +export function is256Color(colorCode: number): boolean { + return colorCode < DEFAULT_COLOR; +} diff --git a/addons/xterm-addon-webgl/src/atlas/Types.d.ts b/addons/xterm-addon-webgl/src/atlas/Types.d.ts new file mode 100644 index 0000000000..1de843e08a --- /dev/null +++ b/addons/xterm-addon-webgl/src/atlas/Types.d.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { FontWeight } from 'xterm'; +import { IColorSet } from 'browser/Types'; + +export interface IGlyphIdentifier { + chars: string; + code: number; + bg: number; + fg: number; + bold: boolean; + dim: boolean; + italic: boolean; +} + +export interface ICharAtlasConfig { + devicePixelRatio: number; + fontSize: number; + fontFamily: string; + fontWeight: FontWeight; + fontWeightBold: FontWeight; + scaledCharWidth: number; + scaledCharHeight: number; + allowTransparency: boolean; + colors: IColorSet; +} diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts new file mode 100644 index 0000000000..6a941d5522 --- /dev/null +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -0,0 +1,401 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IGlyphIdentifier, ICharAtlasConfig } from './Types'; +import { DIM_OPACITY, INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; +import { BaseCharAtlas } from './BaseCharAtlas'; +import { IRasterizedGlyph, IBoundingBox, IRasterizedGlyphSet } from '../Types'; +import { DEFAULT_COLOR, DEFAULT_ATTR } from 'common/buffer/Constants'; +import { is256Color } from './CharAtlasUtils'; +import { IColor } from 'browser/Types'; +import { FLAGS } from '../Constants'; + +// 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; + +/** + * The amount of the texture to be filled before throwing it away and starting + * again. Since the throw away and individual glyph draws don't cost too much, + * this prevent juggling multiple textures in the GL context. + */ +const TEXTURE_CAPACITY = Math.floor(TEXTURE_HEIGHT * 0.8); + +const TRANSPARENT_COLOR = { + css: 'rgba(0, 0, 0, 0)', + rgba: 0 +}; + +/** + * A shared object which is used to draw nothing for a particular cell. + */ +const NULL_RASTERIZED_GLYPH: IRasterizedGlyph = { + offset: { x: 0, y: 0 }, + texturePosition: { x: 0, y: 0 }, + texturePositionClipSpace: { x: 0, y: 0 }, + size: { x: 0, y: 0 }, + sizeClipSpace: { x: 0, y: 0 } +}; + +const TMP_CANVAS_GLYPH_PADDING = 2; + +export class WebglCharAtlas extends BaseCharAtlas { + private _cacheMap: { [code: number]: IRasterizedGlyphSet } = {}; + private _cacheMapCombined: { [chars: string]: IRasterizedGlyphSet } = {}; + + // The texture that the atlas is drawn to + public cacheCanvas: HTMLCanvasElement; + private _cacheCtx: CanvasRenderingContext2D; + + private _tmpCanvas: HTMLCanvasElement; + // A temporary context that glyphs are drawn to before being transfered to the atlas. + private _tmpCtx: CanvasRenderingContext2D; + + // Since glyphs are expected to be around the same height, the packing + // strategy used it to fill a row with glyphs while keeping track of the + // tallest glyph in the row. Once the row is full a new row is started at + // (0,lastRow+lastRowTallestGlyph). + private _currentRowY: number = 0; + private _currentRowX: number = 0; + private _currentRowHeight: number = 0; + + public hasCanvasChanged = false; + + private _workBoundingBox: IBoundingBox = { top: 0, left: 0, bottom: 0, right: 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}); + + this._tmpCanvas = document.createElement('canvas'); + this._tmpCanvas.width = this._config.scaledCharWidth * 2 + TMP_CANVAS_GLYPH_PADDING * 2; + this._tmpCanvas.height = this._config.scaledCharHeight + TMP_CANVAS_GLYPH_PADDING * 2; + this._tmpCtx = this._tmpCanvas.getContext('2d', {alpha: this._config.allowTransparency}); + + // This is useful for debugging + document.body.appendChild(this.cacheCanvas); + } + + public dispose(): void { + if (this.cacheCanvas.parentElement) { + this.cacheCanvas.parentElement.removeChild(this.cacheCanvas); + } + } + + protected _doWarmUp(): void { + // Pre-fill with ASCII 33-126 + for (let i = 33; i < 126; i++) { + const rasterizedGlyph = this._drawToCache(i, DEFAULT_ATTR, DEFAULT_COLOR, DEFAULT_COLOR); + this._cacheMap[i] = { + [DEFAULT_ATTR]: rasterizedGlyph + }; + } + } + + public beginFrame(): boolean { + if (this._currentRowY > TEXTURE_CAPACITY) { + this._cacheCtx.clearRect(0, 0, TEXTURE_WIDTH, TEXTURE_HEIGHT); + this._cacheMap = {}; + this._currentRowHeight = 0; + this._currentRowX = 0; + this._currentRowY = 0; + this._doWarmUp(); + return true; + } + return false; + } + + public getRasterizedGlyphCombinedChar(chars: string, attr: number, bg: number, fg: number): IRasterizedGlyph { + let rasterizedGlyphSet = this._cacheMapCombined[chars]; + if (!rasterizedGlyphSet) { + rasterizedGlyphSet = {}; + this._cacheMapCombined[chars] = rasterizedGlyphSet; + } + let rasterizedGlyph = rasterizedGlyphSet[attr]; + if (!rasterizedGlyph) { + rasterizedGlyph = this._drawToCache(chars, attr, bg, fg); + rasterizedGlyphSet[attr] = rasterizedGlyph; + } + return rasterizedGlyph; + } + + /** + * Gets the glyphs texture coords, drawing the texture if it's not already + */ + public getRasterizedGlyph(code: number, attr: number, bg: number, fg: number): IRasterizedGlyph { + let rasterizedGlyphSet = this._cacheMap[code]; + if (!rasterizedGlyphSet) { + rasterizedGlyphSet = {}; + this._cacheMap[code] = rasterizedGlyphSet; + } + let rasterizedGlyph = rasterizedGlyphSet[attr]; + if (!rasterizedGlyph) { + rasterizedGlyph = this._drawToCache(code, attr, bg, fg); + rasterizedGlyphSet[attr] = rasterizedGlyph; + } + return rasterizedGlyph; + } + + public draw( + ctx: CanvasRenderingContext2D, + glyph: IGlyphIdentifier, + x: number, + y: number + ): boolean { + throw new Error('WebglCharAtlas is only compatible with the webgl renderer'); + } + + private _getColorFromAnsiIndex(idx: number): IColor { + if (idx >= this._config.colors.ansi.length) { + throw new Error('No color found for idx ' + idx); + } + return this._config.colors.ansi[idx]; + } + + private _getBackgroundColor(bg: number): 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 (bg === INVERTED_DEFAULT_COLOR) { + return this._config.colors.foreground; + } else if (is256Color(bg)) { + return this._getColorFromAnsiIndex(bg); + } + // TODO: Support true color + return this._config.colors.background; + } + + private _getForegroundColor(fg: number): IColor { + if (fg === INVERTED_DEFAULT_COLOR) { + return this._config.colors.background; + } else if (is256Color(fg)) { + return this._getColorFromAnsiIndex(fg); + } + // TODO: Support true color + return this._config.colors.foreground; + } + + private _drawToCache(code: number, attr: number, bg: number, fg: number): IRasterizedGlyph; + private _drawToCache(chars: string, attr: number, bg: number, fg: number): IRasterizedGlyph; + private _drawToCache(codeOrChars: number | string, attr: number, bg: number, fg: number): IRasterizedGlyph { + const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars; + + this.hasCanvasChanged = true; + + const flags = attr >> 18; + + const bold = !!(flags & FLAGS.BOLD); + const dim = !!(flags & FLAGS.DIM); + const italic = !!(flags & FLAGS.ITALIC); + + this._tmpCtx.save(); + + // draw the background + const backgroundColor = this._getBackgroundColor(bg); + // 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._tmpCanvas.width, this._tmpCanvas.height); + this._tmpCtx.globalCompositeOperation = 'source-over'; + + // draw the foreground/glyph + const fontWeight = bold ? this._config.fontWeightBold : this._config.fontWeight; + const fontStyle = 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(fg).css; + + // Apply alpha to dim the character + if (dim) { + this._tmpCtx.globalAlpha = DIM_OPACITY; + } + + // Draw the character + this._tmpCtx.fillText(chars, TMP_CANVAS_GLYPH_PADDING, TMP_CANVAS_GLYPH_PADDING); + 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._tmpCanvas.width, this._tmpCanvas.height + ); + + // TODO: Support transparency + // let isEmpty = false; + // if (!this._config.allowTransparency) { + // isEmpty = clearColor(imageData, backgroundColor); + // } + + // Clear out the background color and determine if the glyph is empty. + const isEmpty = clearColor(imageData, backgroundColor); + + // Handle empty glyphs + if (isEmpty) { + return NULL_RASTERIZED_GLYPH; + } + + const rasterizedGlyph = this._findGlyphBoundingBox(imageData, this._workBoundingBox); + const clippedImageData = this._clipImageData(imageData, this._workBoundingBox); + + // Check if there is enough room in the current row and go to next if needed + if (this._currentRowX + this._config.scaledCharWidth > TEXTURE_WIDTH) { + this._currentRowX = 0; + this._currentRowY += this._currentRowHeight; + this._currentRowHeight = 0; + } + + // Record texture position + rasterizedGlyph.texturePosition.x = this._currentRowX; + rasterizedGlyph.texturePosition.y = this._currentRowY; + rasterizedGlyph.texturePositionClipSpace.x = this._currentRowX / TEXTURE_WIDTH; + rasterizedGlyph.texturePositionClipSpace.y = this._currentRowY / TEXTURE_HEIGHT; + + // Update atlas current row + this._currentRowHeight = Math.max(this._currentRowHeight, rasterizedGlyph.size.y); + this._currentRowX += rasterizedGlyph.size.x; + + // putImageData doesn't do any blending, so it will overwrite any existing cache entry for us + this._cacheCtx.putImageData(clippedImageData, rasterizedGlyph.texturePosition.x, rasterizedGlyph.texturePosition.y); + + return rasterizedGlyph; + } + + /** + * Given an ImageData object, find the bounding box of the non-transparent + * portion of the texture and return an IRasterizedGlyph with these + * dimensions. + * @param imageData The image data to read. + * @param boundingBox An IBoundingBox to put the clipped bounding box values. + */ + private _findGlyphBoundingBox(imageData: ImageData, boundingBox: IBoundingBox): IRasterizedGlyph { + boundingBox.top = 0; + let found = false; + for (let y = 0; y < this._tmpCanvas.height; y++) { + for (let x = 0; x < this._tmpCanvas.width; x++) { + const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; + if (imageData.data[alphaOffset] !== 0) { + boundingBox.top = y; + found = true; + break; + } + } + if (found) { + break; + } + } + boundingBox.left = 0; + found = false; + for (let x = 0; x < this._tmpCanvas.width; x++) { + for (let y = 0; y < this._tmpCanvas.height; y++) { + const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; + if (imageData.data[alphaOffset] !== 0) { + boundingBox.left = x; + found = true; + break; + } + } + if (found) { + break; + } + } + boundingBox.right = this._tmpCanvas.width; + found = false; + for (let x = this._tmpCanvas.width - 1; x >= 0; x--) { + for (let y = 0; y < this._tmpCanvas.height; y++) { + const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; + if (imageData.data[alphaOffset] !== 0) { + boundingBox.right = x; + found = true; + break; + } + } + if (found) { + break; + } + } + boundingBox.bottom = this._tmpCanvas.height; + found = false; + for (let y = this._tmpCanvas.height - 1; y >= 0; y--) { + for (let x = 0; x < this._tmpCanvas.width; x++) { + const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; + if (imageData.data[alphaOffset] !== 0) { + boundingBox.bottom = y; + found = true; + break; + } + } + if (found) { + break; + } + } + return { + texturePosition: { x: 0, y: 0 }, + texturePositionClipSpace: { x: 0, y: 0 }, + size: { + x: boundingBox.right - boundingBox.left + 1, + y: boundingBox.bottom - boundingBox.top + 1 + }, + sizeClipSpace: { + x: (boundingBox.right - boundingBox.left + 1) / TEXTURE_WIDTH, + y: (boundingBox.bottom - boundingBox.top + 1) / TEXTURE_HEIGHT + }, + offset: { + x: -boundingBox.left + TMP_CANVAS_GLYPH_PADDING, + y: -boundingBox.top + TMP_CANVAS_GLYPH_PADDING + } + }; + } + + private _clipImageData(imageData: ImageData, boundingBox: IBoundingBox): ImageData { + const width = boundingBox.right - boundingBox.left + 1; + const height = boundingBox.bottom - boundingBox.top + 1; + const clippedData = new Uint8ClampedArray(width * height * 4); + for (let y = boundingBox.top; y <= boundingBox.bottom; y++) { + for (let x = boundingBox.left; x <= boundingBox.right; x++) { + const oldOffset = y * this._tmpCanvas.width * 4 + x * 4; + const newOffset = (y - boundingBox.top) * width * 4 + (x - boundingBox.left) * 4; + clippedData[newOffset] = imageData.data[oldOffset]; + clippedData[newOffset + 1] = imageData.data[oldOffset + 1]; + clippedData[newOffset + 2] = imageData.data[oldOffset + 2]; + clippedData[newOffset + 3] = imageData.data[oldOffset + 3]; + } + } + return new ImageData(clippedData, width, height); + } +} + +/** + * Makes a partiicular rgb color in an ImageData completely transparent. + * @returns True if the result is "empty", meaning all pixels are fully transparent. + */ +function clearColor(imageData: ImageData, color: IColor): boolean { + let isEmpty = true; + const r = color.rgba >>> 24; + const g = color.rgba >>> 16 & 0xFF; + const b = color.rgba >>> 8 & 0xFF; + for (let offset = 0; offset < imageData.data.length; offset += 4) { + if (imageData.data[offset] === r && + imageData.data[offset + 1] === g && + imageData.data[offset + 2] === b) { + imageData.data[offset + 3] = 0; + } else { + isEmpty = false; + } + } + return isEmpty; +} diff --git a/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts new file mode 100644 index 0000000000..999d94f43c --- /dev/null +++ b/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts @@ -0,0 +1,388 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderLayer } from './Types'; +import { ICellData } from 'common/Types'; +import { DEFAULT_COLOR, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE } from 'common/buffer/Constants'; +import { IGlyphIdentifier } from '../atlas/Types'; +import { DIM_OPACITY, INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; +import { BaseCharAtlas } from '../atlas/BaseCharAtlas'; +import { acquireCharAtlas } from '../atlas/CharAtlasCache'; +import { Terminal } from 'xterm'; +import { IColorSet } from 'browser/Types'; +import { IRenderDimensions } from 'browser/renderer/Types'; +import { CellData } from 'common/buffer/CellData'; +import { AttributeData } from 'common/buffer/AttributeData'; + +export abstract class BaseRenderLayer implements IRenderLayer { + private _canvas: HTMLCanvasElement; + protected _ctx: CanvasRenderingContext2D; + private _scaledCharWidth: number = 0; + private _scaledCharHeight: number = 0; + private _scaledCellWidth: number = 0; + private _scaledCellHeight: number = 0; + private _scaledCharLeft: number = 0; + private _scaledCharTop: number = 0; + + protected _charAtlas: BaseCharAtlas; + + /** + * An object that's reused when drawing glyphs in order to reduce GC. + */ + private _currentGlyphIdentifier: IGlyphIdentifier = { + chars: '', + code: 0, + bg: 0, + fg: 0, + bold: false, + dim: false, + italic: false + }; + + constructor( + private _container: HTMLElement, + id: string, + zIndex: number, + private _alpha: boolean, + protected _colors: IColorSet + ) { + this._canvas = document.createElement('canvas'); + this._canvas.classList.add(`xterm-${id}-layer`); + this._canvas.style.zIndex = zIndex.toString(); + this._initCanvas(); + this._container.appendChild(this._canvas); + } + + public dispose(): void { + this._container.removeChild(this._canvas); + if (this._charAtlas) { + this._charAtlas.dispose(); + } + } + + private _initCanvas(): void { + this._ctx = this._canvas.getContext('2d', {alpha: this._alpha}); + // Draw the background if this is an opaque layer + if (!this._alpha) { + this.clearAll(); + } + } + + public onOptionsChanged(terminal: Terminal): void {} + public onBlur(terminal: Terminal): void {} + public onFocus(terminal: Terminal): void {} + public onCursorMove(terminal: Terminal): void {} + public onGridChanged(terminal: Terminal, startRow: number, endRow: number): void {} + public onSelectionChanged(terminal: Terminal, start: [number, number], end: [number, number], columnSelectMode: boolean = false): void {} + + public setColors(terminal: Terminal, colorSet: IColorSet): void { + this._refreshCharAtlas(terminal, colorSet); + } + + protected setTransparency(terminal: Terminal, alpha: boolean): void { + // Do nothing when alpha doesn't change + if (alpha === this._alpha) { + return; + } + + // Create new canvas and replace old one + const oldCanvas = this._canvas; + this._alpha = alpha; + // Cloning preserves properties + this._canvas = this._canvas.cloneNode(); + this._initCanvas(); + this._container.replaceChild(this._canvas, oldCanvas); + + // Regenerate char atlas and force a full redraw + this._refreshCharAtlas(terminal, this._colors); + this.onGridChanged(terminal, 0, terminal.rows - 1); + } + + /** + * Refreshes the char atlas, aquiring a new one if necessary. + * @param terminal The terminal. + * @param colorSet The color set to use for the char atlas. + */ + private _refreshCharAtlas(terminal: Terminal, colorSet: IColorSet): void { + if (this._scaledCharWidth <= 0 && this._scaledCharHeight <= 0) { + return; + } + this._charAtlas = acquireCharAtlas(terminal, colorSet, this._scaledCharWidth, this._scaledCharHeight); + this._charAtlas.warmUp(); + } + + public resize(terminal: Terminal, dim: IRenderDimensions): void { + this._scaledCellWidth = dim.scaledCellWidth; + this._scaledCellHeight = dim.scaledCellHeight; + this._scaledCharWidth = dim.scaledCharWidth; + this._scaledCharHeight = dim.scaledCharHeight; + this._scaledCharLeft = dim.scaledCharLeft; + this._scaledCharTop = dim.scaledCharTop; + this._canvas.width = dim.scaledCanvasWidth; + this._canvas.height = dim.scaledCanvasHeight; + this._canvas.style.width = `${dim.canvasWidth}px`; + this._canvas.style.height = `${dim.canvasHeight}px`; + + // Draw the background if this is an opaque layer + if (!this._alpha) { + this.clearAll(); + } + + this._refreshCharAtlas(terminal, this._colors); + } + + public abstract reset(terminal: Terminal): void; + + /** + * Fills 1+ cells completely. This uses the existing fillStyle on the context. + * @param x The column to start at. + * @param y The row to start at + * @param width The number of columns to fill. + * @param height The number of rows to fill. + */ + protected fillCells(x: number, y: number, width: number, height: number): void { + this._ctx.fillRect( + x * this._scaledCellWidth, + y * this._scaledCellHeight, + width * this._scaledCellWidth, + height * this._scaledCellHeight); + } + + /** + * Fills a 1px line (2px on HDPI) at the bottom of the cell. This uses the + * existing fillStyle on the context. + * @param x The column to fill. + * @param y The row to fill. + */ + protected fillBottomLineAtCells(x: number, y: number, width: number = 1): void { + this._ctx.fillRect( + x * this._scaledCellWidth, + (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, + width * this._scaledCellWidth, + window.devicePixelRatio); + } + + /** + * Fills a 1px line (2px on HDPI) at the left of the cell. This uses the + * existing fillStyle on the context. + * @param x The column to fill. + * @param y The row to fill. + */ + protected fillLeftLineAtCell(x: number, y: number): void { + this._ctx.fillRect( + x * this._scaledCellWidth, + y * this._scaledCellHeight, + window.devicePixelRatio, + this._scaledCellHeight); + } + + /** + * Strokes a 1px rectangle (2px on HDPI) around a cell. This uses the existing + * strokeStyle on the context. + * @param x The column to fill. + * @param y The row to fill. + */ + protected strokeRectAtCell(x: number, y: number, width: number, height: number): void { + this._ctx.lineWidth = window.devicePixelRatio; + this._ctx.strokeRect( + x * this._scaledCellWidth + window.devicePixelRatio / 2, + y * this._scaledCellHeight + (window.devicePixelRatio / 2), + width * this._scaledCellWidth - window.devicePixelRatio, + (height * this._scaledCellHeight) - window.devicePixelRatio); + } + + /** + * Clears the entire canvas. + */ + protected clearAll(): void { + if (this._alpha) { + this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + } else { + this._ctx.fillStyle = this._colors.background.css; + this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height); + } + } + + /** + * Clears 1+ cells completely. + * @param x The column to start at. + * @param y The row to start at. + * @param width The number of columns to clear. + * @param height The number of rows to clear. + */ + protected clearCells(x: number, y: number, width: number, height: number): void { + if (this._alpha) { + this._ctx.clearRect( + x * this._scaledCellWidth, + y * this._scaledCellHeight, + width * this._scaledCellWidth, + height * this._scaledCellHeight); + } else { + this._ctx.fillStyle = this._colors.background.css; + this._ctx.fillRect( + x * this._scaledCellWidth, + y * this._scaledCellHeight, + width * this._scaledCellWidth, + height * this._scaledCellHeight); + } + } + + /** + * Draws a truecolor character at the cell. The character will be clipped to + * ensure that it fits with the cell, including the cell to the right if it's + * a wide character. This uses the existing fillStyle on the context. + * @param terminal The terminal. + * @param cell The cell data for the character to draw. + * @param x The column to draw at. + * @param y The row to draw at. + * @param color The color of the character. + */ + protected fillCharTrueColor(terminal: Terminal, cell: CellData, x: number, y: number): void { + this._ctx.font = this._getFont(terminal, false, false); + this._ctx.textBaseline = 'middle'; + this._clipRow(terminal, y); + this._ctx.fillText( + cell.getChars(), + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight / 2); + } + + /** + * Draws one or more characters at a cell. If possible this will draw using + * the character atlas to reduce draw time. + * @param terminal The terminal. + * @param chars The character or characters. + * @param code The character code. + * @param width The width of the characters. + * @param x The column to draw at. + * @param y The row to draw at. + * @param fg The foreground color, in the format stored within the attributes. + * @param bg The background color, in the format stored within the attributes. + * This is used to validate whether a cached image can be used. + * @param bold Whether the text is bold. + */ + protected drawChars(terminal: Terminal, cell: ICellData, x: number, y: number): void { + + // skip cache right away if we draw in RGB + // Note: to avoid bad runtime JoinedCellData will be skipped + // in the cache handler itself (atlasDidDraw == false) and + // fall through to uncached later down below + if (cell.isFgRGB() || cell.isBgRGB()) { + this._drawUncachedChars(terminal, cell, x, y); + return; + } + + let fg; + let bg; + if (cell.isInverse()) { + fg = (cell.isBgDefault()) ? INVERTED_DEFAULT_COLOR : cell.getBgColor(); + bg = (cell.isFgDefault()) ? INVERTED_DEFAULT_COLOR : cell.getFgColor(); + } else { + bg = (cell.isBgDefault()) ? DEFAULT_COLOR : cell.getBgColor(); + fg = (cell.isFgDefault()) ? DEFAULT_COLOR : cell.getFgColor(); + } + + const drawInBrightColor = terminal.getOption('drawBoldTextInBrightColors') && cell.isBold() && fg < 8 && fg !== INVERTED_DEFAULT_COLOR; + + fg += drawInBrightColor ? 8 : 0; + this._currentGlyphIdentifier.chars = cell.getChars() || WHITESPACE_CELL_CHAR; + this._currentGlyphIdentifier.code = cell.getCode() || WHITESPACE_CELL_CODE; + this._currentGlyphIdentifier.bg = bg; + this._currentGlyphIdentifier.fg = fg; + this._currentGlyphIdentifier.bold = !!cell.isBold(); + this._currentGlyphIdentifier.dim = !!cell.isDim(); + this._currentGlyphIdentifier.italic = !!cell.isItalic(); + const atlasDidDraw = this._charAtlas && this._charAtlas.draw( + this._ctx, + this._currentGlyphIdentifier, + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop + ); + + if (!atlasDidDraw) { + this._drawUncachedChars(terminal, cell, x, y); + } + } + + /** + * Draws one or more characters at one or more cells. The character(s) will be + * clipped to ensure that they fit with the cell(s), including the cell to the + * right if the last character is a wide character. + * @param terminal The terminal. + * @param chars The character. + * @param width The width of the character. + * @param fg The foreground color, in the format stored within the attributes. + * @param x The column to draw at. + * @param y The row to draw at. + */ + private _drawUncachedChars(terminal: Terminal, cell: ICellData, x: number, y: number): void { + this._ctx.save(); + this._ctx.font = this._getFont(terminal, !!cell.isBold(), !!cell.isItalic()); + this._ctx.textBaseline = 'middle'; + + if (cell.isInverse()) { + if (cell.isBgDefault()) { + this._ctx.fillStyle = this._colors.background.css; + } else if (cell.isBgRGB()) { + this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getBgColor()).join(',')})`; + } else { + this._ctx.fillStyle = this._colors.ansi[cell.getBgColor()].css; + } + } else { + if (cell.isFgDefault()) { + this._ctx.fillStyle = this._colors.foreground.css; + } else if (cell.isFgRGB()) { + this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`; + } else { + let fg = cell.getFgColor(); + if (terminal.getOption('drawBoldTextInBrightColors') && cell.isBold() && fg < 8) { + fg += 8; + } + this._ctx.fillStyle = this._colors.ansi[fg].css; + } + } + + this._clipRow(terminal, y); + + // Apply alpha to dim the character + if (cell.isDim()) { + this._ctx.globalAlpha = DIM_OPACITY; + } + // Draw the character + this._ctx.fillText( + cell.getChars(), + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop + this._scaledCharHeight / 2); + this._ctx.restore(); + } + + /** + * Clips a row to ensure no pixels will be drawn outside the cells in the row. + * @param terminal The terminal. + * @param y The row to clip. + */ + private _clipRow(terminal: Terminal, y: number): void { + this._ctx.beginPath(); + this._ctx.rect( + 0, + y * this._scaledCellHeight, + terminal.cols * this._scaledCellWidth, + this._scaledCellHeight); + this._ctx.clip(); + } + + /** + * Gets the current font. + * @param terminal The terminal. + * @param isBold If we should use the bold fontWeight. + */ + protected _getFont(terminal: Terminal, isBold: boolean, isItalic: boolean): string { + const fontWeight = isBold ? terminal.getOption('fontWeightBold') : terminal.getOption('fontWeight'); + const fontStyle = isItalic ? 'italic' : ''; + + return `${fontStyle} ${fontWeight} ${terminal.getOption('fontSize') * window.devicePixelRatio}px ${terminal.getOption('fontFamily')}`; + } +} + diff --git a/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts new file mode 100644 index 0000000000..b0bf581f33 --- /dev/null +++ b/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts @@ -0,0 +1,360 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal } from 'xterm'; +import { BaseRenderLayer } from './BaseRenderLayer'; +import { ICellData } from 'common/Types'; +import { CellData } from 'common/buffer/CellData'; +import { IColorSet } from 'browser/Types'; +import { IRenderDimensions } from 'browser/renderer/Types'; + +interface ICursorState { + x: number; + y: number; + isFocused: boolean; + style: string; + width: number; +} + +/** + * The time between cursor blinks. + */ +const BLINK_INTERVAL = 600; + +export class CursorRenderLayer extends BaseRenderLayer { + private _state: ICursorState; + private _cursorRenderers: {[key: string]: (terminal: Terminal, x: number, y: number, cell: ICellData) => void}; + private _cursorBlinkStateManager: CursorBlinkStateManager; + private _cell: ICellData = new CellData(); + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { + super(container, 'cursor', zIndex, true, colors); + this._state = { + x: null, + y: null, + isFocused: null, + style: null, + width: null + }; + this._cursorRenderers = { + 'bar': this._renderBarCursor.bind(this), + 'block': this._renderBlockCursor.bind(this), + 'underline': this._renderUnderlineCursor.bind(this) + }; + // TODO: Consider initial options? Maybe onOptionsChanged should be called at the end of open? + } + + public resize(terminal: Terminal, dim: IRenderDimensions): void { + super.resize(terminal, dim); + // Resizing the canvas discards the contents of the canvas so clear state + this._state = { + x: null, + y: null, + isFocused: null, + style: null, + width: null + }; + } + + public reset(terminal: Terminal): void { + this._clearCursor(); + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.dispose(); + this._cursorBlinkStateManager = null; + this.onOptionsChanged(terminal); + } + } + + public onBlur(terminal: Terminal): void { + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.pause(); + } + terminal.refresh(terminal.buffer.cursorY, terminal.buffer.cursorY); + } + + public onFocus(terminal: Terminal): void { + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.resume(terminal); + } else { + terminal.refresh(terminal.buffer.cursorY, terminal.buffer.cursorY); + } + } + + public onOptionsChanged(terminal: Terminal): void { + if (terminal.getOption('cursorBlink')) { + if (!this._cursorBlinkStateManager) { + this._cursorBlinkStateManager = new CursorBlinkStateManager(terminal, () => { + this._render(terminal, true); + }); + } + } else { + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.dispose(); + this._cursorBlinkStateManager = null; + } + // Request a refresh from the terminal as management of rendering is being + // moved back to the terminal + terminal.refresh(terminal.buffer.cursorY, terminal.buffer.cursorY); + } + } + + public onCursorMove(terminal: Terminal): void { + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.restartBlinkAnimation(terminal); + } + } + + public onGridChanged(terminal: Terminal, startRow: number, endRow: number): void { + if (!this._cursorBlinkStateManager || this._cursorBlinkStateManager.isPaused) { + this._render(terminal, false); + } else { + this._cursorBlinkStateManager.restartBlinkAnimation(terminal); + } + } + + private _render(terminal: Terminal, triggeredByAnimationFrame: boolean): void { + // Don't draw the cursor if it's hidden + // TODO: Need to expose API for this + if (!(terminal as any)._core.cursorState || (terminal as any)._core.cursorHidden) { + this._clearCursor(); + return; + } + + const cursorY = terminal.buffer.baseY + terminal.buffer.cursorY; + const viewportRelativeCursorY = cursorY - terminal.buffer.viewportY; + + // Don't draw the cursor if it's off-screen + if (viewportRelativeCursorY < 0 || viewportRelativeCursorY >= terminal.rows) { + this._clearCursor(); + return; + } + + // TODO: Need fast buffer API for loading cell + (terminal as any)._core.buffer.lines.get(cursorY).loadCell(terminal.buffer.cursorX, this._cell); + if (this._cell.content === undefined) { + return; + } + + if (!isTerminalFocused(terminal)) { + this._clearCursor(); + this._ctx.save(); + this._ctx.fillStyle = this._colors.cursor.css; + this._renderBlurCursor(terminal, terminal.buffer.cursorX, viewportRelativeCursorY, this._cell); + this._ctx.restore(); + this._state.x = terminal.buffer.cursorX; + this._state.y = viewportRelativeCursorY; + this._state.isFocused = false; + this._state.style = terminal.getOption('cursorStyle'); + this._state.width = this._cell.getWidth(); + return; + } + + // Don't draw the cursor if it's blinking + if (this._cursorBlinkStateManager && !this._cursorBlinkStateManager.isCursorVisible) { + this._clearCursor(); + return; + } + + if (this._state) { + // The cursor is already in the correct spot, don't redraw + if (this._state.x === terminal.buffer.cursorX && + this._state.y === viewportRelativeCursorY && + this._state.isFocused === isTerminalFocused(terminal) && + this._state.style === terminal.getOption('cursorStyle') && + this._state.width === this._cell.getWidth()) { + return; + } + this._clearCursor(); + } + + this._ctx.save(); + this._cursorRenderers[terminal.getOption('cursorStyle') || 'block'](terminal, terminal.buffer.cursorX, viewportRelativeCursorY, this._cell); + this._ctx.restore(); + + this._state.x = terminal.buffer.cursorX; + this._state.y = viewportRelativeCursorY; + this._state.isFocused = false; + this._state.style = terminal.getOption('cursorStyle'); + this._state.width = this._cell.getWidth(); + } + + private _clearCursor(): void { + if (this._state) { + this.clearCells(this._state.x, this._state.y, this._state.width, 1); + this._state = { + x: null, + y: null, + isFocused: null, + style: null, + width: null + }; + } + } + + private _renderBarCursor(terminal: Terminal, x: number, y: number, cell: ICellData): void { + this._ctx.save(); + this._ctx.fillStyle = this._colors.cursor.css; + this.fillLeftLineAtCell(x, y); + this._ctx.restore(); + } + + private _renderBlockCursor(terminal: Terminal, x: number, y: number, cell: ICellData): void { + this._ctx.save(); + this._ctx.fillStyle = this._colors.cursor.css; + this.fillCells(x, y, cell.getWidth(), 1); + this._ctx.fillStyle = this._colors.cursorAccent.css; + this.fillCharTrueColor(terminal, cell, x, y); + this._ctx.restore(); + } + + private _renderUnderlineCursor(terminal: Terminal, x: number, y: number, cell: ICellData): void { + this._ctx.save(); + this._ctx.fillStyle = this._colors.cursor.css; + this.fillBottomLineAtCells(x, y); + this._ctx.restore(); + } + + private _renderBlurCursor(terminal: Terminal, x: number, y: number, cell: ICellData): void { + this._ctx.save(); + this._ctx.strokeStyle = this._colors.cursor.css; + this.strokeRectAtCell(x, y, cell.getWidth(), 1); + this._ctx.restore(); + } +} + +class CursorBlinkStateManager { + public isCursorVisible: boolean; + + private _animationFrame: number; + private _blinkStartTimeout: number; + private _blinkInterval: number; + + /** + * The time at which the animation frame was restarted, this is used on the + * next render to restart the timers so they don't need to restart the timers + * multiple times over a short period. + */ + private _animationTimeRestarted: number; + + constructor( + terminal: Terminal, + private _renderCallback: () => void + ) { + this.isCursorVisible = true; + if (isTerminalFocused(terminal)) { + this._restartInterval(); + } + } + + public get isPaused(): boolean { return !(this._blinkStartTimeout || this._blinkInterval); } + + public dispose(): void { + if (this._blinkInterval) { + window.clearInterval(this._blinkInterval); + this._blinkInterval = null; + } + if (this._blinkStartTimeout) { + window.clearTimeout(this._blinkStartTimeout); + this._blinkStartTimeout = null; + } + if (this._animationFrame) { + window.cancelAnimationFrame(this._animationFrame); + this._animationFrame = null; + } + } + + public restartBlinkAnimation(terminal: Terminal): void { + if (this.isPaused) { + return; + } + // Save a timestamp so that the restart can be done on the next interval + this._animationTimeRestarted = Date.now(); + // Force a cursor render to ensure it's visible and in the correct position + this.isCursorVisible = true; + if (!this._animationFrame) { + this._animationFrame = window.requestAnimationFrame(() => { + this._renderCallback(); + this._animationFrame = null; + }); + } + } + + private _restartInterval(timeToStart: number = BLINK_INTERVAL): void { + // Clear any existing interval + if (this._blinkInterval) { + window.clearInterval(this._blinkInterval); + } + + // Setup the initial timeout which will hide the cursor, this is done before + // the regular interval is setup in order to support restarting the blink + // animation in a lightweight way (without thrashing clearInterval and + // setInterval). + this._blinkStartTimeout = setTimeout(() => { + // Check if another animation restart was requested while this was being + // started + if (this._animationTimeRestarted) { + const time = BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted); + this._animationTimeRestarted = null; + if (time > 0) { + this._restartInterval(time); + return; + } + } + + // Hide the cursor + this.isCursorVisible = false; + this._animationFrame = window.requestAnimationFrame(() => { + this._renderCallback(); + this._animationFrame = null; + }); + + // Setup the blink interval + this._blinkInterval = setInterval(() => { + // Adjust the animation time if it was restarted + if (this._animationTimeRestarted) { + // calc time diff + // Make restart interval do a setTimeout initially? + const time = BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted); + this._animationTimeRestarted = null; + this._restartInterval(time); + return; + } + + // Invert visibility and render + this.isCursorVisible = !this.isCursorVisible; + this._animationFrame = window.requestAnimationFrame(() => { + this._renderCallback(); + this._animationFrame = null; + }); + }, BLINK_INTERVAL); + }, timeToStart); + } + + public pause(): void { + this.isCursorVisible = true; + if (this._blinkInterval) { + window.clearInterval(this._blinkInterval); + this._blinkInterval = null; + } + if (this._blinkStartTimeout) { + window.clearTimeout(this._blinkStartTimeout); + this._blinkStartTimeout = null; + } + if (this._animationFrame) { + window.cancelAnimationFrame(this._animationFrame); + this._animationFrame = null; + } + } + + public resume(terminal: Terminal): void { + this._animationTimeRestarted = null; + this._restartInterval(); + this.restartBlinkAnimation(terminal); + } +} + +function isTerminalFocused(terminal: Terminal): boolean { + return document.activeElement === terminal.textarea && document.hasFocus(); +} diff --git a/addons/xterm-addon-webgl/src/renderLayer/LinkRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/LinkRenderLayer.ts new file mode 100644 index 0000000000..d37f264082 --- /dev/null +++ b/addons/xterm-addon-webgl/src/renderLayer/LinkRenderLayer.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ILinkifierEvent, ILinkifierAccessor } from '../../../../src/Types'; +import { Terminal } from 'xterm'; +import { BaseRenderLayer } from './BaseRenderLayer'; +import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; +import { is256Color } from '../atlas/CharAtlasUtils'; +import { IColorSet } from 'browser/Types'; +import { IRenderDimensions } from 'browser/renderer/Types'; + +export class LinkRenderLayer extends BaseRenderLayer { + private _state: ILinkifierEvent = null; + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet, terminal: ILinkifierAccessor) { + super(container, 'link', zIndex, true, colors); + terminal.linkifier.onLinkHover(e => this._onLinkHover(e)); + terminal.linkifier.onLinkLeave(e => this._onLinkLeave(e)); + } + + public resize(terminal: Terminal, dim: IRenderDimensions): void { + super.resize(terminal, dim); + // Resizing the canvas discards the contents of the canvas so clear state + this._state = null; + } + + public reset(terminal: Terminal): void { + this._clearCurrentLink(); + } + + private _clearCurrentLink(): void { + if (this._state) { + this.clearCells(this._state.x1, this._state.y1, this._state.cols - this._state.x1, 1); + const middleRowCount = this._state.y2 - this._state.y1 - 1; + if (middleRowCount > 0) { + this.clearCells(0, this._state.y1 + 1, this._state.cols, middleRowCount); + } + this.clearCells(0, this._state.y2, this._state.x2, 1); + this._state = null; + } + } + + private _onLinkHover(e: ILinkifierEvent): void { + if (e.fg === INVERTED_DEFAULT_COLOR) { + this._ctx.fillStyle = this._colors.background.css; + } else if (is256Color(e.fg)) { + // 256 color support + this._ctx.fillStyle = this._colors.ansi[e.fg].css; + } else { + this._ctx.fillStyle = this._colors.foreground.css; + } + + if (e.y1 === e.y2) { + // Single line link + this.fillBottomLineAtCells(e.x1, e.y1, e.x2 - e.x1); + } else { + // Multi-line link + this.fillBottomLineAtCells(e.x1, e.y1, e.cols - e.x1); + for (let y = e.y1 + 1; y < e.y2; y++) { + this.fillBottomLineAtCells(0, y, e.cols); + } + this.fillBottomLineAtCells(0, e.y2, e.x2); + } + this._state = e; + } + + private _onLinkLeave(e: ILinkifierEvent): void { + this._clearCurrentLink(); + } +} diff --git a/addons/xterm-addon-webgl/src/renderLayer/Types.ts b/addons/xterm-addon-webgl/src/renderLayer/Types.ts new file mode 100644 index 0000000000..148ea46958 --- /dev/null +++ b/addons/xterm-addon-webgl/src/renderLayer/Types.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable, Terminal } from 'xterm'; +import { IColorSet } from 'browser/Types'; +import { IRenderDimensions } from 'browser/renderer/Types'; + +export interface IRenderLayer extends IDisposable { + /** + * Called when the terminal loses focus. + */ + onBlur(terminal: Terminal): void; + + /** + * * Called when the terminal gets focus. + */ + onFocus(terminal: Terminal): void; + + /** + * Called when the cursor is moved. + */ + onCursorMove(terminal: Terminal): void; + + /** + * Called when options change. + */ + onOptionsChanged(terminal: Terminal): void; + + /** + * Called when the theme changes. + */ + setColors(terminal: Terminal, colorSet: IColorSet): void; + + /** + * Called when the data in the grid has changed (or needs to be rendered + * again). + */ + onGridChanged(terminal: Terminal, startRow: number, endRow: number): void; + + /** + * Calls when the selection changes. + */ + onSelectionChanged(terminal: Terminal, start: [number, number], end: [number, number], columnSelectMode: boolean): void; + + /** + * Registers a handler to join characters to render as a group + */ + registerCharacterJoiner?(handler: (text: string) => [number, number][]): void; + + /** + * Deregisters the specified character joiner handler + */ + deregisterCharacterJoiner?(joinerId: number): void; + + /** + * Resize the render layer. + */ + resize(terminal: Terminal, dim: IRenderDimensions): void; + + /** + * Clear the state of the render layer. + */ + reset(terminal: Terminal): void; +} diff --git a/addons/xterm-addon-webgl/src/tsconfig.json b/addons/xterm-addon-webgl/src/tsconfig.json new file mode 100644 index 0000000000..34149159ba --- /dev/null +++ b/addons/xterm-addon-webgl/src/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "lib": [ + "dom", + "es6", + ], + "rootDir": ".", + "outDir": "../out", + "sourceMap": true, + "removeComments": true, + "baseUrl": ".", + "paths": { + "common/*": [ "../../../src/common/*" ], + "browser/*": [ "../../../src/browser/*" ] + } + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { "path": "../../../src/common" }, + { "path": "../../../src/browser" } + ] +} diff --git a/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts b/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts new file mode 100644 index 0000000000..5199a2609c --- /dev/null +++ b/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, IDisposable, ITerminalAddon } from 'xterm'; + +declare module 'xterm-addon-webgl' { + /** + * An xterm.js addon that provides search functionality. + */ + export class WebglAddon implements ITerminalAddon { + constructor(preserveDrawingBuffer?: boolean); + + /** + * Activates the addon + * @param terminal The terminal the addon is being loaded in. + */ + public activate(terminal: Terminal): void; + + /** + * Disposes the addon. + */ + public dispose(): void; + } +} diff --git a/addons/xterm-addon-webgl/webpack.config.js b/addons/xterm-addon-webgl/webpack.config.js new file mode 100644 index 0000000000..578b110263 --- /dev/null +++ b/addons/xterm-addon-webgl/webpack.config.js @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const path = require('path'); + +const addonName = 'WebglAddon'; +const mainFile = 'xterm-addon-webgl.js'; + +module.exports = { + entry: `./out/${addonName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + resolve: { + modules: ['./node_modules'], + extensions: [ '.js' ], + alias: { + common: path.resolve('../../out/common'), + browser: path.resolve('../../out/browser') + } + }, + output: { + filename: mainFile, + path: path.resolve('./lib'), + library: addonName, + libraryTarget: 'umd' + }, + mode: 'production' +}; diff --git a/bin/publish.js b/bin/publish.js index e03fa6abb1..0610d116f0 100644 --- a/bin/publish.js +++ b/bin/publish.js @@ -27,7 +27,8 @@ const addonPackageDirs = [ path.resolve(__dirname, '../addons/xterm-addon-attach'), path.resolve(__dirname, '../addons/xterm-addon-fit'), path.resolve(__dirname, '../addons/xterm-addon-search'), - path.resolve(__dirname, '../addons/xterm-addon-web-links') + path.resolve(__dirname, '../addons/xterm-addon-web-links'), + path.resolve(__dirname, '../addons/xterm-addon-webgl') ]; addonPackageDirs.forEach(p => { const addon = path.basename(p); diff --git a/demo/client.ts b/demo/client.ts index e0d55d57a2..005d171d04 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -13,6 +13,7 @@ import { AttachAddon } from '../addons/xterm-addon-attach/out/AttachAddon'; import { FitAddon } from '../addons/xterm-addon-fit/out/FitAddon'; import { SearchAddon, ISearchOptions } from '../addons/xterm-addon-search/out/SearchAddon'; import { WebLinksAddon } from '../addons/xterm-addon-web-links/out/WebLinksAddon'; +import { WebglAddon } from '../addons/xterm-addon-webgl/out/WebglAddon'; // Use webpacked version (yarn package) // import { Terminal } from '../lib/xterm'; @@ -20,6 +21,7 @@ import { WebLinksAddon } from '../addons/xterm-addon-web-links/out/WebLinksAddon // import { FitAddon } from 'xterm-addon-fit'; // import { SearchAddon, ISearchOptions } from 'xterm-addon-search'; // import { WebLinksAddon } from 'xterm-addon-web-links'; +// import { WebglAddon } from 'xterm-addon-webgl'; // Pulling in the module's types relies on the above, it's looks a // little weird here as we're importing "this" module @@ -32,6 +34,7 @@ export interface IWindowWithTerminal extends Window { FitAddon?: typeof FitAddon; SearchAddon?: typeof SearchAddon; WebLinksAddon?: typeof WebLinksAddon; + WebglAddon?: typeof WebglAddon; } declare let window: IWindowWithTerminal; @@ -84,9 +87,11 @@ if (document.location.pathname === '/test') { window.FitAddon = FitAddon; window.SearchAddon = SearchAddon; window.WebLinksAddon = WebLinksAddon; + window.WebglAddon = WebglAddon; } else { createTerminal(); document.getElementById('dispose').addEventListener('click', disposeRecreateButtonHandler); + document.getElementById('webgl').addEventListener('click', () => term.loadAddon(new WebglAddon())); } function createTerminal(): void { diff --git a/demo/index.html b/demo/index.html index 7a939da517..ef8f3891c7 100644 --- a/demo/index.html +++ b/demo/index.html @@ -36,6 +36,7 @@

Style


Attention: The demo is a barebones implementation and is designed for the development and evaluation of xterm.js only. Exposing the demo to the public as is would introduce security risks for the host.

+ diff --git a/package.json b/package.json index d291f14715..ca803391e8 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "prepare": "npm run build", "prepublishOnly": "npm run package", "watch": "tsc -b -w ./tsconfig.all.json --preserveWatchOutput", - "clean": "rm -rf lib out addons/*/lib" + "clean": "rm -rf lib out addons/*/lib addons/*/out" }, "devDependencies": { "@types/chai": "^3.4.34", diff --git a/tsconfig.all.json b/tsconfig.all.json index d2670811ac..06efafcbc9 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -7,6 +7,7 @@ { "path": "./addons/xterm-addon-attach/src" }, { "path": "./addons/xterm-addon-fit/src" }, { "path": "./addons/xterm-addon-search/src" }, - { "path": "./addons/xterm-addon-web-links/src" } + { "path": "./addons/xterm-addon-web-links/src" }, + { "path": "./addons/xterm-addon-webgl/src" } ] }