diff --git a/.gitignore b/.gitignore index cd350bb8..e386a725 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ package-lock.json # Keep bundled code out of Git dist/ demo/dist/ + +# dont commit benchmark folders +.benchmark/ +timeline/ diff --git a/README.md b/README.md index 4707f195..fb6883f7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Xterm.js is a front-end component written in TypeScript that lets applications b ## Features - **Terminal apps just work**: Xterm.js works with most terminal apps such as `bash`, `vim` and `tmux`, this includes support for curses-based apps and mouse event support. -- **Perfomant**: Xterm.js is *really* fast, it even includes a GPU-accelerated renderer. +- **Performant**: Xterm.js is *really* fast, it even includes a GPU-accelerated renderer. - **Rich unicode support**: Supports CJK, emojis and IMEs. - **Self-contained**: Requires zero dependencies to work. - **Accessible**: Screen reader support can be turned on using the `screenReaderMode` option. diff --git a/addons/xterm-addon-webgl/.gitignore b/addons/xterm-addon-webgl/.gitignore new file mode 100644 index 00000000..a9f4ed54 --- /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 00000000..1c794445 --- /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 00000000..b9dc26fe --- /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 00000000..e4f34147 --- /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 00000000..13613c88 --- /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 00000000..80372e65 --- /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 00000000..ab18b97a --- /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 00000000..dc375348 --- /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 00000000..d4ce3b4c --- /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 00000000..a9ee24e9 --- /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 00000000..f18c6774 --- /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 00000000..ee93be35 --- /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 00000000..6ebc8d64 --- /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 00000000..0074f9b2 --- /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 00000000..b26790c4 --- /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 00000000..defed962 --- /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 00000000..8f166a23 --- /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 00000000..470736f1 --- /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 00000000..647b96b4 --- /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 00000000..1c43dd70 --- /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 00000000..1de843e0 --- /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 00000000..6a941d55 --- /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 00000000..999d94f4 --- /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 00000000..b0bf581f --- /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 00000000..d37f2640 --- /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 00000000..148ea469 --- /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 00000000..34149159 --- /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 00000000..5199a260 --- /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 00000000..578b1102 --- /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 e03fa6ab..0610d116 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 e0d55d57..e2259841 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 { @@ -230,7 +235,8 @@ function initOptions(term: TerminalType): void { fontFamily: null, fontWeight: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], fontWeightBold: ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'], - rendererType: ['dom', 'canvas'] + rendererType: ['dom', 'canvas'], + wordSeparator: null }; const options = Object.keys((term)._core.options); const booleanOptions = []; diff --git a/demo/index.html b/demo/index.html index 7a939da5..ef8f3891 100644 --- a/demo/index.html +++ b/demo/index.html @@ -36,6 +36,7 @@

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 d291f147..f26c010f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,10 @@ "prepare": "npm run build", "prepublishOnly": "npm run package", "watch": "tsc -b -w ./tsconfig.all.json --preserveWatchOutput", - "clean": "rm -rf lib out addons/*/lib" + "benchmark": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json", + "benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/test/benchmark/*benchmark.js", + "benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/test/benchmark/*benchmark.js", + "clean": "rm -rf lib out addons/*/lib addons/*/out" }, "devDependencies": { "@types/chai": "^3.4.34", @@ -48,6 +51,7 @@ "utf8": "^3.0.0", "webpack": "^4.17.1", "webpack-cli": "^3.1.0", - "ws": "^7.0.0" + "ws": "^7.0.0", + "xterm-benchmark": "^0.1.3" } } diff --git a/src/Clipboard.ts b/src/Clipboard.ts index 9461afa1..1ee232ee 100644 --- a/src/Clipboard.ts +++ b/src/Clipboard.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { ITerminal, ISelectionManager } from './Types'; +import { ISelectionManager } from 'browser/selection/Types'; /** * Prepares text to be pasted into the terminal by normalizing the line endings @@ -28,7 +28,7 @@ export function bracketTextForPaste(text: string, bracketedPasteMode: boolean): * Binds copy functionality to the given terminal. * @param ev The original copy event to be handled */ -export function copyHandler(ev: ClipboardEvent, term: ITerminal, selectionManager: ISelectionManager): void { +export function copyHandler(ev: ClipboardEvent, selectionManager: ISelectionManager): void { ev.clipboardData.setData('text/plain', selectionManager.selectionText); // Prevent or the original text will be copied. ev.preventDefault(); @@ -39,17 +39,16 @@ export function copyHandler(ev: ClipboardEvent, term: ITerminal, selectionManage * @param ev The original paste event to be handled * @param term The terminal on which to apply the handled paste event */ -export function pasteHandler(ev: ClipboardEvent, term: ITerminal): void { +export function pasteHandler(ev: ClipboardEvent, textarea: HTMLTextAreaElement, bracketedPasteMode: boolean, triggerUserInput: (data: string) => void): void { ev.stopPropagation(); let text: string; const dispatchPaste = function(text: string): void { text = prepareTextForTerminal(text); - text = bracketTextForPaste(text, term.bracketedPasteMode); - term.handler(text); - term.textarea.value = ''; - term.cancel(ev); + text = bracketTextForPaste(text, bracketedPasteMode); + triggerUserInput(text); + textarea.value = ''; }; if (ev.clipboardData) { @@ -63,32 +62,32 @@ export function pasteHandler(ev: ClipboardEvent, term: ITerminal): void { * @param ev The original right click event to be handled. * @param textarea The terminal's textarea. */ -export function moveTextAreaUnderMouseCursor(ev: MouseEvent, term: ITerminal): void { +export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextAreaElement, screenElement: HTMLElement): void { // Calculate textarea position relative to the screen element - const pos = term.screenElement.getBoundingClientRect(); + const pos = screenElement.getBoundingClientRect(); const left = ev.clientX - pos.left - 10; const top = ev.clientY - pos.top - 10; // Bring textarea at the cursor position - term.textarea.style.position = 'absolute'; - term.textarea.style.width = '20px'; - term.textarea.style.height = '20px'; - term.textarea.style.left = `${left}px`; - term.textarea.style.top = `${top}px`; - term.textarea.style.zIndex = '1000'; + textarea.style.position = 'absolute'; + textarea.style.width = '20px'; + textarea.style.height = '20px'; + textarea.style.left = `${left}px`; + textarea.style.top = `${top}px`; + textarea.style.zIndex = '1000'; - term.textarea.focus(); + textarea.focus(); // Reset the terminal textarea's styling // Timeout needs to be long enough for click event to be handled. setTimeout(() => { - term.textarea.style.position = null; - term.textarea.style.width = null; - term.textarea.style.height = null; - term.textarea.style.left = null; - term.textarea.style.top = null; - term.textarea.style.zIndex = null; + textarea.style.position = null; + textarea.style.width = null; + textarea.style.height = null; + textarea.style.left = null; + textarea.style.top = null; + textarea.style.zIndex = null; }, 200); } @@ -99,14 +98,14 @@ export function moveTextAreaUnderMouseCursor(ev: MouseEvent, term: ITerminal): v * @param selectionManager The terminal's selection manager. * @param shouldSelectWord If true and there is no selection the current word will be selected */ -export function rightClickHandler(ev: MouseEvent, term: ITerminal, selectionManager: ISelectionManager, shouldSelectWord: boolean): void { - moveTextAreaUnderMouseCursor(ev, term); +export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, screenElement: HTMLElement, selectionManager: ISelectionManager, shouldSelectWord: boolean): void { + moveTextAreaUnderMouseCursor(ev, textarea, screenElement); if (shouldSelectWord && !selectionManager.isClickInSelection(ev)) { selectionManager.selectWordAtCursor(ev); } // Get textarea ready to copy from the context menu - term.textarea.value = selectionManager.selectionText; - term.textarea.select(); + textarea.value = selectionManager.selectionText; + textarea.select(); } diff --git a/src/CompositionHelper.test.ts b/src/CompositionHelper.test.ts index 2d28f55d..64207fa0 100644 --- a/src/CompositionHelper.test.ts +++ b/src/CompositionHelper.test.ts @@ -7,6 +7,7 @@ import { assert } from 'chai'; import { CompositionHelper } from './CompositionHelper'; import { ITerminal } from './Types'; import { MockCharSizeService } from 'browser/TestUtils.test'; +import { MockCoreService } from '../out/common/TestUtils.test'; describe('CompositionHelper', () => { let terminal: ITerminal; @@ -43,9 +44,6 @@ describe('CompositionHelper', () => { return { offsetLeft: 0, offsetTop: 0 }; } }, - handler: (text: string) => { - handledText += text; - }, buffer: { isCursorInViewport: true }, @@ -53,8 +51,12 @@ describe('CompositionHelper', () => { lineHeight: 1 } } as any; + const coreService = new MockCoreService(); + coreService.triggerDataEvent = (text: string) => { + handledText += text; + }; handledText = ''; - compositionHelper = new CompositionHelper(textarea, compositionView, terminal, new MockCharSizeService(10, 10)); + compositionHelper = new CompositionHelper(textarea, compositionView, terminal, new MockCharSizeService(10, 10), coreService); }); describe('Input', () => { diff --git a/src/CompositionHelper.ts b/src/CompositionHelper.ts index 2b2d3042..010585f8 100644 --- a/src/CompositionHelper.ts +++ b/src/CompositionHelper.ts @@ -5,6 +5,7 @@ import { ITerminal } from './Types'; import { ICharSizeService } from 'browser/services/Services'; +import { ICoreService } from 'common/services/Services'; interface IPosition { start: number; @@ -41,10 +42,11 @@ export class CompositionHelper { * @param _terminal The Terminal to forward the finished composition to. */ constructor( - private _textarea: HTMLTextAreaElement, - private _compositionView: HTMLElement, - private _terminal: ITerminal, - private _charSizeService: ICharSizeService + private readonly _textarea: HTMLTextAreaElement, + private readonly _compositionView: HTMLElement, + private readonly _terminal: ITerminal, + private readonly _charSizeService: ICharSizeService, + private readonly _coreService: ICoreService ) { this._isComposing = false; this._isSendingComposition = false; @@ -127,7 +129,7 @@ export class CompositionHelper { // Cancel any delayed composition send requests and send the input immediately. this._isSendingComposition = false; const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end); - this._terminal.handler(input); + this._coreService.triggerDataEvent(input, true); } else { // Make a deep copy of the composition position here as a new compositionstart event may // fire before the setTimeout executes. @@ -159,7 +161,7 @@ export class CompositionHelper { // (eg. 2) after a composition character. input = this._textarea.value.substring(currentCompositionPosition.start); } - this._terminal.handler(input); + this._coreService.triggerDataEvent(input, true); } }, 0); } @@ -179,7 +181,7 @@ export class CompositionHelper { const newValue = this._textarea.value; const diff = newValue.replace(oldValue, ''); if (diff.length > 0) { - this._terminal.handler(diff); + this._coreService.triggerDataEvent(diff, true); } } }, 0); diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index ef9bdde4..b8f94d5b 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -13,6 +13,7 @@ import { CellData } from 'common/buffer/CellData'; import { Attributes } from 'common/buffer/Constants'; import { AttributeData } from 'common/buffer/AttributeData'; import { Params } from 'common/parser/Params'; +import { MockCoreService } from 'common/TestUtils.test'; describe('InputHandler', () => { describe('save and restore cursor', () => { @@ -21,7 +22,7 @@ describe('InputHandler', () => { terminal.buffer.y = 2; terminal.buffer.ybase = 0; terminal.curAttrData.fg = 3; - const inputHandler = new InputHandler(terminal); + const inputHandler = new InputHandler(terminal, new MockCoreService()); // Save cursor position inputHandler.saveCursor(); assert.equal(terminal.buffer.x, 1); @@ -40,7 +41,7 @@ describe('InputHandler', () => { describe('setCursorStyle', () => { it('should call Terminal.setOption with correct params', () => { const terminal = new MockInputHandlingTerminal(); - const inputHandler = new InputHandler(terminal); + const inputHandler = new InputHandler(terminal, new MockCoreService()); const collect = ' '; inputHandler.setCursorStyle(Params.fromArray([0]), collect); @@ -83,7 +84,7 @@ describe('InputHandler', () => { const terminal = new MockInputHandlingTerminal(); const collect = '?'; terminal.bracketedPasteMode = false; - const inputHandler = new InputHandler(terminal); + const inputHandler = new InputHandler(terminal, new MockCoreService()); // Set bracketed paste mode inputHandler.setMode(Params.fromArray([2004]), collect); assert.equal(terminal.bracketedPasteMode, true); @@ -101,7 +102,7 @@ describe('InputHandler', () => { it('insertChars', function(): void { const term = new Terminal(); - const inputHandler = new InputHandler(term); + const inputHandler = new InputHandler(term, new MockCoreService()); // insert some data in first and second line inputHandler.parse(Array(term.cols - 9).join('a')); @@ -138,7 +139,7 @@ describe('InputHandler', () => { }); it('deleteChars', function(): void { const term = new Terminal(); - const inputHandler = new InputHandler(term); + const inputHandler = new InputHandler(term, new MockCoreService()); // insert some data in first and second line inputHandler.parse(Array(term.cols - 9).join('a')); @@ -178,7 +179,7 @@ describe('InputHandler', () => { }); it('eraseInLine', function(): void { const term = new Terminal(); - const inputHandler = new InputHandler(term); + const inputHandler = new InputHandler(term, new MockCoreService()); // fill 6 lines to test 3 different states inputHandler.parse(Array(term.cols + 1).join('a')); @@ -206,7 +207,7 @@ describe('InputHandler', () => { }); it('eraseInDisplay', function(): void { const term = new Terminal({cols: 80, rows: 7}); - const inputHandler = new InputHandler(term); + const inputHandler = new InputHandler(term, new MockCoreService()); // fill display with a's for (let i = 0; i < term.rows; ++i) inputHandler.parse(Array(term.cols + 1).join('a')); @@ -341,7 +342,7 @@ describe('InputHandler', () => { describe('print', () => { it('should not cause an infinite loop (regression test)', () => { const term = new Terminal(); - const inputHandler = new InputHandler(term); + const inputHandler = new InputHandler(term, new MockCoreService()); const container = new Uint32Array(10); container[0] = 0x200B; inputHandler.print(container, 0, 1); @@ -354,7 +355,7 @@ describe('InputHandler', () => { beforeEach(() => { term = new Terminal(); - handler = new InputHandler(term); + handler = new InputHandler(term, new MockCoreService()); }); it('should handle DECSET/DECRST 47 (alt screen buffer)', () => { handler.parse('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST'); diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 7698c30e..68dfc3d5 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -20,6 +20,7 @@ import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags } from 'c import { CellData } from 'common/buffer/CellData'; import { AttributeData } from 'common/buffer/AttributeData'; import { IAttributeData } from 'common/Types'; +import { ICoreService } from 'common/services/Services'; /** * Map collect to glevel. Used in `selectCharset`. @@ -114,8 +115,6 @@ export class InputHandler extends Disposable implements IInputHandler { private _onCursorMove = new EventEmitter(); public get onCursorMove(): IEvent { return this._onCursorMove.event; } - private _onData = new EventEmitter(); - public get onData(): IEvent { return this._onData.event; } private _onLineFeed = new EventEmitter(); public get onLineFeed(): IEvent { return this._onLineFeed.event; } private _onScroll = new EventEmitter(); @@ -123,6 +122,7 @@ export class InputHandler extends Disposable implements IInputHandler { constructor( protected _terminal: IInputHandlingTerminal, + private _coreService: ICoreService, private _parser: IEscapeSequenceParser = new EscapeSequenceParser()) { super(); @@ -1061,24 +1061,24 @@ export class InputHandler extends Disposable implements IInputHandler { if (!collect) { if (this._terminal.is('xterm') || this._terminal.is('rxvt-unicode') || this._terminal.is('screen')) { - this._terminal.handler(C0.ESC + '[?1;2c'); + this._coreService.triggerDataEvent(C0.ESC + '[?1;2c'); } else if (this._terminal.is('linux')) { - this._terminal.handler(C0.ESC + '[?6c'); + this._coreService.triggerDataEvent(C0.ESC + '[?6c'); } } else if (collect === '>') { // xterm and urxvt // seem to spit this // out around ~370 times (?). if (this._terminal.is('xterm')) { - this._terminal.handler(C0.ESC + '[>0;276;0c'); + this._coreService.triggerDataEvent(C0.ESC + '[>0;276;0c'); } else if (this._terminal.is('rxvt-unicode')) { - this._terminal.handler(C0.ESC + '[>85;95;0c'); + this._coreService.triggerDataEvent(C0.ESC + '[>85;95;0c'); } else if (this._terminal.is('linux')) { // not supported by linux console. // linux console echoes parameters. - this._terminal.handler(params.params[0] + 'c'); + this._coreService.triggerDataEvent(params.params[0] + 'c'); } else if (this._terminal.is('screen')) { - this._terminal.handler(C0.ESC + '[>83;40003;0c'); + this._coreService.triggerDataEvent(C0.ESC + '[>83;40003;0c'); } } } @@ -1799,13 +1799,13 @@ export class InputHandler extends Disposable implements IInputHandler { switch (params.params[0]) { case 5: // status report - this._onData.fire(`${C0.ESC}[0n`); + this._coreService.triggerDataEvent(`${C0.ESC}[0n`); break; case 6: // cursor position const y = this._terminal.buffer.y + 1; const x = this._terminal.buffer.x + 1; - this._onData.fire(`${C0.ESC}[${y};${x}R`); + this._coreService.triggerDataEvent(`${C0.ESC}[${y};${x}R`); break; } } else if (collect === '?') { @@ -1816,7 +1816,7 @@ export class InputHandler extends Disposable implements IInputHandler { // cursor position const y = this._terminal.buffer.y + 1; const x = this._terminal.buffer.x + 1; - this._onData.fire(`${C0.ESC}[?${y};${x}R`); + this._coreService.triggerDataEvent(`${C0.ESC}[?${y};${x}R`); break; case 15: // no printer diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index 0b82466d..6b11698f 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -10,9 +10,9 @@ import { ITerminal } from './Types'; import { IBuffer } from 'common/buffer/Types'; import { IBufferLine } from 'common/Types'; import { MockTerminal } from './TestUtils.test'; -import { MockBufferService } from 'common/TestUtils.test'; +import { MockBufferService, MockOptionsService, MockCoreService } from 'common/TestUtils.test'; import { BufferLine } from 'common/buffer/BufferLine'; -import { IBufferService } from 'common/services/Services'; +import { IBufferService, IOptionsService } from 'common/services/Services'; import { MockCharSizeService, MockMouseService } from 'browser/TestUtils.test'; import { CellData } from 'common/buffer/CellData'; @@ -23,9 +23,10 @@ class TestMockTerminal extends MockTerminal { class TestSelectionManager extends SelectionManager { constructor( terminal: ITerminal, - bufferService: IBufferService + bufferService: IBufferService, + optionsService: IOptionsService ) { - super(terminal, new MockCharSizeService(10, 10), bufferService, new MockMouseService()); + super(terminal, null, new MockCharSizeService(10, 10), bufferService, new MockCoreService(), new MockMouseService(), optionsService); } public get model(): SelectionModel { return this._model; } @@ -46,17 +47,19 @@ describe('SelectionManager', () => { let terminal: ITerminal; let buffer: IBuffer; let bufferService: IBufferService; + let optionsService: IOptionsService; let selectionManager: TestSelectionManager; beforeEach(() => { terminal = new TestMockTerminal(); - bufferService = new MockBufferService(20, 20); + optionsService = new MockOptionsService(); + bufferService = new MockBufferService(20, 20, optionsService); terminal.buffers = bufferService.buffers; terminal.cols = 20; terminal.rows = 20; terminal.buffer = terminal.buffers.active; buffer = terminal.buffer; - selectionManager = new TestSelectionManager(terminal, bufferService); + selectionManager = new TestSelectionManager(terminal, bufferService, optionsService); }); function stringToRow(text: string): IBufferLine { diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 0b77d183..70023836 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -3,18 +3,19 @@ * @license MIT */ -import { ITerminal, ISelectionManager, ISelectionRedrawRequestEvent } from './Types'; +import { ITerminal } from './Types'; +import { ISelectionManager, ISelectionRedrawRequestEvent } from 'browser/selection/Types'; import { IBuffer } from 'common/buffer/Types'; import { IBufferLine } from 'common/Types'; import * as Browser from 'common/Platform'; import { SelectionModel } from 'browser/selection/SelectionModel'; -import { AltClickHandler } from './handlers/AltClickHandler'; import { CellData } from 'common/buffer/CellData'; import { IDisposable } from 'xterm'; import { EventEmitter, IEvent } from 'common/EventEmitter'; import { ICharSizeService, IMouseService } from 'browser/services/Services'; -import { IBufferService } from 'common/services/Services'; +import { IBufferService, IOptionsService, ICoreService } from 'common/services/Services'; import { getCoordsRelativeToElement } from 'browser/input/Mouse'; +import { moveToCellSequence } from 'browser/input/MoveToCell'; /** * The number of pixels the mouse needs to be above or below the viewport in @@ -38,12 +39,6 @@ const DRAG_SCROLL_INTERVAL = 50; */ const ALT_CLICK_MOVE_CURSOR_TIME = 500; -/** - * A string containing all characters that are considered word separated by the - * double click to select work logic. - */ -const WORD_SEPARATORS = ' ()[]{}\'"'; - const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160); const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g'); @@ -119,14 +114,17 @@ export class SelectionManager implements ISelectionManager { constructor( private readonly _terminal: ITerminal, + private readonly _screenElement: HTMLElement, private readonly _charSizeService: ICharSizeService, - readonly bufferService: IBufferService, - private readonly _mouseService: IMouseService + private readonly _bufferService: IBufferService, + private readonly _coreService: ICoreService, + private readonly _mouseService: IMouseService, + private readonly _optionsService: IOptionsService ) { this._initListeners(); this.enable(); - this._model = new SelectionModel(bufferService); + this._model = new SelectionModel(this._bufferService); this._activeSelectionMode = SelectionMode.NORMAL; } @@ -134,23 +132,23 @@ export class SelectionManager implements ISelectionManager { this._removeMouseDownListeners(); } - private get _buffer(): IBuffer { - return this._terminal.buffers.active; - } - /** * Initializes listener variables. */ private _initListeners(): void { this._mouseMoveListener = event => this._onMouseMove(event); this._mouseUpListener = event => this._onMouseUp(event); - + this._coreService.onUserInput(() => { + if (this.hasSelection) { + this.clearSelection(); + } + }); this.initBuffersListeners(); } public initBuffersListeners(): void { - this._trimListener = this._terminal.buffer.lines.onTrim(amount => this._onTrim(amount)); - this._terminal.buffers.onBufferActivate(e => this._onBufferActivate(e)); + this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._onTrim(amount)); + this._bufferService.buffers.onBufferActivate(e => this._onBufferActivate(e)); } /** @@ -194,6 +192,7 @@ export class SelectionManager implements ISelectionManager { return ''; } + const buffer = this._bufferService.buffer; const result: string[] = []; if (this._activeSelectionMode === SelectionMode.COLUMN) { @@ -203,18 +202,18 @@ export class SelectionManager implements ISelectionManager { } for (let i = start[1]; i <= end[1]; i++) { - const lineText = this._buffer.translateBufferLineToString(i, true, start[0], end[0]); + const lineText = buffer.translateBufferLineToString(i, true, start[0], end[0]); result.push(lineText); } } else { // Get first row const startRowEndCol = start[1] === end[1] ? end[0] : undefined; - result.push(this._buffer.translateBufferLineToString(start[1], true, start[0], startRowEndCol)); + result.push(buffer.translateBufferLineToString(start[1], true, start[0], startRowEndCol)); // Get middle rows for (let i = start[1] + 1; i <= end[1] - 1; i++) { - const bufferLine = this._buffer.lines.get(i); - const lineText = this._buffer.translateBufferLineToString(i, true); + const bufferLine = buffer.lines.get(i); + const lineText = buffer.translateBufferLineToString(i, true); if (bufferLine.isWrapped) { result[result.length - 1] += lineText; } else { @@ -224,8 +223,8 @@ export class SelectionManager implements ISelectionManager { // Get final row if (start[1] !== end[1]) { - const bufferLine = this._buffer.lines.get(end[1]); - const lineText = this._buffer.translateBufferLineToString(end[1], true, 0, end[0]); + const bufferLine = buffer.lines.get(end[1]); + const lineText = buffer.translateBufferLineToString(end[1], true, 0, end[0]); if (bufferLine.isWrapped) { result[result.length - 1] += lineText; } else { @@ -335,9 +334,9 @@ export class SelectionManager implements ISelectionManager { public selectLines(start: number, end: number): void { this._model.clearSelection(); start = Math.max(start, 0); - end = Math.min(end, this._terminal.buffer.lines.length - 1); + end = Math.min(end, this._bufferService.buffer.lines.length - 1); this._model.selectionStart = [0, start]; - this._model.selectionEnd = [this._terminal.cols, end]; + this._model.selectionEnd = [this._bufferService.cols, end]; this.refresh(); this._onSelectionChange.fire(); } @@ -358,7 +357,7 @@ export class SelectionManager implements ISelectionManager { * @param event The mouse event. */ private _getMouseBufferCoords(event: MouseEvent): [number, number] { - const coords = this._mouseService.getCoords(event, this._terminal.screenElement, this._terminal.cols, this._terminal.rows, true); + const coords = this._mouseService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true); if (!coords) { return null; } @@ -368,7 +367,7 @@ export class SelectionManager implements ISelectionManager { coords[1]--; // Convert viewport coords to buffer coords - coords[1] += this._terminal.buffer.ydisp; + coords[1] += this._bufferService.buffer.ydisp; return coords; } @@ -378,8 +377,8 @@ export class SelectionManager implements ISelectionManager { * @param event The mouse event. */ private _getMouseEventScrollAmount(event: MouseEvent): number { - let offset = getCoordsRelativeToElement(event, this._terminal.screenElement)[1]; - const terminalHeight = this._terminal.rows * Math.ceil(this._charSizeService.height * this._terminal.options.lineHeight); + let offset = getCoordsRelativeToElement(event, this._screenElement)[1]; + const terminalHeight = this._bufferService.rows * Math.ceil(this._charSizeService.height * this._optionsService.options.lineHeight); if (offset >= 0 && offset <= terminalHeight) { return 0; } @@ -399,7 +398,7 @@ export class SelectionManager implements ISelectionManager { */ public shouldForceSelection(event: MouseEvent): boolean { if (Browser.isMac) { - return event.altKey && this._terminal.options.macOptionClickForcesSelection; + return event.altKey && this._optionsService.options.macOptionClickForcesSelection; } return event.shiftKey; @@ -459,8 +458,8 @@ export class SelectionManager implements ISelectionManager { */ private _addMouseDownListeners(): void { // Listen on the document so that dragging outside of viewport works - this._terminal.element.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); - this._terminal.element.ownerDocument.addEventListener('mouseup', this._mouseUpListener); + this._screenElement.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); + this._screenElement.ownerDocument.addEventListener('mouseup', this._mouseUpListener); this._dragScrollIntervalTimer = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); } @@ -468,9 +467,9 @@ export class SelectionManager implements ISelectionManager { * Removes the listeners that are registered when mousedown is triggered. */ private _removeMouseDownListeners(): void { - if (this._terminal.element.ownerDocument) { - this._terminal.element.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); - this._terminal.element.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); + if (this._screenElement.ownerDocument) { + this._screenElement.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); + this._screenElement.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); } clearInterval(this._dragScrollIntervalTimer); this._dragScrollIntervalTimer = null; @@ -505,7 +504,7 @@ export class SelectionManager implements ISelectionManager { this._model.selectionEnd = null; // Ensure the line exists - const line = this._buffer.lines.get(this._model.selectionStart[1]); + const line = this._bufferService.buffer.lines.get(this._model.selectionStart[1]); if (!line) { return; } @@ -552,7 +551,7 @@ export class SelectionManager implements ISelectionManager { * @param event the mouse or keyboard event */ public shouldColumnSelect(event: KeyboardEvent | MouseEvent): boolean { - return event.altKey && !(Browser.isMac && this._terminal.options.macOptionClickForcesSelection); + return event.altKey && !(Browser.isMac && this._optionsService.options.macOptionClickForcesSelection); } /** @@ -582,7 +581,7 @@ export class SelectionManager implements ISelectionManager { if (this._model.selectionEnd[1] < this._model.selectionStart[1]) { this._model.selectionEnd[0] = 0; } else { - this._model.selectionEnd[0] = this._terminal.cols; + this._model.selectionEnd[0] = this._bufferService.cols; } } else if (this._activeSelectionMode === SelectionMode.WORD) { this._selectToWordAt(this._model.selectionEnd); @@ -596,7 +595,7 @@ export class SelectionManager implements ISelectionManager { // NOT in column select mode. if (this._activeSelectionMode !== SelectionMode.COLUMN) { if (this._dragScrollAmount > 0) { - this._model.selectionEnd[0] = this._terminal.cols; + this._model.selectionEnd[0] = this._bufferService.cols; } else if (this._dragScrollAmount < 0) { this._model.selectionEnd[0] = 0; } @@ -605,8 +604,9 @@ export class SelectionManager implements ISelectionManager { // If the character is a wide character include the cell to the right in the // selection. Note that selections at the very end of the line will never // have a character. - if (this._model.selectionEnd[1] < this._buffer.lines.length) { - if (this._buffer.lines.get(this._model.selectionEnd[1]).hasWidth(this._model.selectionEnd[0]) === 0) { + const buffer = this._bufferService.buffer; + if (this._model.selectionEnd[1] < buffer.lines.length) { + if (buffer.lines.get(this._model.selectionEnd[1]).hasWidth(this._model.selectionEnd[0]) === 0) { this._model.selectionEnd[0]++; } } @@ -630,16 +630,17 @@ export class SelectionManager implements ISelectionManager { // If the cursor was above or below the viewport, make sure it's at the // start or end of the viewport respectively. This should only happen when // NOT in column select mode. + const buffer = this._bufferService.buffer; if (this._dragScrollAmount > 0) { if (this._activeSelectionMode !== SelectionMode.COLUMN) { - this._model.selectionEnd[0] = this._terminal.cols; + this._model.selectionEnd[0] = this._bufferService.cols; } - this._model.selectionEnd[1] = Math.min(this._terminal.buffer.ydisp + this._terminal.rows, this._terminal.buffer.lines.length - 1); + this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows, buffer.lines.length - 1); } else { if (this._activeSelectionMode !== SelectionMode.COLUMN) { this._model.selectionEnd[0] = 0; } - this._model.selectionEnd[1] = this._terminal.buffer.ydisp; + this._model.selectionEnd[1] = buffer.ydisp; } this.refresh(); } @@ -655,7 +656,19 @@ export class SelectionManager implements ISelectionManager { this._removeMouseDownListeners(); if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME) { - (new AltClickHandler(event, this._terminal, this._mouseService)).move(); + if (event.altKey) { + const coordinates = this._mouseService.getCoords( + event, + this._terminal.element, + this._bufferService.cols, + this._bufferService.rows, + false + ); + if (coordinates && coordinates[0] !== undefined && coordinates[1] !== undefined) { + const sequence = moveToCellSequence(coordinates[0] - 1, coordinates[1] - 1, this._bufferService, this._terminal.applicationCursor); + this._coreService.triggerDataEvent(sequence, true); + } + } } else if (this.hasSelection) { this._onSelectionChange.fire(); } @@ -710,16 +723,17 @@ export class SelectionManager implements ISelectionManager { */ private _getWordAt(coords: [number, number], allowWhitespaceOnlySelection: boolean, followWrappedLinesAbove: boolean = true, followWrappedLinesBelow: boolean = true): IWordPosition { // Ensure coords are within viewport (eg. not within scroll bar) - if (coords[0] >= this._terminal.cols) { + if (coords[0] >= this._bufferService.cols) { return null; } - const bufferLine = this._buffer.lines.get(coords[1]); + const buffer = this._bufferService.buffer; + const bufferLine = buffer.lines.get(coords[1]); if (!bufferLine) { return null; } - const line = this._buffer.translateBufferLineToString(coords[1], false); + const line = buffer.translateBufferLineToString(coords[1], false); // Get actual index, taking into consideration wide characters let startIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); @@ -814,7 +828,7 @@ export class SelectionManager implements ISelectionManager { // Calculate the length in _columns_, converting the the string indexes back // to column coordinates. - let length = Math.min(this._terminal.cols, // Disallow lengths larger than the terminal cols + let length = Math.min(this._bufferService.cols, // Disallow lengths larger than the terminal cols endIndex // The index of the selection's end char in the line string - startIndex // The index of the selection's start char in the line string + leftWideCharCount // The number of wide chars left of the initial char @@ -829,11 +843,11 @@ export class SelectionManager implements ISelectionManager { // Recurse upwards if the line is wrapped and the word wraps to the above line if (followWrappedLinesAbove) { if (start === 0 && bufferLine.getCodePoint(0) !== 32 /*' '*/) { - const previousBufferLine = this._buffer.lines.get(coords[1] - 1); - if (previousBufferLine && bufferLine.isWrapped && previousBufferLine.getCodePoint(this._terminal.cols - 1) !== 32 /*' '*/) { - const previousLineWordPosition = this._getWordAt([this._terminal.cols - 1, coords[1] - 1], false, true, false); + const previousBufferLine = buffer.lines.get(coords[1] - 1); + if (previousBufferLine && bufferLine.isWrapped && previousBufferLine.getCodePoint(this._bufferService.cols - 1) !== 32 /*' '*/) { + const previousLineWordPosition = this._getWordAt([this._bufferService.cols - 1, coords[1] - 1], false, true, false); if (previousLineWordPosition) { - const offset = this._terminal.cols - previousLineWordPosition.start; + const offset = this._bufferService.cols - previousLineWordPosition.start; start -= offset; length += offset; } @@ -843,8 +857,8 @@ export class SelectionManager implements ISelectionManager { // Recurse downwards if the line is wrapped and the word wraps to the next line if (followWrappedLinesBelow) { - if (start + length === this._terminal.cols && bufferLine.getCodePoint(this._terminal.cols - 1) !== 32 /*' '*/) { - const nextBufferLine = this._buffer.lines.get(coords[1] + 1); + if (start + length === this._bufferService.cols && bufferLine.getCodePoint(this._bufferService.cols - 1) !== 32 /*' '*/) { + const nextBufferLine = buffer.lines.get(coords[1] + 1); if (nextBufferLine && nextBufferLine.isWrapped && nextBufferLine.getCodePoint(0) !== 32 /*' '*/) { const nextLineWordPosition = this._getWordAt([0, coords[1] + 1], false, false, true); if (nextLineWordPosition) { @@ -867,7 +881,7 @@ export class SelectionManager implements ISelectionManager { if (wordPosition) { // Adjust negative start value while (wordPosition.start < 0) { - wordPosition.start += this._terminal.cols; + wordPosition.start += this._bufferService.cols; coords[1]--; } this._model.selectionStart = [wordPosition.start, coords[1]]; @@ -886,15 +900,15 @@ export class SelectionManager implements ISelectionManager { // Adjust negative start value while (wordPosition.start < 0) { - wordPosition.start += this._terminal.cols; + wordPosition.start += this._bufferService.cols; endRow--; } // Adjust wrapped length value, this only needs to happen when values are reversed as in that // case we're interested in the start of the word, not the end if (!this._model.areSelectionValuesReversed()) { - while (wordPosition.start + wordPosition.length > this._terminal.cols) { - wordPosition.length -= this._terminal.cols; + while (wordPosition.start + wordPosition.length > this._bufferService.cols) { + wordPosition.length -= this._bufferService.cols; endRow++; } } @@ -914,7 +928,7 @@ export class SelectionManager implements ISelectionManager { if (cell.getWidth() === 0) { return false; } - return WORD_SEPARATORS.indexOf(cell.getChars()) >= 0; + return this._optionsService.options.wordSeparator.indexOf(cell.getChars()) >= 0; } /** @@ -922,9 +936,9 @@ export class SelectionManager implements ISelectionManager { * @param line The line index. */ protected _selectLineAt(line: number): void { - const wrappedRange = this._buffer.getWrappedRangeForLine(line); + const wrappedRange = this._bufferService.buffer.getWrappedRangeForLine(line); this._model.selectionStart = [0, wrappedRange.first]; - this._model.selectionEnd = [this._terminal.cols, wrappedRange.last]; + this._model.selectionEnd = [this._bufferService.cols, wrappedRange.last]; this._model.selectionStartLength = 0; } } diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index f3f7fc58..f2f02eec 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -53,10 +53,11 @@ describe('Terminal', () => { }); describe('events', () => { - it('should fire the onData evnet', (done) => { - term.onData(() => done()); - term.handler('fake'); - }); + // TODO: Add an onData test back + // it('should fire the onData evnet', (done) => { + // term.onData(() => done()); + // term.handler('fake'); + // }); it('should fire the onCursorMove event', (done) => { term.onCursorMove(() => done()); term.write('foo'); @@ -142,7 +143,6 @@ describe('Terminal', () => { }; beforeEach(() => { - term.handler = () => { }; term.showCursor = () => { }; term.clearSelection = () => { }; }); @@ -520,7 +520,6 @@ describe('Terminal', () => { let evKeyPress: any; beforeEach(() => { - term.handler = () => { }; term.showCursor = () => { }; term.clearSelection = () => { }; // term.compositionHelper = { diff --git a/src/Terminal.ts b/src/Terminal.ts index 29a24500..8b4f662a 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -47,7 +47,7 @@ import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { applyWindowsMode } from './WindowsMode'; import { ColorManager } from 'browser/ColorManager'; import { RenderService } from 'browser/services/RenderService'; -import { IOptionsService, IBufferService } from 'common/services/Services'; +import { IOptionsService, IBufferService, ICoreService } from 'common/services/Services'; import { OptionsService } from 'common/services/OptionsService'; import { ICharSizeService, IRenderService, IMouseService } from 'browser/services/Services'; import { CharSizeService } from 'browser/services/CharSizeService'; @@ -57,6 +57,7 @@ import { IBufferSet, IBuffer } from 'common/buffer/Types'; import { Attributes } from 'common/buffer/Constants'; import { MouseService } from 'browser/services/MouseService'; import { IParams } from 'common/parser/Types'; +import { CoreService } from 'common/services/CoreService'; // Let it work inside Node.js for automated testing purposes. const document = (typeof window !== 'undefined') ? window.document : null; @@ -108,6 +109,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // common services private _bufferService: IBufferService; + private _coreService: ICoreService; public optionsService: IOptionsService; // browser services @@ -238,6 +240,9 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // Setup and initialize common services this.optionsService = new OptionsService(options); this._bufferService = new BufferService(this.optionsService); + this._coreService = new CoreService(() => this.scrollToBottom(), this._bufferService, this.optionsService); + this._coreService.onData(e => this._onData.fire(e)); + this._setupOptionsListeners(); this._setup(); } @@ -250,7 +255,6 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp } this._customKeyEventHandler = null; removeTerminalFromCache(this); - this.handler = () => {}; this.write = () => {}; if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); @@ -295,10 +299,9 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._userScrolling = false; // Register input handler and refire/handle events - this._inputHandler = new InputHandler(this); + this._inputHandler = new InputHandler(this, this._coreService); this._inputHandler.onCursorMove(() => this._onCursorMove.fire()); this._inputHandler.onLineFeed(() => this._onLineFeed.fire()); - this._inputHandler.onData(e => this._onData.fire(e)); this.register(this._inputHandler); this.selectionManager = this.selectionManager || null; @@ -435,7 +438,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp */ private _onTextAreaFocus(ev: KeyboardEvent): void { if (this.sendFocus) { - this.handler(C0.ESC + '[I'); + this._coreService.triggerDataEvent(C0.ESC + '[I'); } this.updateCursorStyle(ev); this.element.classList.add('focus'); @@ -460,7 +463,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this.textarea.value = ''; this.refresh(this.buffer.y, this.buffer.y); if (this.sendFocus) { - this.handler(C0.ESC + '[O'); + this._coreService.triggerDataEvent(C0.ESC + '[O'); } this.element.classList.remove('focus'); this._onBlur.fire(); @@ -479,9 +482,9 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp if (!this.hasSelection()) { return; } - copyHandler(event, this, this.selectionManager); + copyHandler(event, this.selectionManager); })); - const pasteHandlerWrapper = (event: ClipboardEvent) => pasteHandler(event, this); + const pasteHandlerWrapper = (event: ClipboardEvent) => pasteHandler(event, this.textarea, this.bracketedPasteMode, e => this._coreService.triggerDataEvent(e, true)); this.register(addDisposableDomListener(this.textarea, 'paste', pasteHandlerWrapper)); this.register(addDisposableDomListener(this.element, 'paste', pasteHandlerWrapper)); @@ -490,12 +493,12 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // Firefox doesn't appear to fire the contextmenu event on right click this.register(addDisposableDomListener(this.element, 'mousedown', (event: MouseEvent) => { if (event.button === 2) { - rightClickHandler(event, this, this.selectionManager, this.options.rightClickSelectsWord); + rightClickHandler(event, this.textarea, this.screenElement, this.selectionManager, this.options.rightClickSelectsWord); } })); } else { this.register(addDisposableDomListener(this.element, 'contextmenu', (event: MouseEvent) => { - rightClickHandler(event, this, this.selectionManager, this.options.rightClickSelectsWord); + rightClickHandler(event, this.textarea, this.screenElement, this.selectionManager, this.options.rightClickSelectsWord); })); } @@ -507,7 +510,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // that the regular click event doesn't fire for the middle mouse button. this.register(addDisposableDomListener(this.element, 'auxclick', (event: MouseEvent) => { if (event.button === 1) { - moveTextAreaUnderMouseCursor(event, this); + moveTextAreaUnderMouseCursor(event, this.textarea, this.screenElement); } })); } @@ -608,7 +611,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._compositionView = document.createElement('div'); this._compositionView.classList.add('composition-view'); - this._compositionHelper = new CompositionHelper(this.textarea, this._compositionView, this, this._charSizeService); + this._compositionHelper = new CompositionHelper(this.textarea, this._compositionView, this, this._charSizeService, this._coreService); this._helperContainer.appendChild(this._compositionView); // Performance: Add viewport and helper elements from the fragment @@ -641,7 +644,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this.register(this.onFocus(() => this._renderService.onFocus())); this.register(this._renderService.onDimensionsChange(() => this.viewport.syncScrollArea())); - this.selectionManager = new SelectionManager(this, this._charSizeService, this._bufferService, this._mouseService); + this.selectionManager = new SelectionManager(this, this.screenElement, this._charSizeService, this._bufferService, this._coreService, this._mouseService, this.optionsService); this.register(this.selectionManager.onSelectionChange(() => this._onSelectionChange.fire())); this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e))); this.register(this.selectionManager.onRedrawRequest(e => this._renderService.onSelectionChanged(e.start, e.end, e.columnSelectMode))); @@ -815,7 +818,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp else if (button === 3) return; else data += '0'; data += '~[' + pos.x + ',' + pos.y + ']\r'; - self.handler(data); + self._coreService.triggerDataEvent(data, true); return; } @@ -828,7 +831,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp else if (button === 1) button = 4; else if (button === 2) button = 6; else if (button === 3) button = 3; - self.handler(C0.ESC + '[' + self._coreService.triggerDataEvent(C0.ESC + '[' + button + ';' + (button === 3 ? 4 : 0) @@ -839,7 +842,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp + ';' // Not sure what page is meant to be + (pos).page || 0 - + '&w'); + + '&w', true); return; } @@ -848,20 +851,20 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp pos.y -= 32; pos.x++; pos.y++; - self.handler(C0.ESC + '[' + button + ';' + pos.x + ';' + pos.y + 'M'); + self._coreService.triggerDataEvent(C0.ESC + '[' + button + ';' + pos.x + ';' + pos.y + 'M', true); return; } if (self.sgrMouse) { pos.x -= 32; pos.y -= 32; - self.handler(C0.ESC + '[<' + self._coreService.triggerDataEvent(C0.ESC + '[<' + (((button & 3) === 3 ? button & ~3 : button) - 32) + ';' + pos.x + ';' + pos.y - + ((button & 3) === 3 ? 'm' : 'M')); + + ((button & 3) === 3 ? 'm' : 'M'), true); return; } @@ -871,7 +874,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp encode(data, pos.x); encode(data, pos.y); - self.handler(C0.ESC + '[M' + String.fromCharCode.apply(String, data)); + self._coreService.triggerDataEvent(C0.ESC + '[M' + String.fromCharCode.apply(String, data), true); } function getButton(ev: MouseEvent): number { @@ -1016,7 +1019,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp for (let i = 0; i < Math.abs(amount); i++) { data += sequence; } - this.handler(data); + this._coreService.triggerDataEvent(data, true); } return; } @@ -1241,7 +1244,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp if (this.options.useFlowControl && !this._xoffSentToCatchUp && this.writeBufferUtf8.length >= WRITE_BUFFER_PAUSE_THRESHOLD) { // XOFF - stop pty pipe // XON will be triggered by emulator before processing data chunk - this.handler(C0.DC3); + this._coreService.triggerDataEvent(C0.DC3); this._xoffSentToCatchUp = true; } @@ -1269,7 +1272,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // If XOFF was sent in order to catch up with the pty process, resume it if // we reached the end of the writeBuffer to allow more data to come in. if (this._xoffSentToCatchUp && this.writeBufferUtf8.length === bufferOffset) { - this.handler(C0.DC1); + this._coreService.triggerDataEvent(C0.DC1); this._xoffSentToCatchUp = false; } @@ -1328,7 +1331,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp if (this.options.useFlowControl && !this._xoffSentToCatchUp && this.writeBuffer.length >= WRITE_BUFFER_PAUSE_THRESHOLD) { // XOFF - stop pty pipe // XON will be triggered by emulator before processing data chunk - this.handler(C0.DC3); + this._coreService.triggerDataEvent(C0.DC3); this._xoffSentToCatchUp = true; } @@ -1356,7 +1359,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // If XOFF was sent in order to catch up with the pty process, resume it if // we reached the end of the writeBuffer to allow more data to come in. if (this._xoffSentToCatchUp && this.writeBuffer.length === bufferOffset) { - this.handler(C0.DC1); + this._coreService.triggerDataEvent(C0.DC1); this._xoffSentToCatchUp = false; } @@ -1588,7 +1591,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._onKey.fire({ key: result.key, domEvent: event }); this.showCursor(); - this.handler(result.key); + this._coreService.triggerDataEvent(result.key, true); return this.cancel(event, true); } @@ -1666,7 +1669,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._onKey.fire({ key, domEvent: ev }); this.showCursor(); - this.handler(key); + this._coreService.triggerDataEvent(key, true); return true; } @@ -1797,23 +1800,23 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * Emit the data event and populate the given data. * @param data The data to populate in the event. */ - public handler(data: string): void { - // Prevents all events to pty process if stdin is disabled - if (this.options.disableStdin) { - return; - } + // public handler(data: string): void { + // // Prevents all events to pty process if stdin is disabled + // if (this.options.disableStdin) { + // return; + // } - // Clear the selection if the selection manager is available and has an active selection - if (this.selectionManager && this.selectionManager.hasSelection) { - this.selectionManager.clearSelection(); - } + // // Clear the selection if the selection manager is available and has an active selection + // if (this.selectionManager && this.selectionManager.hasSelection) { + // this.selectionManager.clearSelection(); + // } - // Input is being sent to the terminal, the terminal should focus the prompt. - if (this.buffer.ybase !== this.buffer.ydisp) { - this.scrollToBottom(); - } - this._onData.fire(data); - } + // // Input is being sent to the terminal, the terminal should focus the prompt. + // if (this.buffer.ybase !== this.buffer.ydisp) { + // this.scrollToBottom(); + // } + // this._onData.fire(data); + // } /** * Emit the 'title' event and populate the given title. diff --git a/src/TestUtils.test.ts b/src/TestUtils.test.ts index 52887f19..8dcb2cdd 100644 --- a/src/TestUtils.test.ts +++ b/src/TestUtils.test.ts @@ -4,7 +4,7 @@ */ import { IRenderer, IRenderDimensions, CharacterJoinerHandler } from 'browser/renderer/Types'; -import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBrowser, ISelectionManager, ITerminalOptions, ILinkifier, ILinkMatcherOptions } from './Types'; +import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBrowser, ITerminalOptions, ILinkifier, ILinkMatcherOptions } from './Types'; import { IBuffer, IBufferStringIterator, IBufferSet } from 'common/buffer/Types'; import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; @@ -16,6 +16,7 @@ import { IColorManager, IColorSet } from 'browser/Types'; import { IOptionsService } from 'common/services/Services'; import { EventEmitter } from 'common/EventEmitter'; import { IParams } from 'common/parser/Types'; +import { ISelectionManager } from 'browser/selection/Types'; export class TestTerminal extends Terminal { writeSync(data: string): void { diff --git a/src/Types.d.ts b/src/Types.d.ts index d5399b80..1cab66ef 100644 --- a/src/Types.d.ts +++ b/src/Types.d.ts @@ -10,6 +10,7 @@ import { IColorSet } from 'browser/Types'; import { IOptionsService } from 'common/services/Services'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IParams } from 'common/parser/Types'; +import { ISelectionManager } from 'browser/selection/Types'; export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean; @@ -73,7 +74,6 @@ export interface IInputHandlingTerminal { refresh(start: number, end: number): void; error(text: string, data?: any): void; tabSet(): void; - handler(data: string): void; handleTitle(title: string): void; index(): void; reverseIndex(): void; @@ -217,7 +217,6 @@ export interface ITerminal extends IPublicTerminal, IElementAccessor, IBufferAcc onA11yChar: IEvent; onA11yTab: IEvent; - handler(data: string): void; scrollLines(disp: number, suppressScrollEvent?: boolean): void; cancel(ev: Event, force?: boolean): boolean | void; log(text: string): void; @@ -297,24 +296,6 @@ export interface ITerminalOptions extends IPublicTerminalOptions { useFlowControl?: boolean; } -export interface ISelectionManager { - selectionText: string; - selectionStart: [number, number]; - selectionEnd: [number, number]; - - disable(): void; - enable(): void; - setSelection(row: number, col: number, length: number): void; - isClickInSelection(event: MouseEvent): boolean; - selectWordAtCursor(event: MouseEvent): void; -} - -export interface ISelectionRedrawRequestEvent { - start: [number, number]; - end: [number, number]; - columnSelectMode: boolean; -} - export interface ILinkifier { onLinkHover: IEvent; onLinkLeave: IEvent; diff --git a/src/browser/input/MoveToCell.test.ts b/src/browser/input/MoveToCell.test.ts new file mode 100644 index 00000000..bc4012c0 --- /dev/null +++ b/src/browser/input/MoveToCell.test.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { IBufferService } from 'common/services/Services'; +import { MockBufferService } from 'common/TestUtils.test'; +import { moveToCellSequence } from './MoveToCell'; + +describe('MoveToCell', () => { + let bufferService: IBufferService; + + beforeEach(() => { + bufferService = new MockBufferService(5, 5); + bufferService.buffer.x = 3; + bufferService.buffer.y = 3; + }); + + describe('normal buffer', () => { + it('should use the right directional escape sequences', () => { + assert.equal(moveToCellSequence(2, 3, bufferService, false), '\x1b[D'); + assert.equal(moveToCellSequence(4, 3, bufferService, false), '\x1b[C'); + }); + it('should ignore the Y value', () => { + assert.equal(moveToCellSequence(1, 1, bufferService, false), '\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(1, 2, bufferService, false), '\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(1, 3, bufferService, false), '\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(1, 4, bufferService, false), '\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(1, 5, bufferService, false), '\x1b[D\x1b[D'); + }); + it('should use the correct character for application cursor', () => { + assert.equal(moveToCellSequence(2, 1, bufferService, false), '\x1b[D'); + assert.equal(moveToCellSequence(2, 1, bufferService, true), '\x1bOD'); + }); + }); + + describe('alt buffer', () => { + beforeEach(() => { + bufferService.buffers.activateAltBuffer(); + bufferService.buffer.x = 3; + bufferService.buffer.y = 3; + }); + + it('should move the cursor across rows', () => { + assert.equal(moveToCellSequence(4, 4, bufferService, false), '\x1b[B\x1b[C'); + }); + }); +}); diff --git a/src/browser/input/MoveToCell.ts b/src/browser/input/MoveToCell.ts new file mode 100644 index 00000000..406ec807 --- /dev/null +++ b/src/browser/input/MoveToCell.ts @@ -0,0 +1,230 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { C0 } from 'common/data/EscapeSequences'; +import { IBufferService } from 'common/services/Services'; + +const enum Direction { + UP = 'A', + DOWN = 'B', + RIGHT = 'C', + LEFT = 'D' +} + +/** + * Concatenates all the arrow sequences together. + * Resets the starting row to an unwrapped row, moves to the requested row, + * then moves to requested col. + */ +export function moveToCellSequence(targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string { + const startX = bufferService.buffer.x; + const startY = bufferService.buffer.y; + + // The alt buffer should try to navigate between rows + if (!bufferService.buffer.hasScrollback) { + return resetStartingRow(startX, startY, targetX, targetY, bufferService, applicationCursor) + + moveToRequestedRow(startY, targetY, bufferService, applicationCursor) + + moveToRequestedCol(startX, startY, targetX, targetY, bufferService, applicationCursor); + } + + // Only move horizontally for the normal buffer + return moveHorizontallyOnly(startX, startY, targetX, targetY, bufferService, applicationCursor); +} + +/** + * If the initial position of the cursor is on a row that is wrapped, move the + * cursor up to the first row that is not wrapped to have accurate vertical + * positioning. + */ +function resetStartingRow(startX: number, startY: number, targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string { + if (moveToRequestedRow(startY, targetY, bufferService, applicationCursor).length === 0) { + return ''; + } + return repeat(bufferLine( + startX, startY, startX, + startY - wrappedRowsForRow(bufferService, startY), false, bufferService + ).length, sequence(Direction.LEFT, applicationCursor)); +} + +/** + * Using the reset starting and ending row, move to the requested row, + * ignoring wrapped rows + */ +function moveToRequestedRow(startY: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string { + const startRow = startY - wrappedRowsForRow(bufferService, startY); + const endRow = targetY - wrappedRowsForRow(bufferService, targetY); + + const rowsToMove = Math.abs(startRow - endRow) - wrappedRowsCount(startY, targetY, bufferService); + + return repeat(rowsToMove, sequence(verticalDirection(startY, targetY), applicationCursor)); +} + +/** + * Move to the requested col on the ending row + */ +function moveToRequestedCol(startX: number, startY: number, targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string { + let startRow; + if (moveToRequestedRow(startY, targetY, bufferService, applicationCursor).length > 0) { + startRow = targetY - wrappedRowsForRow(bufferService, targetY); + } else { + startRow = startY; + } + + const endRow = targetY; + const direction = horizontalDirection(startX, startY, targetX, targetY, bufferService, applicationCursor); + + return repeat(bufferLine( + startX, startRow, targetX, endRow, + direction === Direction.RIGHT, bufferService + ).length, sequence(direction, applicationCursor)); +} + +function moveHorizontallyOnly(startX: number, startY: number, targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string { + const direction = horizontalDirection(startX, startY, targetX, targetY, bufferService, applicationCursor); + return repeat(Math.abs(startX - targetX), sequence(direction, applicationCursor)); +} + +/** + * Utility functions + */ + +/** + * Calculates the number of wrapped rows between the unwrapped starting and + * ending rows. These rows need to ignored since the cursor skips over them. + */ +function wrappedRowsCount(startY: number, targetY: number, bufferService: IBufferService): number { + let wrappedRows = 0; + const startRow = startY - wrappedRowsForRow(bufferService, startY); + const endRow = targetY - wrappedRowsForRow(bufferService, targetY); + + for (let i = 0; i < Math.abs(startRow - endRow); i++) { + const direction = verticalDirection(startY, targetY) === Direction.UP ? -1 : 1; + const line = bufferService.buffer.lines.get(startRow + (direction * i)); + if (line && line.isWrapped) { + wrappedRows++; + } + } + + return wrappedRows; +} + +/** + * Calculates the number of wrapped rows that make up a given row. + * @param currentRow The row to determine how many wrapped rows make it up + */ +function wrappedRowsForRow(bufferService: IBufferService, currentRow: number): number { + let rowCount = 0; + let line = bufferService.buffer.lines.get(currentRow); + let lineWraps = line && line.isWrapped; + + while (lineWraps && currentRow >= 0 && currentRow < bufferService.rows) { + rowCount++; + line = bufferService.buffer.lines.get(--currentRow); + lineWraps = line && line.isWrapped; + } + + return rowCount; +} + +/** + * Direction determiners + */ + +/** + * Determines if the right or left arrow is needed + */ +function horizontalDirection(startX: number, startY: number, targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): Direction { + let startRow; + if (moveToRequestedRow(targetX, targetY, bufferService, applicationCursor).length > 0) { + startRow = targetY - wrappedRowsForRow(bufferService, targetY); + } else { + startRow = startY; + } + + if ((startX < targetX && + startRow <= targetY) || // down/right or same y/right + (startX >= targetX && + startRow < targetY)) { // down/left or same y/left + return Direction.RIGHT; + } + return Direction.LEFT; +} + +/** + * Determines if the up or down arrow is needed + */ +function verticalDirection(startY: number, targetY: number): Direction { + return startY > targetY ? Direction.UP : Direction.DOWN; +} + +/** + * Constructs the string of chars in the buffer from a starting row and col + * to an ending row and col + * @param startCol The starting column position + * @param startRow The starting row position + * @param endCol The ending column position + * @param endRow The ending row position + * @param forward Direction to move + */ +function bufferLine( + startCol: number, + startRow: number, + endCol: number, + endRow: number, + forward: boolean, + bufferService: IBufferService +): string { + let currentCol = startCol; + let currentRow = startRow; + let bufferStr = ''; + + while (currentCol !== endCol || currentRow !== endRow) { + currentCol += forward ? 1 : -1; + + if (forward && currentCol > bufferService.cols - 1) { + bufferStr += bufferService.buffer.translateBufferLineToString( + currentRow, false, startCol, currentCol + ); + currentCol = 0; + startCol = 0; + currentRow++; + } else if (!forward && currentCol < 0) { + bufferStr += bufferService.buffer.translateBufferLineToString( + currentRow, false, 0, startCol + 1 + ); + currentCol = bufferService.cols - 1; + startCol = currentCol; + currentRow--; + } + } + + return bufferStr + bufferService.buffer.translateBufferLineToString( + currentRow, false, startCol, currentCol + ); +} + +/** + * Constructs the escape sequence for clicking an arrow + * @param direction The direction to move + */ +function sequence(direction: Direction, applicationCursor: boolean): string { + const mod = applicationCursor ? 'O' : '['; + return C0.ESC + mod + direction; +} + +/** + * Returns a string repeated a given number of times + * Polyfill from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/repeat + * @param count The number of times to repeat the string + * @param string The string that is to be repeated + */ +function repeat(count: number, str: string): string { + count = Math.floor(count); + let rpt = ''; + for (let i = 0; i < count; i++) { + rpt += str; + } + return rpt; +} diff --git a/src/browser/selection/Types.d.ts b/src/browser/selection/Types.d.ts new file mode 100644 index 00000000..241731f1 --- /dev/null +++ b/src/browser/selection/Types.d.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export interface ISelectionManager { + selectionText: string; + selectionStart: [number, number]; + selectionEnd: [number, number]; + + disable(): void; + enable(): void; + setSelection(row: number, col: number, length: number): void; + isClickInSelection(event: MouseEvent): boolean; + selectWordAtCursor(event: MouseEvent): void; +} + +export interface ISelectionRedrawRequestEvent { + start: [number, number]; + end: [number, number]; + columnSelectMode: boolean; +} diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index c6402fb1..86098d33 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IBufferService, IOptionsService, ITerminalOptions, IPartialTerminalOptions } from 'common/services/Services'; +import { IBufferService, ICoreService, IOptionsService, ITerminalOptions, IPartialTerminalOptions } from 'common/services/Services'; import { IEvent, EventEmitter } from 'common/EventEmitter'; import { clone } from 'common/Clone'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; @@ -27,6 +27,12 @@ export class MockBufferService implements IBufferService { reset(): void {} } +export class MockCoreService implements ICoreService { + onData: IEvent = new EventEmitter().event; + onUserInput: IEvent = new EventEmitter().event; + triggerDataEvent(data: string, wasUserInput?: boolean): void {} +} + export class MockOptionsService implements IOptionsService { options: ITerminalOptions = clone(DEFAULT_OPTIONS); onOptionChange: IEvent = new EventEmitter().event; diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts new file mode 100644 index 00000000..bd731e8d --- /dev/null +++ b/src/common/services/CoreService.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ICoreService, IOptionsService, IBufferService } from 'common/services/Services'; +import { EventEmitter, IEvent } from 'common/EventEmitter'; + +export class CoreService implements ICoreService { + private _onData = new EventEmitter(); + public get onData(): IEvent { return this._onData.event; } + private _onUserInput = new EventEmitter(); + public get onUserInput(): IEvent { return this._onUserInput.event; } + + constructor( + // TODO: Move this into a service + private readonly _scrollToBottom: () => void, + private readonly _bufferService: IBufferService, + private readonly _optionsService: IOptionsService + ) { + } + + public triggerDataEvent(data: string, wasUserInput: boolean = false): void { + // Prevents all events to pty process if stdin is disabled + if (this._optionsService.options.disableStdin) { + return; + } + + // Input is being sent to the terminal, the terminal should focus the prompt. + const buffer = this._bufferService.buffer; + if (buffer.ybase !== buffer.ydisp) { + this._scrollToBottom(); + } + + // Fire onUserInput so listeners can react as well (eg. clear selection) + if (wasUserInput) { + this._onUserInput.fire(); + } + + // Fire onData API + this._onData.fire(data); + } +} diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 2d2a08f0..ab041488 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -46,7 +46,8 @@ export const DEFAULT_OPTIONS: ITerminalOptions = Object.freeze({ screenKeys: false, debug: false, cancelEvents: false, - useFlowControl: false + useFlowControl: false, + wordSeparator: ' ()[]{}\'"' }); /** @@ -99,6 +100,7 @@ export class OptionsService implements IOptionsService { case 'fontWeight': case 'fontWeightBold': case 'rendererType': + case 'wordSeparator': if (!value) { value = DEFAULT_OPTIONS[key]; } diff --git a/src/common/services/Services.d.ts b/src/common/services/Services.d.ts index 602671ff..d9903e70 100644 --- a/src/common/services/Services.d.ts +++ b/src/common/services/Services.d.ts @@ -18,6 +18,21 @@ export interface IBufferService { reset(): void; } +export interface ICoreService { + readonly onData: IEvent; + readonly onUserInput: IEvent; + + /** + * Triggers the onData event in the public API. + * @param data The data that is being emitted. + * @param wasFromUser Whether the data originated from the user (as opposed to + * resulting from parsing incoming data). When true this will also: + * - Scroll to the bottom of the buffer.s + * - Fire the `onUserInput` event (so selection can be cleared). + */ + triggerDataEvent(data: string, wasUserInput?: boolean): void; +} + export interface IOptionsService { readonly options: ITerminalOptions; @@ -56,6 +71,7 @@ export interface IPartialTerminalOptions { tabStopWidth?: number; theme?: ITheme; windowsMode?: boolean; + wordSeparator?: string; } export interface ITerminalOptions { @@ -91,6 +107,7 @@ export interface ITerminalOptions { screenKeys: boolean; termName: string; useFlowControl: boolean; + wordSeparator?: string; } export interface ITheme { diff --git a/src/handlers/AltClickHandler.ts b/src/handlers/AltClickHandler.ts deleted file mode 100644 index 334b78d8..00000000 --- a/src/handlers/AltClickHandler.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Copyright (c) 2018 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { ITerminal } from '../Types'; -import { IBufferLine, ICircularList } from 'common/Types'; -import { C0 } from 'common/data/EscapeSequences'; -import { IMouseService } from 'browser/services/Services'; - -const enum Direction { - UP = 'A', - DOWN = 'B', - RIGHT = 'C', - LEFT = 'D' -} - -export class AltClickHandler { - private _startRow: number; - private _startCol: number; - private _endRow: number; - private _endCol: number; - private _lines: ICircularList; - - constructor( - private _mouseEvent: MouseEvent, - private _terminal: ITerminal, - private readonly _mouseService: IMouseService - ) { - this._lines = this._terminal.buffer.lines; - this._startCol = this._terminal.buffer.x; - this._startRow = this._terminal.buffer.y; - - const coordinates = this._mouseService.getCoords( - this._mouseEvent, - this._terminal.element, - this._terminal.cols, - this._terminal.rows, - false - ); - - if (coordinates) { - [this._endCol, this._endRow] = coordinates.map((coordinate: number) => { - return coordinate - 1; - }); - } - } - - /** - * Writes the escape sequences of arrows to the terminal - */ - public move(): void { - if (this._mouseEvent.altKey && this._endCol !== undefined && this._endRow !== undefined) { - this._terminal.handler(this._arrowSequences()); - } - } - - /** - * Concatenates all the arrow sequences together. - * Resets the starting row to an unwrapped row, moves to the requested row, - * then moves to requested col. - */ - private _arrowSequences(): string { - // The alt buffer should try to navigate between rows - if (!this._terminal.buffer.hasScrollback) { - return this._resetStartingRow() + this._moveToRequestedRow() + this._moveToRequestedCol(); - } - - // Only move horizontally for the normal buffer - return this._moveHorizontallyOnly(); - } - - /** - * If the initial position of the cursor is on a row that is wrapped, move the - * cursor up to the first row that is not wrapped to have accurate vertical - * positioning. - */ - private _resetStartingRow(): string { - if (this._moveToRequestedRow().length === 0) { - return ''; - } - return repeat(this._bufferLine( - this._startCol, this._startRow, this._startCol, - this._startRow - this._wrappedRowsForRow(this._startRow), false - ).length, this._sequence(Direction.LEFT)); - } - - /** - * Using the reset starting and ending row, move to the requested row, - * ignoring wrapped rows - */ - private _moveToRequestedRow(): string { - const startRow = this._startRow - this._wrappedRowsForRow(this._startRow); - const endRow = this._endRow - this._wrappedRowsForRow(this._endRow); - - const rowsToMove = Math.abs(startRow - endRow) - this._wrappedRowsCount(); - - return repeat(rowsToMove, this._sequence(this._verticalDirection())); - } - - /** - * Move to the requested col on the ending row - */ - private _moveToRequestedCol(): string { - let startRow; - if (this._moveToRequestedRow().length > 0) { - startRow = this._endRow - this._wrappedRowsForRow(this._endRow); - } else { - startRow = this._startRow; - } - - const endRow = this._endRow; - const direction = this._horizontalDirection(); - - return repeat(this._bufferLine( - this._startCol, startRow, this._endCol, endRow, - direction === Direction.RIGHT - ).length, this._sequence(direction)); - } - - private _moveHorizontallyOnly(): string { - const direction = this._horizontalDirection(); - return repeat(Math.abs(this._startCol - this._endCol), this._sequence(direction)); - } - - /** - * Utility functions - */ - - /** - * Calculates the number of wrapped rows between the unwrapped starting and - * ending rows. These rows need to ignored since the cursor skips over them. - */ - private _wrappedRowsCount(): number { - let wrappedRows = 0; - const startRow = this._startRow - this._wrappedRowsForRow(this._startRow); - const endRow = this._endRow - this._wrappedRowsForRow(this._endRow); - - for (let i = 0; i < Math.abs(startRow - endRow); i++) { - const direction = this._verticalDirection() === Direction.UP ? -1 : 1; - - if (this._lines.get(startRow + (direction * i)).isWrapped) { - wrappedRows++; - } - } - - return wrappedRows; - } - - /** - * Calculates the number of wrapped rows that make up a given row. - * @param currentRow The row to determine how many wrapped rows make it up - */ - private _wrappedRowsForRow(currentRow: number): number { - let rowCount = 0; - let lineWraps = this._lines.get(currentRow).isWrapped; - - while (lineWraps && currentRow >= 0 && currentRow < this._terminal.rows) { - rowCount++; - currentRow--; - lineWraps = this._lines.get(currentRow).isWrapped; - } - - return rowCount; - } - - /** - * Direction determiners - */ - - /** - * Determines if the right or left arrow is needed - */ - private _horizontalDirection(): Direction { - let startRow; - if (this._moveToRequestedRow().length > 0) { - startRow = this._endRow - this._wrappedRowsForRow(this._endRow); - } else { - startRow = this._startRow; - } - - if ((this._startCol < this._endCol && - startRow <= this._endRow) || // down/right or same y/right - (this._startCol >= this._endCol && - startRow < this._endRow)) { // down/left or same y/left - return Direction.RIGHT; - } - return Direction.LEFT; - } - - /** - * Determines if the up or down arrow is needed - */ - private _verticalDirection(): Direction { - if (this._startRow > this._endRow) { - return Direction.UP; - } - return Direction.DOWN; - } - - /** - * Constructs the string of chars in the buffer from a starting row and col - * to an ending row and col - * @param startCol The starting column position - * @param startRow The starting row position - * @param endCol The ending column position - * @param endRow The ending row position - * @param forward Direction to move - */ - private _bufferLine( - startCol: number, - startRow: number, - endCol: number, - endRow: number, - forward: boolean - ): string { - let currentCol = startCol; - let currentRow = startRow; - let bufferStr = ''; - - while (currentCol !== endCol || currentRow !== endRow) { - currentCol += forward ? 1 : -1; - - if (forward && currentCol > this._terminal.cols - 1) { - bufferStr += this._terminal.buffer.translateBufferLineToString( - currentRow, false, startCol, currentCol - ); - currentCol = 0; - startCol = 0; - currentRow++; - } else if (!forward && currentCol < 0) { - bufferStr += this._terminal.buffer.translateBufferLineToString( - currentRow, false, 0, startCol + 1 - ); - currentCol = this._terminal.cols - 1; - startCol = currentCol; - currentRow--; - } - } - - return bufferStr + this._terminal.buffer.translateBufferLineToString( - currentRow, false, startCol, currentCol - ); - } - - /** - * Constructs the escape sequence for clicking an arrow - * @param direction The direction to move - */ - private _sequence(direction: Direction): string { - const mod = this._terminal.applicationCursor ? 'O' : '['; - return C0.ESC + mod + direction; - } -} - -/** - * Returns a string repeated a given number of times - * Polyfill from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/repeat - * @param count The number of times to repeat the string - * @param string The string that is to be repeated - */ -function repeat(count: number, str: string): string { - count = Math.floor(count); - let rpt = ''; - for (let i = 0; i < count; i++) { - rpt += str; - } - return rpt; -} diff --git a/src/public/Terminal.ts b/src/public/Terminal.ts index 70486207..0a5535ed 100644 --- a/src/public/Terminal.ts +++ b/src/public/Terminal.ts @@ -125,7 +125,7 @@ export class Terminal implements ITerminalApi { public writeUtf8(data: Uint8Array): void { this._core.writeUtf8(data); } - public getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold' | 'rendererType' | 'termName'): string; + public getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold' | 'rendererType' | 'termName' | 'wordSeparator'): string; public getOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'debug' | 'disableStdin' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'screenKeys' | 'useFlowControl' | 'visualBell'): boolean; public getOption(key: 'colors'): string[]; public getOption(key: 'cols' | 'fontSize' | 'letterSpacing' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback'): number; @@ -134,7 +134,7 @@ export class Terminal implements ITerminalApi { public getOption(key: any): any { return this._core.optionsService.getOption(key); } - public setOption(key: 'bellSound' | 'fontFamily' | 'termName', value: string): void; + public setOption(key: 'bellSound' | 'fontFamily' | 'termName' | 'wordSeparator', value: string): void; public setOption(key: 'fontWeight' | 'fontWeightBold', value: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'): void; public setOption(key: 'bellStyle', value: 'none' | 'visual' | 'sound' | 'both'): void; public setOption(key: 'cursorStyle', value: 'block' | 'underline' | 'bar'): void; diff --git a/test/CharWidth.api.ts b/test/api/CharWidth.api.ts similarity index 100% rename from test/CharWidth.api.ts rename to test/api/CharWidth.api.ts diff --git a/test/InputHandler.api.ts b/test/api/InputHandler.api.ts similarity index 100% rename from test/InputHandler.api.ts rename to test/api/InputHandler.api.ts diff --git a/test/Terminal.api.ts b/test/api/Terminal.api.ts similarity index 100% rename from test/Terminal.api.ts rename to test/api/Terminal.api.ts diff --git a/test/tsconfig.json b/test/api/tsconfig.json similarity index 69% rename from test/tsconfig.json rename to test/api/tsconfig.json index fce9a1ba..2bd0a92b 100644 --- a/test/tsconfig.json +++ b/test/api/tsconfig.json @@ -5,9 +5,9 @@ "es6", ], "rootDir": ".", - "outDir": "../out/test", + "outDir": "../../out-test/api", "types": [ - "../node_modules/@types/mocha" + "../../node_modules/@types/mocha" ], "sourceMap": true, "removeComments": true, @@ -16,6 +16,6 @@ }, "include": [ "./**/*", - "../typings/xterm.d.ts" + "../../typings/xterm.d.ts" ] } diff --git a/test/benchmark/EscapeSequenceParser.benchmark.ts b/test/benchmark/EscapeSequenceParser.benchmark.ts new file mode 100644 index 00000000..b9a6b0df --- /dev/null +++ b/test/benchmark/EscapeSequenceParser.benchmark.ts @@ -0,0 +1,282 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { perfContext, before, beforeEach, ThroughputRuntimeCase } from 'xterm-benchmark'; + +import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; +import { C0, C1 } from 'common/data/EscapeSequences'; +import { IDcsHandler, IParams } from 'common/parser/Types'; + + +function toUtf32(s: string): Uint32Array { + const result = new Uint32Array(s.length); + for (let i = 0; i < s.length; ++i) { + result[i] = s.charCodeAt(i); + } + return result; +} + +class DcsHandler implements IDcsHandler { + hook(collect: string, params: IParams, flag: number) : void {} + put(data: Uint32Array, start: number, end: number) : void {} + unhook() :void {} +} + + +perfContext('Parser throughput - 50MB data', () => { + let parsed: Uint32Array; + let parser: EscapeSequenceParser; + + beforeEach(() => { + parser = new EscapeSequenceParser(); + parser.setPrintHandler((data, start, end) => {}); + parser.setCsiHandler('@', (params, collect) => {}); + parser.setCsiHandler('A', (params, collect) => {}); + parser.setCsiHandler('B', (params, collect) => {}); + parser.setCsiHandler('C', (params, collect) => {}); + parser.setCsiHandler('D', (params, collect) => {}); + parser.setCsiHandler('E', (params, collect) => {}); + parser.setCsiHandler('F', (params, collect) => {}); + parser.setCsiHandler('G', (params, collect) => {}); + parser.setCsiHandler('H', (params, collect) => {}); + parser.setCsiHandler('I', (params, collect) => {}); + parser.setCsiHandler('J', (params, collect) => {}); + parser.setCsiHandler('K', (params, collect) => {}); + parser.setCsiHandler('L', (params, collect) => {}); + parser.setCsiHandler('M', (params, collect) => {}); + parser.setCsiHandler('P', (params, collect) => {}); + parser.setCsiHandler('S', (params, collect) => {}); + parser.setCsiHandler('T', (params, collect) => {}); + parser.setCsiHandler('X', (params, collect) => {}); + parser.setCsiHandler('Z', (params, collect) => {}); + parser.setCsiHandler('`', (params, collect) => {}); + parser.setCsiHandler('a', (params, collect) => {}); + parser.setCsiHandler('b', (params, collect) => {}); + parser.setCsiHandler('c', (params, collect) => {}); + parser.setCsiHandler('d', (params, collect) => {}); + parser.setCsiHandler('e', (params, collect) => {}); + parser.setCsiHandler('f', (params, collect) => {}); + parser.setCsiHandler('g', (params, collect) => {}); + parser.setCsiHandler('h', (params, collect) => {}); + parser.setCsiHandler('l', (params, collect) => {}); + parser.setCsiHandler('m', (params, collect) => {}); + parser.setCsiHandler('n', (params, collect) => {}); + parser.setCsiHandler('p', (params, collect) => {}); + parser.setCsiHandler('q', (params, collect) => {}); + parser.setCsiHandler('r', (params, collect) => {}); + parser.setCsiHandler('s', (params, collect) => {}); + parser.setCsiHandler('u', (params, collect) => {}); + parser.setExecuteHandler(C0.BEL, () => {}); + parser.setExecuteHandler(C0.LF, () => {}); + parser.setExecuteHandler(C0.VT, () => {}); + parser.setExecuteHandler(C0.FF, () => {}); + parser.setExecuteHandler(C0.CR, () => {}); + parser.setExecuteHandler(C0.BS, () => {}); + parser.setExecuteHandler(C0.HT, () => {}); + parser.setExecuteHandler(C0.SO, () => {}); + parser.setExecuteHandler(C0.SI, () => {}); + parser.setExecuteHandler(C1.IND, () => {}); + parser.setExecuteHandler(C1.NEL, () => {}); + parser.setExecuteHandler(C1.HTS, () => {}); + parser.setOscHandler(0, (data) => {}); + parser.setOscHandler(2, (data) => {}); + parser.setEscHandler('7', () => {}); + parser.setEscHandler('8', () => {}); + parser.setEscHandler('D', () => {}); + parser.setEscHandler('E', () => {}); + parser.setEscHandler('H', () => {}); + parser.setEscHandler('M', () => {}); + parser.setEscHandler('=', () => {}); + parser.setEscHandler('>', () => {}); + parser.setEscHandler('c', () => {}); + parser.setEscHandler('n', () => {}); + parser.setEscHandler('o', () => {}); + parser.setEscHandler('|', () => {}); + parser.setEscHandler('}', () => {}); + parser.setEscHandler('~', () => {}); + parser.setEscHandler('%@', () => {}); + parser.setEscHandler('%G', () => {}); + parser.setDcsHandler('q', new DcsHandler()); + }); + + perfContext('PRINT - a', () => { + before(() => { + const data = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', async () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('EXECUTE - \\n', () => { + before(() => { + const data = '\n\n\n\n\n\n\n'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('ESCAPE - ESC E', () => { + before(() => { + const data = '\x1bE\x1bE\x1bE\x1bE\x1bE\x1bE\x1bE\x1bE\x1bE\x1bE'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('ESCAPE with collect - ESC % G', () => { + before(() => { + const data = '\x1b%G\x1b%G\x1b%G\x1b%G\x1b%G\x1b%G\x1b%G\x1b%G\x1b%G\x1b%G'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('CSI - CSI A', () => { + before(() => { + const data = '\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A\x1b[A'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('CSI with collect - CSI ? p', () => { + before(() => { + const data = '\x1b[?p\x1b[?p\x1b[?p\x1b[?p\x1b[?p\x1b[?p\x1b[?p\x1b[?p\x1b[?p\x1b[?p'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('CSI with params (short) - CSI 1;2 m', () => { + before(() => { + const data = '\x1b[1;2m\x1b[1;2m\x1b[1;2m\x1b[1;2m\x1b[1;2m\x1b[1;2m\x1b[1;2m\x1b[1;2m\x1b[1;2m\x1b[1;2m'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('CSI with params (long) - CSI 1;2;3;4;5;6;7;8;9;0 m', () => { + before(() => { + const data = '\x1b[1;2;3;4;5;6;7;8;9;0m\x1b[1;2;3;4;5;6;7;8;9;0m\x1b[1;2;3;4;5;6;7;8;9;0m'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('OSC (short) - OSC 0;hi ST', () => { + before(() => { + const data = '\x1b]0;hi\x1b\\\x1b]0;hi\x1b\\\x1b]0;hi\x1b\\\x1b]0;hi\x1b\\x1b]0;hi\x1b\\'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('OSC (long) - OSC 0; ST', () => { + before(() => { + const data = '\x1b]0;Lorem ipsum dolor sit amet, consetetur sadipscing elitr.\x1b\\'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('DCS (short)', () => { + before(() => { + const data = '\x1bPq~~\x1b\\'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', async () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); + + perfContext('DCS (long)', () => { + before(() => { + const data = '\x1bPq#0;2;0;0;0#1;2;100;100;0#2;2;0;100;0#1~~@@vv@@~~@@~~$#2??}}GG}}??}}??-#1!14@\x1b\\'; + let content = ''; + while (content.length < 50000000) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', async () => { + parser.parse(parsed, parsed.length); + return {payloadSize: parsed.length}; + }, {fork: true}).showAverageThroughput(); + }); +}); diff --git a/test/benchmark/Terminal.benchmark.ts b/test/benchmark/Terminal.benchmark.ts new file mode 100644 index 00000000..a0b8fd29 --- /dev/null +++ b/test/benchmark/Terminal.benchmark.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, ThroughputRuntimeCase } from 'xterm-benchmark'; + +import { spawn } from 'node-pty'; +import { Utf8ToUtf32, stringFromCodePoint } from 'common/input/TextDecoder'; +import { Terminal } from 'Terminal'; + +class TestTerminal extends Terminal { + writeSync(data: string): void { + this.writeBuffer.push(data); + this._innerWrite(); + } + writeSyncUtf8(data: Uint8Array): void { + this.writeBufferUtf8.push(data); + this._innerWriteUtf8(); + } +} + +perfContext('Terminal: ls -lR /usr/lib', () => { + let content = ''; + let contentUtf8: Uint8Array; + + before(async () => { + // grab output from "ls -lR /usr" + const p = spawn('ls', ['--color=auto', '-lR', '/usr/lib'], { + name: 'xterm-256color', + cols: 80, + rows: 25, + cwd: process.env.HOME, + env: process.env, + encoding: (null as unknown as string) // needs to be fixed in node-pty + }); + const chunks: Buffer[] = []; + let length = 0; + p.on('data', data => { + chunks.push(data as unknown as Buffer); + length += data.length; + }); + await new Promise(resolve => p.on('exit', () => resolve())); + contentUtf8 = Buffer.concat(chunks, length); + // translate to content string + const buffer = new Uint32Array(contentUtf8.length); + const decoder = new Utf8ToUtf32(); + const codepoints = decoder.decode(contentUtf8, buffer); + for (let i = 0; i < codepoints; ++i) { + content += stringFromCodePoint(buffer[i]); + // peek into content to force flat repr in v8 + if (!(i % 10000000)) { + content[i]; + } + } + }); + + perfContext('write', () => { + let terminal: TestTerminal; + before(() => { + terminal = new TestTerminal({cols: 80, rows: 25, scrollback: 1000}); + }); + new ThroughputRuntimeCase('', () => { + terminal.writeSync(content); + return {payloadSize: contentUtf8.length}; + }, {fork: false}).showAverageThroughput(); + }); + + perfContext('writeUtf8', () => { + let terminal: TestTerminal; + before(() => { + terminal = new TestTerminal({cols: 80, rows: 25, scrollback: 1000}); + }); + new ThroughputRuntimeCase('', () => { + terminal.writeSyncUtf8(contentUtf8); + return {payloadSize: contentUtf8.length}; + }, {fork: false}).showAverageThroughput(); + }); +}); diff --git a/test/benchmark/benchmark.json b/test/benchmark/benchmark.json new file mode 100644 index 00000000..f8b99b55 --- /dev/null +++ b/test/benchmark/benchmark.json @@ -0,0 +1,19 @@ +{ + "APP_PATH": ".benchmark", + "evalConfig": { + "tolerance": { + "*": [0.75, 1.5], + "*.dev": [0.01, 1.5], + "*.cv": [0.01, 1.5], + "EscapeSequenceParser.benchmark.js.*.averageThroughput.mean": [0.9, 5] + }, + "skip": [ + "*.median", + "*.runs", + "*.dev", + "*.cv", + "EscapeSequenceParser.benchmark.js.*.averageRuntime", + "Terminal.benchmark.js.*.averageRuntime" + ] + } +} diff --git a/test/benchmark/tsconfig.json b/test/benchmark/tsconfig.json new file mode 100644 index 00000000..8b93dcb1 --- /dev/null +++ b/test/benchmark/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "es6", + ], + "outDir": "../../out-test/benchmark", + "types": [ + "../../node_modules/@types/node" + ], + "moduleResolution": "node", + "strict": false, + "target": "es2015", + "module": "commonjs", + "baseUrl": ".", + "paths": { + "common/*": [ "../../src/common/*" ], + "browser/*": [ "../../src/browser/*" ], + "Terminal": [ "../../src/Terminal" ] + }, + }, + "include": [ + "./**/*", + "../../typings/xterm.d.ts", + "../../out/**/*" + ], + "exclude": [ + "../../**/*test.ts" + ], + "references": [ + { "path": "../../src/common" }, + { "path": "../../src/browser" }, + ] +} \ No newline at end of file diff --git a/tsconfig.all.json b/tsconfig.all.json index d2670811..7ddf33d5 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -3,10 +3,12 @@ "include": [], "references": [ { "path": "./src" }, - { "path": "./test" }, + { "path": "./test/api" }, + { "path": "./test/benchmark" }, { "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" } ] } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 6e5377db..2f161757 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -177,6 +177,12 @@ declare module 'xterm' { * not whitespace. */ windowsMode?: boolean; + + /** + * A string containing all characters that are considered word separated by the + * double click to select work logic. + */ + wordSeparator?: string; } /** @@ -673,7 +679,7 @@ declare module 'xterm' { * Retrieves an option's value from the terminal. * @param key The option key. */ - getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold'| 'rendererType' | 'termName'): string; + getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold'| 'rendererType' | 'termName' | 'wordSeparator'): string; /** * Retrieves an option's value from the terminal. * @param key The option key. @@ -705,7 +711,7 @@ declare module 'xterm' { * @param key The option key. * @param value The option value. */ - setOption(key: 'fontFamily' | 'termName' | 'bellSound', value: string): void; + setOption(key: 'fontFamily' | 'termName' | 'bellSound' | 'wordSeparator', value: string): void; /** * Sets an option on the terminal. * @param key The option key. diff --git a/yarn.lock b/yarn.lock index 2c2be0f3..9a7bfe1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,22 @@ # yarn lockfile v1 +"@babel/code-frame@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" + integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA== + dependencies: + "@babel/highlight" "^7.0.0" + +"@babel/highlight@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" + integrity sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw== + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^4.0.0" + "@fimbul/bifrost@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@fimbul/bifrost/-/bifrost-0.11.0.tgz#83cacc21464198b12e3cc1c2204ae6c6d7afd158" @@ -21,16 +37,33 @@ reflect-metadata "^0.1.12" tslib "^1.8.1" +"@types/app-root-path@^1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@types/app-root-path/-/app-root-path-1.2.4.tgz#a78b703282b32ac54de768f5512ecc3569919dc7" + integrity sha1-p4twMoKzKsVN52j1US7MNWmRncc= + "@types/chai@^3.4.34": version "3.5.2" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-3.5.2.tgz#c11cd2817d3a401b7ba0f5a420f35c56139b1c1e" integrity sha1-wRzSgX06QBt7oPWkIPNcVhObHB4= +"@types/cli-table@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@types/cli-table/-/cli-table-0.3.0.tgz#f1857156bf5fd115c6a2db260ba0be1f8fc5671c" + integrity sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ== + "@types/events@*": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86" integrity sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA== +"@types/fs-extra@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-7.0.0.tgz#9c4ad9e1339e7448a76698829def1f159c1b636c" + integrity sha512-ndoMMbGyuToTy4qB6Lex/inR98nPiNHacsgMPvy+zqMLgSxbt8VtWpDArpGp69h1fEDQHn1KB+9DWD++wgbwYA== + dependencies: + "@types/node" "*" + "@types/glob@^5.0.35": version "5.0.35" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-5.0.35.tgz#1ae151c802cece940443b5ac246925c85189f32a" @@ -49,6 +82,13 @@ "@types/tough-cookie" "*" parse5 "^3.0.2" +"@types/mathjs@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/mathjs/-/mathjs-5.0.1.tgz#b98e163ea396b4f27bec20ee25ffb8fe9e656af8" + integrity sha512-EFBuueI+BRed9bnUO6/9my55b4FH+VQIvqMm58h9JGbtaGCkqr3YSDhnmVbM1SJjF//8SURERSypzNwejOk7lA== + dependencies: + decimal.js "^10.0.0" + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -59,6 +99,11 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-2.2.48.tgz#3523b126a0b049482e1c3c11877460f76622ffab" integrity sha512-nlK/iyETgafGli8Zh9zJVCTicvU3iajSkRwOh3Hhiva598CMqNJ4NcVCGMTGKpGpTYj/9R8RLzS9NAykSSCqGw== +"@types/mocha@^5.2.7": + version "5.2.7" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" + integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== + "@types/node@*": version "10.5.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.2.tgz#f19f05314d5421fe37e74153254201a7bf00a707" @@ -69,6 +114,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.108.tgz#852e8496bcfc5e74cae83a5eb3b30e5661e9b7b9" integrity sha512-5q14jNJCPW+Iwk6Y1JxtA7T5ov1aVRS2VA2PvRgFMZtCjoIo8WT1WO56dSV0MSiHR7BEoe2QNuXigBQNqbWdAw== +"@types/node@^12.0.4": + version "12.0.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.0.5.tgz#ac14404c33d1a789973c45379a67f7f7e58a01b9" + integrity sha512-CFLSALoE+93+Hcb5pFjp0J1uMrrbLRe+L1+gFwerJ776R3TACSF0kTVRQ7AvRa7aFx70nqYHAc7wQPlt9kY2Mg== + "@types/puppeteer@^1.12.4": version "1.12.4" resolved "https://registry.yarnpkg.com/@types/puppeteer/-/puppeteer-1.12.4.tgz#8388efdb0b30a54a7e7c4831ca0d709191d77ff1" @@ -389,6 +439,11 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" +app-root-path@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.2.1.tgz#d0df4a682ee408273583d43f6f79e9892624bc9a" + integrity sha512-91IFKeKk7FjfmezPKkwtaRvSpnUc4gDwPAjA1YZ9Gn0q0PPeW+vbeUsZuyDwjI7+QTHhcLen2v25fi/AmhvbJA== + aproba@^1.0.3, aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -492,6 +547,13 @@ async@^2.5.0: dependencies: lodash "^4.17.10" +async@^2.6.1: + version "2.6.2" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381" + integrity sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg== + dependencies: + lodash "^4.17.11" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -815,6 +877,25 @@ chownr@^1.0.1: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" integrity sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE= +chrome-devtools-frontend@1.0.445684: + version "1.0.445684" + resolved "https://registry.yarnpkg.com/chrome-devtools-frontend/-/chrome-devtools-frontend-1.0.445684.tgz#8540131836024df2b70fe90d0322af368931d762" + integrity sha1-hUATGDYCTfK3D+kNAyKvNokx12I= + +chrome-timeline@0.0.12: + version "0.0.12" + resolved "https://registry.yarnpkg.com/chrome-timeline/-/chrome-timeline-0.0.12.tgz#1516223b4bf289750b4b244c3f88d0095b10f914" + integrity sha512-lDVZGV2VYVS7kTmoLxwOmoTvKLxBHRiBQqGNvD87Bky+uIsnK6ThwESqIj+rsjx218Vq9tU5H6Bv2u6IsafS/g== + dependencies: + "@types/app-root-path" "^1.2.4" + "@types/fs-extra" "^7.0.0" + "@types/puppeteer" "^1.12.4" + app-root-path "^2.2.1" + devtools-timeline-model "^1.4.0" + puppeteer "^1.17.0" + simple-git "^1.113.0" + winston "^3.2.1" + chrome-trace-event@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.0.tgz#45a91bd2c20c9411f0963b5aaeb9a1b95e09cc48" @@ -847,6 +928,13 @@ cli-cursor@^2.1.0: dependencies: restore-cursor "^2.0.0" +cli-table@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23" + integrity sha1-9TsFJmqLGguTSz0IIebi3FkUriM= + dependencies: + colors "1.0.3" + cli-width@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" @@ -861,6 +949,11 @@ cliui@^4.0.0: strip-ansi "^4.0.0" wrap-ansi "^2.0.0" +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -886,11 +979,75 @@ color-convert@^1.9.0: dependencies: color-name "1.1.1" +color-convert@^1.9.1: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + color-name@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.1.tgz#4b1415304cf50028ea81643643bd82ea05803689" integrity sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok= +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^1.5.2: + version "1.5.3" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" + integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@3.0.x: + version "3.0.0" + resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" + integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + +colornames@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96" + integrity sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y= + +colors@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" + integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs= + +colors@^1.2.1: + version "1.3.3" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.3.tgz#39e005d546afe01e01f9c4ca8fa50f686a01205d" + integrity sha512-mmGt/1pZqYRjMxB1axhTo16/snVZ5krrKkcmMeVKxzECMMXoCgnvTPp10QgHfcbQZw8Dq2jMNG6je4JlWU0gWg== + +colorspace@1.1.x: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5" + integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ== + dependencies: + color "3.0.x" + text-hex "1.0.x" + +columnify@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/columnify/-/columnify-1.5.4.tgz#4737ddf1c7b69a8a7c340570782e947eec8e78bb" + integrity sha1-Rzfd8ce2mop8NAVweC6UfuyOeLs= + dependencies: + strip-ansi "^3.0.0" + wcwidth "^1.0.0" + combined-stream@1.0.6, combined-stream@~1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.6.tgz#723e7df6e801ac5613113a7e445a9b69cb632818" @@ -903,6 +1060,11 @@ commander@^2.12.1: resolved "https://registry.yarnpkg.com/commander/-/commander-2.16.0.tgz#f16390593996ceb4f3eeb020b31d78528f7f8a50" integrity sha512-sVXqklSaotK9at437sFlFpyOcJonxe0yST/AG9DkQKUdIE6IqGIMv4SfAQSKaJbSdVEJYItASCrBiVQHq1HQew== +commander@^2.20.0: + version "2.20.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" + integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== + commander@~2.13.0: version "2.13.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" @@ -913,6 +1075,11 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= +complex.js@2.0.11: + version "2.0.11" + resolved "https://registry.yarnpkg.com/complex.js/-/complex.js-2.0.11.tgz#09a873fbf15ffd8c18c9c2201ccef425c32b8bf1" + integrity sha512-6IArJLApNtdg1P1dFtn3dnyzoZBEF0MwMnrfF1exSBRpZYoy4yieMkpZhQDC0uwctw48vii0CFVyHfpgZ/DfGw== + component-emitter@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" @@ -1119,7 +1286,7 @@ debug@^3.1.0: dependencies: ms "2.0.0" -debug@^4.1.0: +debug@^4.0.1, debug@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== @@ -1145,6 +1312,11 @@ decamelize@^2.0.0: dependencies: xregexp "4.0.0" +decimal.js@10.2.0, decimal.js@^10.0.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231" + integrity sha512-vDPw+rDgn3bZe1+F/pyEwb1oMG2XTlRVgAa6B4KccTEpYgF8w6eQllVbQcfIJnZyvzFtFpxnpGtx8dd7DJp/Rw== + decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -1167,6 +1339,13 @@ deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + integrity sha1-xlYFHpgX2f8I7YgUd/P+QBnz730= + dependencies: + clone "^1.0.2" + define-properties@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" @@ -1229,6 +1408,23 @@ detect-libc@^1.0.2: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= +devtools-timeline-model@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/devtools-timeline-model/-/devtools-timeline-model-1.4.0.tgz#91f9624fb0313fa3ebeda7bf99865357bc66c726" + integrity sha512-zjC31eo4yhPaGC6NnjpksA9ejq9sKtWrQXW9i790RfACXQFrZ/Ba/qcGcU3sqWww/3iZsnZpVDEiTUniKs4CvQ== + dependencies: + chrome-devtools-frontend "1.0.445684" + resolve "1.1.7" + +diagnostics@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a" + integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ== + dependencies: + colorspace "1.1.x" + enabled "1.0.x" + kuler "1.0.x" + diff@3.5.0, diff@^3.2.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -1300,6 +1496,13 @@ emojis-list@^2.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= +enabled@1.0.x: + version "1.0.2" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93" + integrity sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M= + dependencies: + env-variable "0.0.x" + end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.1" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" @@ -1316,6 +1519,11 @@ enhanced-resolve@^4.0.0, enhanced-resolve@^4.1.0: memory-fs "^0.4.0" tapable "^1.0.0" +env-variable@0.0.x: + version "0.0.5" + resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88" + integrity sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA== + errno@^0.1.3, errno@~0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" @@ -1361,6 +1569,11 @@ escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= +escape-latex@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/escape-latex/-/escape-latex-1.2.0.tgz#07c03818cf7dac250cce517f4fda1b001ef2bca1" + integrity sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw== + escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -1591,6 +1804,11 @@ fast-levenshtein@~2.0.4: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-safe-stringify@^2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz#04b26106cc56681f51a044cfc0d76cf0008ac2c2" + integrity sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg== + fd-slicer@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz#8b5bcbd9ec327c5041bf9ab023fd6750f1177e65" @@ -1598,6 +1816,11 @@ fd-slicer@~1.0.1: dependencies: pend "~1.2.0" +fecha@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd" + integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg== + figures@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" @@ -1687,6 +1910,11 @@ forwarded@~0.1.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= +fraction.js@4.0.12: + version "4.0.12" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.0.12.tgz#0526d47c65a5fb4854df78bc77f7bec708d7b8c3" + integrity sha512-8Z1K0VTG4hzYY7kA/1sj4/r1/RWLBD3xwReT/RCrUCbzPszjNQCCsy3ktkU/eaEqX3MYa4pY37a52eiBlPMlhA== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" @@ -2112,6 +2340,11 @@ is-accessor-descriptor@^1.0.0: dependencies: kind-of "^6.0.0" +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + is-binary-path@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" @@ -2289,12 +2522,22 @@ isstream@~0.1.2: resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= +javascript-natural-sort@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59" + integrity sha1-+eIwPUUH9tdDVac2ZNFED7Wg71k= + js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@3.13.1, js-yaml@^3.7.0: +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@3.13.1, js-yaml@^3.13.1: version "3.13.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== @@ -2403,6 +2646,13 @@ kind-of@^6.0.0, kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== +kuler@1.0.x: + version "1.0.1" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-1.0.1.tgz#ef7c784f36c9fb6e16dd3150d152677b2b0228a6" + integrity sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ== + dependencies: + colornames "^1.1.1" + lcid@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" @@ -2487,6 +2737,17 @@ log-symbols@2.2.0: dependencies: chalk "^2.0.1" +logform@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360" + integrity sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ== + dependencies: + colors "^1.2.1" + fast-safe-stringify "^2.0.4" + fecha "^2.3.3" + ms "^2.1.1" + triple-beam "^1.3.0" + long@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" @@ -2536,6 +2797,20 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +mathjs@^5.10.3: + version "5.10.3" + resolved "https://registry.yarnpkg.com/mathjs/-/mathjs-5.10.3.tgz#e998885f932ea8886db8b40f7f5b199f89b427f1" + integrity sha512-ySjg30BC3dYjQm73ILZtwcWzFJde0VU6otkXW/57IjjuYRa3Qaf0Kb8pydEuBZYtqW2OxreAtsricrAmOj3jIw== + dependencies: + complex.js "2.0.11" + decimal.js "10.2.0" + escape-latex "1.2.0" + fraction.js "4.0.12" + javascript-natural-sort "0.7.1" + seed-random "2.2.0" + tiny-emitter "2.1.0" + typed-function "1.1.0" + md5.js@^1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d" @@ -3007,6 +3282,11 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +one-time@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e" + integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4= + onetime@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" @@ -3368,6 +3648,20 @@ puppeteer@^1.15.0: rimraf "^2.6.1" ws "^6.1.0" +puppeteer@^1.17.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-1.17.0.tgz#371957d227a2f450fa74b78e78a2dadb2be7f14f" + integrity sha512-3EXZSximCzxuVKpIHtyec8Wm2dWZn1fc5tQi34qWfiUgubEVYHjUvr0GOJojqf3mifI6oyKnCdrGxaOI+lWReA== + dependencies: + debug "^4.1.0" + extract-zip "^1.6.6" + https-proxy-agent "^2.2.1" + mime "^2.0.3" + progress "^2.0.1" + proxy-from-env "^1.0.0" + rimraf "^2.6.1" + ws "^6.1.0" + qs@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/qs/-/qs-4.0.0.tgz#c31d9b74ec27df75e543a86c78728ed8d4623607" @@ -3431,6 +3725,15 @@ rc@^1.2.7: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.1.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc" + integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" @@ -3543,6 +3846,11 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= +resolve@1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= + resolve@^1.3.2: version "1.8.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" @@ -3629,6 +3937,11 @@ schema-utils@^0.4.4, schema-utils@^0.4.5: ajv "^6.1.0" ajv-keywords "^3.1.0" +seed-random@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/seed-random/-/seed-random-2.2.0.tgz#2a9b19e250a817099231a5b99a4daf80b7fbed54" + integrity sha1-KpsZ4lCoFwmSMaW5mk2vgLf77VQ= + semver@^5.0.1: version "5.5.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" @@ -3754,6 +4067,20 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= +simple-git@^1.113.0: + version "1.113.0" + resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-1.113.0.tgz#668989728a1e9cf4ec6c72b69ea2eecc93489bea" + integrity sha512-i9WVsrK2u0G/cASI9nh7voxOk9mhanWY9eGtWBDSYql6m49Yk5/Fan6uZsDr/xmzv8n+eQ8ahKCoEr8cvU3h+g== + dependencies: + debug "^4.0.1" + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + dependencies: + is-arrayish "^0.3.1" + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -3858,6 +4185,11 @@ ssri@^5.2.4: dependencies: safe-buffer "^5.1.1" +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -3946,6 +4278,13 @@ string_decoder@^1.0.0, string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +string_decoder@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d" + integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w== + dependencies: + safe-buffer "~5.1.0" + strip-ansi@^3.0.0, strip-ansi@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" @@ -4026,6 +4365,11 @@ tar@^4: safe-buffer "^5.1.2" yallist "^3.0.2" +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + through2@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be" @@ -4046,6 +4390,11 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +tiny-emitter@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -4105,6 +4454,11 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" +triple-beam@^1.2.0, triple-beam@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9" + integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw== + ts-loader@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-4.5.0.tgz#a1ce70b2dc799941fb2197605f0d67874097859b" @@ -4130,6 +4484,25 @@ tslint-consistent-codestyle@^1.13.0: tslib "^1.7.1" tsutils "^2.27.0" +tslint@^5.17.0: + version "5.17.0" + resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.17.0.tgz#f9f0ce2011d8e90debaa6e9b4975f24cd16852b8" + integrity sha512-pflx87WfVoYepTet3xLfDOLDm9Jqi61UXIKePOuca0qoAZyrGWonDG9VTbji58Fy+8gciUn8Bt7y69+KEVjc/w== + dependencies: + "@babel/code-frame" "^7.0.0" + builtin-modules "^1.1.1" + chalk "^2.3.0" + commander "^2.12.1" + diff "^3.2.0" + glob "^7.1.1" + js-yaml "^3.13.1" + minimatch "^3.0.4" + mkdirp "^0.5.1" + resolve "^1.3.2" + semver "^5.3.0" + tslib "^1.8.0" + tsutils "^2.29.0" + tslint@^5.9.1: version "5.10.0" resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.10.0.tgz#11e26bccb88afa02dd0d9956cae3d4540b5f54c3" @@ -4155,6 +4528,13 @@ tsutils@^2.12.1, tsutils@^2.24.0, tsutils@^2.27.0: dependencies: tslib "^1.8.1" +tsutils@^2.29.0: + version "2.29.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-2.29.0.tgz#32b488501467acbedd4b85498673a0812aca0b99" + integrity sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA== + dependencies: + tslib "^1.8.1" + tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" @@ -4197,12 +4577,17 @@ type-is@~1.6.6: media-typer "0.3.0" mime-types "~2.1.18" +typed-function@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/typed-function/-/typed-function-1.1.0.tgz#ea149706e0fb42aca1791c053a6d94ccd6c4fdcb" + integrity sha512-TuQzwiT4DDg19beHam3E66oRXhyqlyfgjHB/5fcvsRXbfmWPJfto9B4a0TBdTrQAPGlGmXh/k7iUI+WsObgORA== + typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@3.5: +typescript@3.5, typescript@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.1.tgz#ba72a6a600b2158139c5dd8850f700e231464202" integrity sha512-64HkdiRv1yYZsSe4xC1WVgamNigVYjlssIoaH2HcZF0+ijsk5YK2g0G34w9wJkze8+5ow4STd22AynfO6ZYYLw== @@ -4308,7 +4693,7 @@ utf8@^3.0.0: resolved "https://registry.yarnpkg.com/utf8/-/utf8-3.0.0.tgz#f052eed1364d696e769ef058b183df88c87f69d1" integrity sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ== -util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= @@ -4379,6 +4764,13 @@ watchpack@^1.5.0: graceful-fs "^4.1.2" neo-async "^2.5.0" +wcwidth@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= + dependencies: + defaults "^1.0.3" + webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -4488,6 +4880,29 @@ wide-align@1.1.3, wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" +winston-transport@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.3.0.tgz#df68c0c202482c448d9b47313c07304c2d7c2c66" + integrity sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A== + dependencies: + readable-stream "^2.3.6" + triple-beam "^1.2.0" + +winston@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.2.1.tgz#63061377976c73584028be2490a1846055f77f07" + integrity sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw== + dependencies: + async "^2.6.1" + diagnostics "^1.1.1" + is-stream "^1.1.0" + logform "^2.1.1" + one-time "0.0.4" + readable-stream "^3.1.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.3.0" + wordwrap@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" @@ -4558,6 +4973,26 @@ xtend@^4.0.0, xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= +xterm-benchmark@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/xterm-benchmark/-/xterm-benchmark-0.1.3.tgz#c637d078f7b73f77a4342299e706b4d0e52ab46a" + integrity sha512-HBSeUOFlr9JVMFNkL5w8EkuWccczkfZAX6adK5fSot1sRRAFJS9NZcXH/yRLZp9S24qOUBJznMZGa9CjWB3h3g== + dependencies: + "@types/app-root-path" "^1.2.4" + "@types/cli-table" "^0.3.0" + "@types/mathjs" "^5.0.1" + "@types/mocha" "^5.2.7" + "@types/node" "^12.0.4" + app-root-path "^2.2.1" + chrome-timeline "0.0.12" + cli-table "^0.3.1" + columnify "^1.5.4" + commander "^2.20.0" + mathjs "^5.10.3" + mocha "^6.1.4" + tslint "^5.17.0" + typescript "^3.5.1" + "y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"