From 061854b5356f972095579cfb7b35dfb73b178acc Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Sun, 19 May 2019 00:55:02 -0700 Subject: [PATCH] Remove more dependencies on rest of project --- src/renderer/webgl/GlyphRenderer.ts | 3 +- src/renderer/webgl/Lifecycle.test.ts | 45 ++ src/renderer/webgl/Lifecycle.ts | 47 +++ src/renderer/webgl/Platform.ts | 10 + src/renderer/webgl/RectangleRenderer.ts | 3 +- src/renderer/webgl/WebglRenderer.ts | 31 +- .../webgl/atlas/CharAtlasGenerator.ts | 2 +- .../webgl/renderLayer/BaseRenderLayer.ts | 384 ++++++++++++++++++ .../webgl/renderLayer/CursorRenderLayer.ts | 358 ++++++++++++++++ .../webgl/renderLayer/LinkRenderLayer.ts | 71 ++++ src/renderer/webgl/renderLayer/Types.ts | 65 +++ typings/xterm.d.ts | 41 +- 12 files changed, 1039 insertions(+), 21 deletions(-) create mode 100644 src/renderer/webgl/Lifecycle.test.ts create mode 100644 src/renderer/webgl/Lifecycle.ts create mode 100644 src/renderer/webgl/Platform.ts create mode 100644 src/renderer/webgl/renderLayer/BaseRenderLayer.ts create mode 100644 src/renderer/webgl/renderLayer/CursorRenderLayer.ts create mode 100644 src/renderer/webgl/renderLayer/LinkRenderLayer.ts create mode 100644 src/renderer/webgl/renderLayer/Types.ts diff --git a/src/renderer/webgl/GlyphRenderer.ts b/src/renderer/webgl/GlyphRenderer.ts index de9c0bf7..545a666e 100644 --- a/src/renderer/webgl/GlyphRenderer.ts +++ b/src/renderer/webgl/GlyphRenderer.ts @@ -4,7 +4,6 @@ */ import { createProgram, PROJECTION_MATRIX } from './WebglUtils'; -import { IRenderDimensions } from '../Types'; import WebglCharAtlas from './atlas/WebglCharAtlas'; import { IWebGL2RenderingContext, IWebGLVertexArrayObject, IRenderModel, IRasterizedGlyph } from './Types'; import { INDICIES_PER_CELL } from './WebglRenderer'; @@ -12,7 +11,7 @@ import { COMBINED_CHAR_BIT_MASK } from './RenderModel'; import { fill, slice } from './TypedArray'; import { NULL_CELL_CODE, WHITESPACE_CELL_CODE } from '../../core/buffer/BufferLine'; import { getLuminance } from './ColorUtils'; -import { IColorSet, Terminal, IBufferLine } from 'xterm'; +import { IColorSet, Terminal, IBufferLine, IRenderDimensions } from 'xterm'; interface IVertices { attributes: Float32Array; diff --git a/src/renderer/webgl/Lifecycle.test.ts b/src/renderer/webgl/Lifecycle.test.ts new file mode 100644 index 00000000..4b696fa5 --- /dev/null +++ b/src/renderer/webgl/Lifecycle.test.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { Disposable } from './Lifecycle'; + +class TestDisposable extends Disposable { + public get isDisposed(): boolean { + return this._isDisposed; + } +} + +describe('Disposable', () => { + describe('register', () => { + it('should register disposables', () => { + const d = new TestDisposable(); + const d2 = { + dispose: () => { throw new Error(); } + }; + d.register(d2); + assert.throws(() => d.dispose()); + }); + }); + describe('unregister', () => { + it('should unregister disposables', () => { + const d = new TestDisposable(); + const d2 = { + dispose: () => { throw new Error(); } + }; + d.register(d2); + d.unregister(d2); + assert.doesNotThrow(() => d.dispose()); + }); + }); + describe('dispose', () => { + it('should set is disposed flag', () => { + const d = new TestDisposable(); + assert.isFalse(d.isDisposed); + d.dispose(); + assert.isTrue(d.isDisposed); + }); + }); +}); diff --git a/src/renderer/webgl/Lifecycle.ts b/src/renderer/webgl/Lifecycle.ts new file mode 100644 index 00000000..209a3e2a --- /dev/null +++ b/src/renderer/webgl/Lifecycle.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable } from 'xterm'; + +/** + * A base class that can be extended to provide convenience methods for managing the lifecycle of an + * object and its components. + */ +export abstract class Disposable implements IDisposable { + protected _disposables: IDisposable[] = []; + protected _isDisposed: boolean = false; + + constructor() { + } + + /** + * Disposes the object, triggering the `dispose` method on all registered IDisposables. + */ + public dispose(): void { + this._isDisposed = true; + this._disposables.forEach(d => d.dispose()); + this._disposables.length = 0; + } + + /** + * Registers a disposable object. + * @param d The disposable to register. + */ + public register(d: T): void { + this._disposables.push(d); + } + + /** + * Unregisters a disposable object if it has been registered, if not do + * nothing. + * @param d The disposable to unregister. + */ + public unregister(d: T): void { + const index = this._disposables.indexOf(d); + if (index !== -1) { + this._disposables.splice(index, 1); + } + } +} diff --git a/src/renderer/webgl/Platform.ts b/src/renderer/webgl/Platform.ts new file mode 100644 index 00000000..55a10b7f --- /dev/null +++ b/src/renderer/webgl/Platform.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const isNode = (typeof navigator === 'undefined') ? true : false; +const userAgent = (isNode) ? 'node' : navigator.userAgent; + +export const isFirefox = !!~userAgent.indexOf('Firefox'); +export const isSafari = /^((?!chrome|android).)*safari/i.test(userAgent); diff --git a/src/renderer/webgl/RectangleRenderer.ts b/src/renderer/webgl/RectangleRenderer.ts index ca4c062c..3fc8413c 100644 --- a/src/renderer/webgl/RectangleRenderer.ts +++ b/src/renderer/webgl/RectangleRenderer.ts @@ -3,14 +3,13 @@ * @license MIT */ -import { IRenderDimensions } from '../Types'; import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils'; import { IRenderModel, IWebGLVertexArrayObject, IWebGL2RenderingContext, ISelectionRenderModel } from './Types'; import { fill } from './TypedArray'; import { INVERTED_DEFAULT_COLOR } from './atlas/Types'; import { is256Color } from './atlas/CharAtlasUtils'; import { DEFAULT_COLOR } from '../../common/Types'; -import { IColorSet, IColor, Terminal } from 'xterm'; +import { IColorSet, IColor, Terminal, IRenderDimensions } from 'xterm'; const enum VertexAttribLocations { POSITION = 0, diff --git a/src/renderer/webgl/WebglRenderer.ts b/src/renderer/webgl/WebglRenderer.ts index 2fadebbd..68f74c7b 100644 --- a/src/renderer/webgl/WebglRenderer.ts +++ b/src/renderer/webgl/WebglRenderer.ts @@ -3,22 +3,23 @@ * @license MIT */ -import { IRenderer, IRenderDimensions, IRenderLayer, FLAGS } from '../Types'; +import { FLAGS } from '../Types'; import { CharacterJoinerHandler, ITerminal } from '../../Types'; import { GlyphRenderer } from './GlyphRenderer'; -import { LinkRenderLayer } from '../LinkRenderLayer'; -import { CursorRenderLayer } from '../CursorRenderLayer'; +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 './atlas/Types'; import { RenderModel, COMBINED_CHAR_BIT_MASK } from './RenderModel'; -import { Disposable } from '../../common/Lifecycle'; +import { Disposable } from './Lifecycle'; import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_ATTR_INDEX, NULL_CELL_CODE } from '../../core/buffer/BufferLine'; import { DEFAULT_COLOR } from '../../common/Types'; -import { IColorSet, Terminal } from 'xterm'; +import { IColorSet, Terminal, IRenderDimensions, IRenderer } from 'xterm'; import { getLuminance } from './ColorUtils'; +import { IRenderLayer } from './renderLayer/Types'; export const INDICIES_PER_CELL = 4; @@ -106,8 +107,8 @@ export class WebglRenderer extends Disposable implements IRenderer { // Clear layers and force a full render this._renderLayers.forEach(l => { - l.setColors(this._core, this._colors); - l.reset(this._core); + l.setColors(this._terminal, this._colors); + l.reset(this._terminal); }); this._rectangleRenderer.setColors(); @@ -133,7 +134,7 @@ export class WebglRenderer extends Disposable implements IRenderer { this._rectangleRenderer.onResize(); // Resize all render layers - this._renderLayers.forEach(l => l.resize(this._core, this.dimensions)); + this._renderLayers.forEach(l => l.resize(this._terminal, this.dimensions)); // Resize the canvas this._canvas.width = this.dimensions.scaledCanvasWidth; @@ -155,15 +156,15 @@ export class WebglRenderer extends Disposable implements IRenderer { } public onBlur(): void { - this._renderLayers.forEach(l => l.onBlur(this._core)); + this._renderLayers.forEach(l => l.onBlur(this._terminal)); } public onFocus(): void { - this._renderLayers.forEach(l => l.onFocus(this._core)); + 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._core, start, end, columnSelectMode)); + this._renderLayers.forEach(l => l.onSelectionChanged(this._terminal, start, end, columnSelectMode)); this._updateSelectionModel(start, end); @@ -175,11 +176,11 @@ export class WebglRenderer extends Disposable implements IRenderer { } public onCursorMove(): void { - this._renderLayers.forEach(l => l.onCursorMove(this._core)); + this._renderLayers.forEach(l => l.onCursorMove(this._terminal)); } public onOptionsChanged(): void { - this._renderLayers.forEach(l => l.onOptionsChanged(this._core)); + this._renderLayers.forEach(l => l.onOptionsChanged(this._terminal)); this._updateDimensions(); this._refreshCharAtlas(); } @@ -204,7 +205,7 @@ export class WebglRenderer extends Disposable implements IRenderer { } public clear(): void { - this._renderLayers.forEach(l => l.reset(this._core)); + this._renderLayers.forEach(l => l.reset(this._terminal)); } public registerCharacterJoiner(handler: CharacterJoinerHandler): number { @@ -217,7 +218,7 @@ export class WebglRenderer extends Disposable implements IRenderer { public renderRows(start: number, end: number): void { // Update render layers - this._renderLayers.forEach(l => l.onGridChanged(this._core, start, end)); + this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); // Tell renderer the frame is beginning if (this._glyphRenderer.beginFrame()) { diff --git a/src/renderer/webgl/atlas/CharAtlasGenerator.ts b/src/renderer/webgl/atlas/CharAtlasGenerator.ts index 0d343a43..c01ea630 100644 --- a/src/renderer/webgl/atlas/CharAtlasGenerator.ts +++ b/src/renderer/webgl/atlas/CharAtlasGenerator.ts @@ -4,8 +4,8 @@ */ import { FontWeight, IColor } from 'xterm'; -import { isFirefox, isSafari } from '../../../common/Platform'; import { ICharAtlasConfig, CHAR_ATLAS_CELL_SPACING } from './Types'; +import { isFirefox, isSafari } from '../Platform'; /** * Generates a char atlas. diff --git a/src/renderer/webgl/renderLayer/BaseRenderLayer.ts b/src/renderer/webgl/renderLayer/BaseRenderLayer.ts new file mode 100644 index 00000000..36c5bd53 --- /dev/null +++ b/src/renderer/webgl/renderLayer/BaseRenderLayer.ts @@ -0,0 +1,384 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderLayer } from './Types'; +import { ICellData } from '../../../core/Types'; +import { DEFAULT_COLOR } from '../../../common/Types'; +import { DIM_OPACITY, INVERTED_DEFAULT_COLOR, IGlyphIdentifier } from '../atlas/Types'; +import BaseCharAtlas from '../atlas/BaseCharAtlas'; +import { acquireCharAtlas } from '../atlas/CharAtlasCache'; +import { CellData, AttributeData, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE } from '../../../core/buffer/BufferLine'; +import { IColorSet, IRenderDimensions, Terminal } from 'xterm'; + +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() && terminal.getOption('enableBold'); + 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() && terminal.getOption('enableBold'), !!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/src/renderer/webgl/renderLayer/CursorRenderLayer.ts b/src/renderer/webgl/renderLayer/CursorRenderLayer.ts new file mode 100644 index 00000000..e51bdb7c --- /dev/null +++ b/src/renderer/webgl/renderLayer/CursorRenderLayer.ts @@ -0,0 +1,358 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderDimensions, IColorSet, Terminal } from 'xterm'; +import { BaseRenderLayer } from './BaseRenderLayer'; +import { ICellData } from '../../../core/Types'; +import { CellData } from '../../../core/buffer/BufferLine'; + +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 buffere API for loading cell + (terminal as any)._core.buffer.getLine(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/src/renderer/webgl/renderLayer/LinkRenderLayer.ts b/src/renderer/webgl/renderLayer/LinkRenderLayer.ts new file mode 100644 index 00000000..eb47f7e5 --- /dev/null +++ b/src/renderer/webgl/renderLayer/LinkRenderLayer.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ILinkifierEvent, ILinkifierAccessor } from '../../../Types'; +import { IRenderDimensions, IColorSet, Terminal } from 'xterm'; +import { BaseRenderLayer } from './BaseRenderLayer'; +import { INVERTED_DEFAULT_COLOR } from '../atlas/Types'; +import { is256Color } from '../atlas/CharAtlasUtils'; + +export class LinkRenderLayer extends BaseRenderLayer { + private _state: ILinkifierEvent = null; + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet, terminal: ILinkifierAccessor) { + super(container, 'link', zIndex, true, colors); + // TODO: Need to expose link-related renderer API + 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/src/renderer/webgl/renderLayer/Types.ts b/src/renderer/webgl/renderLayer/Types.ts new file mode 100644 index 00000000..61e69f38 --- /dev/null +++ b/src/renderer/webgl/renderLayer/Types.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable, IRenderDimensions, IColorSet, Terminal } from 'xterm'; +import { ICharacterJoiner } from '../../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?(joiner: ICharacterJoiner): 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/typings/xterm.d.ts b/typings/xterm.d.ts index 8712c3d8..a48e8a21 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -894,12 +894,51 @@ declare module 'xterm' { /** * (EXPERIMENTAL) */ - setRenderer(renderer: any): void; + setRenderer(renderer: IRenderer): void; screenElement: HTMLElement; } export namespace Renderer { const DEFAULT_COLOR: number; + const NULL_CELL_CODE: number; + const WHITESPACE_CELL_CODE: number; + const DEFAULT_ATTR: number; + const DEFAULT_ANSI_COLORS: string[]; + const FLAGS: any; + } + + export interface IRenderer extends IDisposable { + readonly dimensions: IRenderDimensions; + + dispose(): void; + setColors(colors: IColorSet): void; + onDevicePixelRatioChange(): void; + onResize(cols: number, rows: number): void; + onCharSizeChanged(): void; + onBlur(): void; + onFocus(): void; + onSelectionChanged(start: [number, number], end: [number, number], columnSelectMode: boolean): void; + onCursorMove(): void; + onOptionsChanged(): void; + clear(): void; + renderRows(start: number, end: number): void; + registerCharacterJoiner(handler: (text: string) => [number, number][]): number; + deregisterCharacterJoiner(joinerId: number): boolean; + } + + export interface IRenderDimensions { + scaledCharWidth: number; + scaledCharHeight: number; + scaledCellWidth: number; + scaledCellHeight: number; + scaledCharLeft: number; + scaledCharTop: number; + scaledCanvasWidth: number; + scaledCanvasHeight: number; + canvasWidth: number; + canvasHeight: number; + actualCellWidth: number; + actualCellHeight: number; } /**