diff --git a/src/CompositionHelper.ts b/src/CompositionHelper.ts index cec8fb37..ca5b4f4b 100644 --- a/src/CompositionHelper.ts +++ b/src/CompositionHelper.ts @@ -64,7 +64,6 @@ export class CompositionHelper { * @param {CompositionEvent} ev The event. */ public compositionupdate(ev: CompositionEvent): void { - console.log('compositionupdate'); this.compositionView.textContent = ev.data; this.updateCompositionElements(); setTimeout(() => { diff --git a/src/Interfaces.ts b/src/Interfaces.ts index d022dc3e..513deede 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -5,7 +5,7 @@ import { ILinkMatcherOptions } from './Interfaces'; import { LinkMatcherHandler, LinkMatcherValidationCallback, Charset, LineData } from './Types'; -import { IColorSet } from './renderer/Interfaces'; +import { IColorSet, IRenderer } from './renderer/Interfaces'; import { IMouseZoneManager } from './input/Interfaces'; export interface IBrowser { @@ -36,6 +36,7 @@ export interface ITerminal extends ILinkifierAccessor, IBufferAccessor, IElement selectionManager: ISelectionManager; charMeasure: ICharMeasure; textarea: HTMLTextAreaElement; + renderer: IRenderer; rows: number; cols: number; browser: IBrowser; @@ -323,6 +324,7 @@ export interface ITheme { foreground?: string; background?: string; cursor?: string; + cursorAccent?: string; selection?: string; black?: string; red?: string; diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index df9e459c..0878aafc 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -156,6 +156,17 @@ describe('Linkifier', () => { linkifier.linkifyRows(); }); + it('should validate the uri, not the row', done => { + addRow('abc test abc'); + linkifier.registerLinkMatcher(/test/, () => done(), { + validationCallback: (uri, cb) => { + assert.equal(uri, 'test'); + done(); + } + }); + linkifier.linkifyRows(); + }); + it('should disable link if false', done => { addRow('test'); linkifier.registerLinkMatcher(/test/, () => assert.fail(), { diff --git a/src/Linkifier.ts b/src/Linkifier.ts index 94795429..42d20f9c 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -49,11 +49,16 @@ export class Linkifier extends EventEmitter implements ILinkifier { private _mouseZoneManager: IMouseZoneManager; private _rowsTimeoutId: number; private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID; + private _rowsToLinkify: {start: number, end: number}; constructor( protected _terminal: IBufferAccessor & IElementAccessor ) { super(); + this._rowsToLinkify = { + start: null, + end: null + }; this.registerLinkMatcher(strictUrlRegex, null, { matchIndex: 1 }); } @@ -76,25 +81,35 @@ export class Linkifier extends EventEmitter implements ILinkifier { return; } - // Clear out any existing links - this._mouseZoneManager.clearAll(); + // Increase range to linkify + if (!this._rowsToLinkify.start) { + this._rowsToLinkify.start = start; + this._rowsToLinkify.end = end; + } else { + this._rowsToLinkify.start = this._rowsToLinkify.start < start ? this._rowsToLinkify.start : start; + this._rowsToLinkify.end = this._rowsToLinkify.end < end ? this._rowsToLinkify.end : end; + } + // Clear out any existing links on this row range + this._mouseZoneManager.clearAll(start, end); + + // Restart timer if (this._rowsTimeoutId) { clearTimeout(this._rowsTimeoutId); } - this._rowsTimeoutId = setTimeout(this._linkifyRows.bind(this, start, end), Linkifier.TIME_BEFORE_LINKIFY); + this._rowsTimeoutId = setTimeout(() => this._linkifyRows(), Linkifier.TIME_BEFORE_LINKIFY); } /** - * Linkifies - * @param start The row to start at. - * @param end The row to end at. + * Linkifies the rows requested. */ - private _linkifyRows(start: number, end: number): void { + private _linkifyRows(): void { this._rowsTimeoutId = null; - for (let i = start; i <= end; i++) { + for (let i = this._rowsToLinkify.start; i <= this._rowsToLinkify.end; i++) { this._linkifyRow(i); } + this._rowsToLinkify.start = null; + this._rowsToLinkify.end = null; } /** @@ -221,7 +236,7 @@ export class Linkifier extends EventEmitter implements ILinkifier { // Ensure the link is valid before registering if (matcher.validationCallback) { - matcher.validationCallback(text, isValid => { + matcher.validationCallback(uri, isValid => { // Discard link if the line has already changed if (this._rowsTimeoutId) { return; diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 29f12169..28ed1522 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -454,6 +454,11 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param event The mousemove event. */ private _onMouseMove(event: MouseEvent): void { + // If the mousemove listener is active it means that a selection is + // currently being made, we should stop propogation to prevent mouse events + // to be sent to the pty. + event.stopImmediatePropagation(); + // Record the previous position so we know whether to redraw the selection // at the end. const previousSelectionEnd = this._model.selectionEnd ? [this._model.selectionEnd[0], this._model.selectionEnd[1]] : null; diff --git a/src/Terminal.ts b/src/Terminal.ts index e19887cb..52a9142a 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -48,8 +48,11 @@ import { MouseZoneManager } from './input/MouseZoneManager'; import { initialize as initializeCharAtlas } from './renderer/CharAtlas'; import { IRenderer } from './renderer/Interfaces'; -// Declare for RequireJS in loadAddon +// Declares required for loadAddon +declare var exports: any; +declare var module: any; declare var define: any; +declare var require: any; // Let it work inside Node.js for automated testing purposes. const document = (typeof window !== 'undefined') ? window.document : null; @@ -188,7 +191,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT private inputHandler: InputHandler; private parser: Parser; - private renderer: IRenderer; + public renderer: IRenderer; public selectionManager: SelectionManager; public linkifier: ILinkifier; public buffers: BufferSet; @@ -588,6 +591,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.syncBellSound(); this._mouseZoneManager = new MouseZoneManager(this); + this.on('scroll', () => this._mouseZoneManager.clearAll()); this.linkifier.attachToDom(this._mouseZoneManager); // Create the container that will hold helpers like the textarea for @@ -618,15 +622,18 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.charMeasure = new CharMeasure(document, this.helperContainer); + this.renderer = new Renderer(this, this.options.theme); + this.options.theme = null; this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure); - this.charMeasure.on('charsizechanged', () => this.viewport.syncScrollArea()); - this.renderer = new Renderer(this); + this.viewport.onThemeChanged(this.renderer.colorManager.colors); + this.on('cursormove', () => this.renderer.onCursorMove()); this.on('resize', () => this.renderer.onResize(this.cols, this.rows, false)); this.on('blur', () => this.renderer.onBlur()); this.on('focus', () => this.renderer.onFocus()); window.addEventListener('resize', () => this.renderer.onWindowResize(window.devicePixelRatio)); this.charMeasure.on('charsizechanged', () => this.renderer.onResize(this.cols, this.rows, true)); + this.renderer.on('resize', (dimensions) => this.viewport.syncScrollArea()); this.selectionManager = new SelectionManager(this, this.buffer, this.charMeasure); this.element.addEventListener('mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e)); @@ -639,21 +646,15 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.textarea.focus(); this.textarea.select(); }); - this.on('scroll', () => this.selectionManager.refresh()); + this.on('scroll', () => { + this.viewport.syncScrollArea(); + this.selectionManager.refresh(); + }); this.viewportElement.addEventListener('scroll', () => this.selectionManager.refresh()); // Measure the character size this.charMeasure.measure(this.options); - // Set the theme if it was set via setOption/constructor before open. This - // must be run after CharMeasure.measure as it depends on char dimensions. - setTimeout(() => { - if (this.options.theme) { - this._setTheme(this.options.theme); - this.options.theme = null; - } - }, 0); - // Setup loop that draws to screen this.refresh(0, this.rows - 1); @@ -1034,7 +1035,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT */ private queueLinkification(start: number, end: number): void { if (this.linkifier) { - this.linkifier.linkifyRows(0, this.rows); + this.linkifier.linkifyRows(start, end); } } @@ -1071,7 +1072,16 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT // Only adjust ybase and ydisp when the buffer is not trimmed if (!willBufferBeTrimmed) { this.buffer.ybase++; - this.buffer.ydisp++; + // Only scroll the ydisp with ybase if the user has not scrolled up + if (!this.userScrolling) { + this.buffer.ydisp++; + } + } else { + // When the buffer is full and the user has scrolled up, keep the text + // stable unless ydisp is right at the top + if (this.userScrolling) { + this.buffer.ydisp = Math.max(this.buffer.ydisp - 1, 0); + } } } else { // scrollTop is non-zero which means no line will be going to the diff --git a/src/Viewport.test.ts b/src/Viewport.test.ts deleted file mode 100644 index 6ce798d3..00000000 --- a/src/Viewport.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Copyright (c) 2016 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { assert } from 'chai'; -import { Viewport } from './Viewport'; -import { BufferSet } from './BufferSet'; - -describe('Viewport', () => { - let terminal; - let viewportElement; - let charMeasure; - let viewport; - let scrollAreaElement; - - const CHARACTER_HEIGHT = 10; - - beforeEach(() => { - terminal = { - rows: 0, - ydisp: 0, - on: () => {}, - rowContainer: { - style: { - lineHeight: 0 - } - }, - selectionContainer: { - style: { - height: 0 - } - }, - options: { - scrollback: 10, - lineHeight: 1 - } - }; - terminal.buffers = new BufferSet(terminal); - terminal.buffer = terminal.buffers.active; - viewportElement = { - addEventListener: () => {}, - style: { - height: 0, - lineHeight: 0 - } - }; - scrollAreaElement = { - style: { - height: 0 - } - }; - charMeasure = { - height: CHARACTER_HEIGHT - }; - viewport = new Viewport(terminal, viewportElement, scrollAreaElement, charMeasure); - }); - - describe('refresh', () => { - it('should set the height of the viewport when the line-height changed', () => { - terminal.buffer.lines.push(''); - terminal.buffer.lines.push(''); - terminal.rows = 1; - viewport.refresh(); - assert.equal(viewportElement.style.height, 1 * CHARACTER_HEIGHT + 'px'); - charMeasure.height = 2 * CHARACTER_HEIGHT; - viewport.refresh(); - assert.equal(viewportElement.style.height, 2 * CHARACTER_HEIGHT + 'px'); - }); - }); - - describe('syncScrollArea', () => { - it('should sync the scroll area', done => { - // Allow CharMeasure to be initialized - setTimeout(() => { - terminal.buffer.lines.push(''); - terminal.rows = 1; - assert.equal(scrollAreaElement.style.height, 0 * CHARACTER_HEIGHT + 'px'); - viewport.syncScrollArea(); - assert.equal(viewportElement.style.height, 1 * CHARACTER_HEIGHT + 'px'); - assert.equal(scrollAreaElement.style.height, 1 * CHARACTER_HEIGHT + 'px'); - terminal.buffer.lines.push(''); - viewport.syncScrollArea(); - assert.equal(viewportElement.style.height, 1 * CHARACTER_HEIGHT + 'px'); - assert.equal(scrollAreaElement.style.height, 2 * CHARACTER_HEIGHT + 'px'); - done(); - }, 0); - }); - }); -}); diff --git a/src/Viewport.ts b/src/Viewport.ts index 1f693cfe..064076a2 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -12,9 +12,10 @@ import { IColorSet } from './renderer/Interfaces'; * Logic for the virtual scroll bar is included in this object. */ export class Viewport implements IViewport { - private currentRowHeight: number; - private lastRecordedBufferLength: number; - private lastRecordedViewportHeight: number; + private currentRowHeight: number = 0; + private lastRecordedBufferLength: number = 0; + private lastRecordedViewportHeight: number = 0; + private lastRecordedBufferHeight: number = 0; private lastTouchY: number; /** @@ -30,12 +31,6 @@ export class Viewport implements IViewport { private scrollArea: HTMLElement, private charMeasure: CharMeasure ) { - this.currentRowHeight = 0; - this.lastRecordedBufferLength = 0; - this.lastRecordedViewportHeight = 0; - - this.terminal.on('scroll', this.syncScrollArea.bind(this)); - this.terminal.on('resize', this.syncScrollArea.bind(this)); this.viewportElement.addEventListener('scroll', this.onScroll.bind(this)); // Perform this async to ensure the CharMeasure is ready. @@ -52,18 +47,18 @@ export class Viewport implements IViewport { */ private refresh(): void { if (this.charMeasure.height > 0) { - const lineHeight = Math.ceil(this.charMeasure.height * this.terminal.options.lineHeight); - const rowHeightChanged = lineHeight !== this.currentRowHeight; - if (rowHeightChanged) { - this.currentRowHeight = lineHeight; - this.viewportElement.style.lineHeight = lineHeight + 'px'; + this.currentRowHeight = this.terminal.renderer.dimensions.scaledLineHeight / window.devicePixelRatio; + + if (this.lastRecordedViewportHeight !== this.terminal.renderer.dimensions.canvasHeight) { + this.lastRecordedViewportHeight = this.terminal.renderer.dimensions.canvasHeight; + this.viewportElement.style.height = this.lastRecordedViewportHeight + 'px'; } - const viewportHeightChanged = this.lastRecordedViewportHeight !== this.terminal.rows; - if (rowHeightChanged || viewportHeightChanged) { - this.lastRecordedViewportHeight = this.terminal.rows; - this.viewportElement.style.height = lineHeight * this.terminal.rows + 'px'; + + const newBufferHeight = Math.round(this.currentRowHeight * this.lastRecordedBufferLength); + if (this.lastRecordedBufferHeight !== newBufferHeight) { + this.lastRecordedBufferHeight = newBufferHeight; + this.scrollArea.style.height = this.lastRecordedBufferHeight + 'px'; } - this.scrollArea.style.height = (lineHeight * this.lastRecordedBufferLength) + 'px'; } } @@ -75,12 +70,12 @@ export class Viewport implements IViewport { // If buffer height changed this.lastRecordedBufferLength = this.terminal.buffer.lines.length; this.refresh(); - } else if (this.lastRecordedViewportHeight !== this.terminal.rows) { + } else if (this.lastRecordedViewportHeight !== (this.terminal).renderer.dimensions.canvasHeight) { // If viewport height changed this.refresh(); } else { // If size has changed, refresh viewport - if (Math.ceil(this.charMeasure.height * this.terminal.options.lineHeight) !== this.currentRowHeight) { + if (this.terminal.renderer.dimensions.scaledLineHeight / window.devicePixelRatio !== this.currentRowHeight) { this.refresh(); } } diff --git a/src/addons/fit/fit.js b/src/addons/fit/fit.js index 59f0fd52..3e61a7ef 100644 --- a/src/addons/fit/fit.js +++ b/src/addons/fit/fit.js @@ -47,7 +47,7 @@ var availableWidth = parentElementWidth - elementPaddingHor; var geometry = { cols: Math.floor(availableWidth / term.charMeasure.width), - rows: Math.floor(availableHeight / Math.ceil(term.charMeasure.height * term.getOption('lineHeight'))) + rows: Math.floor(availableHeight / Math.floor(term.charMeasure.height * term.getOption('lineHeight'))) }; return geometry; diff --git a/src/input/Interfaces.ts b/src/input/Interfaces.ts index e3ed4eef..21514a53 100644 --- a/src/input/Interfaces.ts +++ b/src/input/Interfaces.ts @@ -5,7 +5,7 @@ export interface IMouseZoneManager { add(zone: IMouseZone): void; - clearAll(): void; + clearAll(start?: number, end?: number): void; } export interface IMouseZone { diff --git a/src/input/MouseZoneManager.ts b/src/input/MouseZoneManager.ts index d1bc8a75..c7feed2a 100644 --- a/src/input/MouseZoneManager.ts +++ b/src/input/MouseZoneManager.ts @@ -44,9 +44,28 @@ export class MouseZoneManager implements IMouseZoneManager { } } - public clearAll(): void { - this._zones.length = 0; - this._deactivate(); + public clearAll(start?: number, end?: number): void { + // Exit if there's nothing to clear + if (this._zones.length === 0) { + return; + } + + // Iterate through zones and clear them out if they're within the range + for (let i = 0; i < this._zones.length; i++) { + const zone = this._zones[i]; + if (zone.y >= start && zone.y <= end) { + if (this._currentZone && this._currentZone === zone) { + this._currentZone.leaveCallback(); + this._currentZone = null; + } + this._zones.splice(i--, 1); + } + } + + // Deactivate the mouse zone manager if all the zones have been removed + if (this._zones.length === 0) { + this._deactivate(); + } } private _activate(): void { @@ -83,10 +102,14 @@ export class MouseZoneManager implements IMouseZoneManager { return; } - // Fire the hover end callback if a zone was being hovered + // Fire the hover end callback and cancel any existing timer if a new zone + // is being hovered if (this._currentZone) { this._currentZone.leaveCallback(); this._currentZone = null; + if (this._tooltipTimeout) { + clearTimeout(this._tooltipTimeout); + } } // Exit if there is not zone @@ -100,14 +123,12 @@ export class MouseZoneManager implements IMouseZoneManager { zone.hoverCallback(e); } - // Restart the timeout - if (this._tooltipTimeout) { - clearTimeout(this._tooltipTimeout); - } + // Restart the tooltip timeout this._tooltipTimeout = setTimeout(() => this._onTooltip(e), HOVER_DURATION); } private _onTooltip(e: MouseEvent): void { + this._tooltipTimeout = null; const zone = this._findZoneEventAt(e); if (zone && zone.tooltipCallback) { zone.tooltipCallback(e); diff --git a/src/renderer/BackgroundRenderLayer.ts b/src/renderer/BackgroundRenderLayer.ts deleted file mode 100644 index 0c09727d..00000000 --- a/src/renderer/BackgroundRenderLayer.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { IColorSet } from './Interfaces'; -import { IBuffer, ICharMeasure, ITerminal } from '../Interfaces'; -import { CHAR_DATA_ATTR_INDEX } from '../Buffer'; -import { GridCache } from './GridCache'; -import { FLAGS } from './Types'; -import { BaseRenderLayer, INVERTED_DEFAULT_COLOR } from './BaseRenderLayer'; - -export class BackgroundRenderLayer extends BaseRenderLayer { - private _state: GridCache; - - constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { - super(container, 'bg', zIndex, colors); - this._state = new GridCache(); - } - - public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { - super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); - // Resizing the canvas discards the contents of the canvas so clear state - this._state.clear(); - this._state.resize(terminal.cols, terminal.rows); - } - - public reset(terminal: ITerminal): void { - this._state.clear(); - this.clearAll(); - } - - public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void { - // Resize has not been called yet - if (this._state.cache.length === 0) { - return; - } - for (let y = startRow; y <= endRow; y++) { - let row = y + terminal.buffer.ydisp; - let line = terminal.buffer.lines.get(row); - for (let x = 0; x < terminal.cols; x++) { - const attr: number = line[x][CHAR_DATA_ATTR_INDEX]; - let bg = attr & 0x1ff; - const flags = attr >> 18; - - // If inverse flag is on, the background should become the foreground. - if (flags & FLAGS.INVERSE) { - bg = (attr >> 9) & 0x1ff; - if (bg === 257) { - bg = INVERTED_DEFAULT_COLOR; - } - } - - const cellState = this._state.cache[x][y]; - const needsRefresh = (bg < 256 && cellState !== bg) || cellState !== null; - if (needsRefresh) { - if (bg < 256) { - this._ctx.save(); - this._ctx.fillStyle = (bg === INVERTED_DEFAULT_COLOR ? this.colors.foreground : this.colors.ansi[bg]); - this.fillCells(x, y, 1, 1); - this._ctx.restore(); - this._state.cache[x][y] = bg; - } else { - this.clearCells(x, y, 1, 1); - this._state.cache[x][y] = null; - } - } - } - } - } -} diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index cc02bcea..2bf49e68 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IRenderLayer, IColorSet } from './Interfaces'; +import { IRenderLayer, IColorSet, IRenderDimensions } from './Interfaces'; import { ITerminal, ITerminalOptions } from '../Interfaces'; import { acquireCharAtlas, CHAR_ATLAS_CELL_SPACING } from './CharAtlas'; import { CharData } from '../Types'; @@ -14,10 +14,10 @@ export const INVERTED_DEFAULT_COLOR = -1; export abstract class BaseRenderLayer implements IRenderLayer { private _canvas: HTMLCanvasElement; protected _ctx: CanvasRenderingContext2D; - private scaledCharWidth: number; - private scaledCharHeight: number; - private scaledLineHeight: number; - private scaledLineDrawY: number; + private _scaledCharWidth: number; + private _scaledCharHeight: number; + private _scaledLineHeight: number; + private _scaledLineDrawY: number; private _charAtlas: HTMLCanvasElement | ImageBitmap; @@ -25,13 +25,18 @@ export abstract class BaseRenderLayer implements IRenderLayer { container: HTMLElement, id: string, zIndex: number, - protected colors: IColorSet + private _alpha: boolean, + protected _colors: IColorSet ) { this._canvas = document.createElement('canvas'); this._canvas.id = `xterm-${id}-layer`; this._canvas.style.zIndex = zIndex.toString(); - this._ctx = this._canvas.getContext('2d'); + this._ctx = this._canvas.getContext('2d', {_alpha}); this._ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + // Draw the background if this is an opaque layer + if (!_alpha) { + this.clearAll(); + } container.appendChild(this._canvas); } @@ -52,8 +57,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param colorSet The color set to use for the char atlas. */ private _refreshCharAtlas(terminal: ITerminal, colorSet: IColorSet): void { + if (this._scaledCharWidth <= 0 && this._scaledCharHeight <= 0) { + return; + } this._charAtlas = null; - const result = acquireCharAtlas(terminal, this.colors, this.scaledCharWidth, this.scaledCharHeight); + const result = acquireCharAtlas(terminal, this._colors, this._scaledCharWidth, this._scaledCharHeight); if (result instanceof HTMLCanvasElement) { this._charAtlas = result; } else { @@ -61,42 +69,38 @@ export abstract class BaseRenderLayer implements IRenderLayer { } } - public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { - // Calculate the scaled character dimensions, if devicePixelRatio is a - // floating point number then the value is ceiled to ensure there is enough - // space to draw the character to the cell - this.scaledCharWidth = Math.ceil(terminal.charMeasure.width * window.devicePixelRatio); - this.scaledCharHeight = Math.ceil(terminal.charMeasure.height * window.devicePixelRatio); + public resize(terminal: ITerminal, dim: IRenderDimensions, charSizeChanged: boolean): void { + this._scaledCharWidth = dim.scaledCharWidth; + this._scaledCharHeight = dim.scaledCharHeight; + this._scaledLineHeight = dim.scaledLineHeight; + this._scaledLineDrawY = dim.scaledLineDrawY; + this._canvas.width = dim.scaledCanvasWidth; + this._canvas.height = dim.scaledCanvasHeight; + this._canvas.style.width = `${dim.canvasWidth}px`; + this._canvas.style.height = `${dim.canvasHeight}px`; - // Calculate the scaled line 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.scaledLineHeight = Math.floor(this.scaledCharHeight * terminal.options.lineHeight); - - // Calculate the y coordinate within a cell that text should draw from in - // order to draw in the center of a cell. - this.scaledLineDrawY = terminal.options.lineHeight === 1 ? 0 : Math.round((this.scaledLineHeight - this.scaledCharHeight) / 2); - - // Recalcualte the canvas dimensions; width/height define the actual number - // of pixels in the canvas, style.width/height define 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._canvas.width = Math.round(canvasWidth * window.devicePixelRatio); - this._canvas.height = Math.round(canvasHeight * window.devicePixelRatio); - this._canvas.style.width = `${canvasWidth}px`; - this._canvas.style.height = `${canvasHeight}px`; + // Draw the background if this is an opaque layer + if (!this._alpha) { + this.clearAll(); + } if (charSizeChanged) { - this._refreshCharAtlas(terminal, this.colors); + this._refreshCharAtlas(terminal, this._colors); } } public abstract reset(terminal: ITerminal): void; + /** + * Gets the left position of a cell. Since character width is stored as a + * float in order to prevent bad letter spacing, drawing shapes in the cell + * need to be rounded. + * @param x The column of the cell. + */ + private _getCellLeft(x: number): number { + return Math.round(x * this._scaledCharWidth); + } + /** * Fills 1+ cells completely. This uses the existing fillStyle on the context. * @param x The column to start at. @@ -105,7 +109,12 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param height The number of rows to fill. */ protected fillCells(x: number, y: number, width: number, height: number): void { - this._ctx.fillRect(x * this.scaledCharWidth, y * this.scaledLineHeight, width * this.scaledCharWidth, height * this.scaledLineHeight); + const cellLeft = this._getCellLeft(x); + this._ctx.fillRect( + cellLeft, + y * this._scaledLineHeight, + this._getCellLeft(x + width) - cellLeft, + height * this._scaledLineHeight); } /** @@ -115,10 +124,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param y The row to fill. */ protected fillBottomLineAtCells(x: number, y: number, width: number = 1): void { + const cellLeft = this._getCellLeft(x); this._ctx.fillRect( - x * this.scaledCharWidth, - (y + 1) * this.scaledLineHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, - width * this.scaledCharWidth, + cellLeft, + (y + 1) * this._scaledLineHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, + this._getCellLeft(x + width) - cellLeft, window.devicePixelRatio); } @@ -130,10 +140,10 @@ export abstract class BaseRenderLayer implements IRenderLayer { */ protected fillLeftLineAtCell(x: number, y: number): void { this._ctx.fillRect( - x * this.scaledCharWidth, - y * this.scaledLineHeight, + this._getCellLeft(x), + y * this._scaledLineHeight, window.devicePixelRatio, - this.scaledLineHeight); + this._scaledLineHeight); } /** @@ -143,19 +153,25 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param y The row to fill. */ protected strokeRectAtCell(x: number, y: number, width: number, height: number): void { + const cellLeft = this._getCellLeft(x); this._ctx.lineWidth = window.devicePixelRatio; this._ctx.strokeRect( - x * this.scaledCharWidth + window.devicePixelRatio / 2, - y * this.scaledLineHeight + (window.devicePixelRatio / 2), - (width * this.scaledCharWidth) - window.devicePixelRatio, - (height * this.scaledLineHeight) - window.devicePixelRatio); + cellLeft + window.devicePixelRatio / 2, + y * this._scaledLineHeight + (window.devicePixelRatio / 2), + this._getCellLeft(x + width) - cellLeft - window.devicePixelRatio, + (height * this._scaledLineHeight) - window.devicePixelRatio); } /** * Clears the entire canvas. */ protected clearAll(): void { - this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + if (this._alpha) { + this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + } else { + this._ctx.fillStyle = this._colors.background; + this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height); + } } /** @@ -166,7 +182,21 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param height The number of rows to clear. */ protected clearCells(x: number, y: number, width: number, height: number): void { - this._ctx.clearRect(x * this.scaledCharWidth, y * this.scaledLineHeight, width * this.scaledCharWidth, height * this.scaledLineHeight); + const cellLeft = this._getCellLeft(x); + if (this._alpha) { + this._ctx.clearRect( + cellLeft, + y * this._scaledLineHeight, + this._getCellLeft(x + width) - cellLeft, + height * this._scaledLineHeight); + } else { + this._ctx.fillStyle = this._colors.background; + this._ctx.fillRect( + cellLeft, + y * this._scaledLineHeight, + this._getCellLeft(x + width) - cellLeft, + height * this._scaledLineHeight); + } } /** @@ -188,9 +218,9 @@ export abstract class BaseRenderLayer implements IRenderLayer { // can bleed into other cells. This code will clip the following fillText, // ensuring that its contents don't go beyond the cell bounds. this._ctx.beginPath(); - this._ctx.rect(x * this.scaledCharWidth, y * this.scaledLineHeight + this.scaledLineDrawY, charData[CHAR_DATA_WIDTH_INDEX] * this.scaledCharWidth, this.scaledCharHeight); + this._ctx.rect(x * this._scaledCharWidth, y * this._scaledLineHeight + this._scaledLineDrawY, charData[CHAR_DATA_WIDTH_INDEX] * this._scaledCharWidth, this._scaledCharHeight); this._ctx.clip(); - this._ctx.fillText(charData[CHAR_DATA_CHAR_INDEX], x * this.scaledCharWidth, y * this.scaledCharHeight); + this._ctx.fillText(charData[CHAR_DATA_CHAR_INDEX], x * this._scaledCharWidth, y * this._scaledCharHeight); } /** @@ -203,9 +233,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @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 drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bold: boolean): void { + protected drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bg: number, bold: boolean): void { // Clear the cell next to this character if it's wide if (width === 2) { this.clearCells(x + 1, y, 1, 1); @@ -223,15 +255,16 @@ export abstract class BaseRenderLayer implements IRenderLayer { const isAscii = code < 256; const isBasicColor = (colorIndex > 1 && fg < 16); const isDefaultColor = fg >= 256; - if (isAscii && (isBasicColor || isDefaultColor)) { + const isDefaultBackground = bg >= 256; + if (this._charAtlas && isAscii && (isBasicColor || isDefaultColor) && isDefaultBackground) { // ImageBitmap's draw about twice as fast as from a canvas - const charAtlasCellWidth = this.scaledCharWidth + CHAR_ATLAS_CELL_SPACING; - const charAtlasCellHeight = this.scaledCharHeight + CHAR_ATLAS_CELL_SPACING; + const charAtlasCellWidth = this._scaledCharWidth + CHAR_ATLAS_CELL_SPACING; + const charAtlasCellHeight = this._scaledCharHeight + CHAR_ATLAS_CELL_SPACING; this._ctx.drawImage(this._charAtlas, - code * charAtlasCellWidth, colorIndex * charAtlasCellHeight, this.scaledCharWidth, this.scaledCharHeight, - x * this.scaledCharWidth, y * this.scaledLineHeight + this.scaledLineDrawY, this.scaledCharWidth, this.scaledCharHeight); + code * charAtlasCellWidth, colorIndex * charAtlasCellHeight, this._scaledCharWidth, this._scaledCharHeight, + x * this._scaledCharWidth, y * this._scaledLineHeight + this._scaledLineDrawY, this._scaledCharWidth, this._scaledCharHeight); } else { - this._drawUncachedChar(terminal, char, width, fg, x, y); + this._drawUncachedChar(terminal, char, width, fg, x, y, bold); } // This draws the atlas (for debugging purposes) // this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); @@ -249,18 +282,21 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param x The column to draw at. * @param y The row to draw at. */ - private _drawUncachedChar(terminal: ITerminal, char: string, width: number, fg: number, x: number, y: number): void { + private _drawUncachedChar(terminal: ITerminal, char: string, width: number, fg: number, x: number, y: number, bold: boolean): void { this._ctx.save(); this._ctx.font = `${terminal.options.fontSize * window.devicePixelRatio}px ${terminal.options.fontFamily}`; + if (bold) { + this._ctx.font = `bold ${this._ctx.font}`; + } this._ctx.textBaseline = 'top'; if (fg === INVERTED_DEFAULT_COLOR) { - this._ctx.fillStyle = this.colors.background; + this._ctx.fillStyle = this._colors.background; } else if (fg < 256) { // 256 color support - this._ctx.fillStyle = this.colors.ansi[fg]; + this._ctx.fillStyle = this._colors.ansi[fg]; } else { - this._ctx.fillStyle = this.colors.foreground; + this._ctx.fillStyle = this._colors.foreground; } // Since uncached characters are not coming off the char atlas with source @@ -268,11 +304,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { // can bleed into other cells. This code will clip the following fillText, // ensuring that its contents don't go beyond the cell bounds. this._ctx.beginPath(); - this._ctx.rect(x * this.scaledCharWidth, y * this.scaledLineHeight + this.scaledLineDrawY, width * this.scaledCharWidth, this.scaledCharHeight); + this._ctx.rect(x * this._scaledCharWidth, y * this._scaledLineHeight + this._scaledLineDrawY, width * this._scaledCharWidth, this._scaledCharHeight); this._ctx.clip(); // Draw the character - this._ctx.fillText(char, x * this.scaledCharWidth, y * this.scaledLineHeight + this.scaledLineDrawY); + this._ctx.fillText(char, x * this._scaledCharWidth, y * this._scaledLineHeight + this._scaledLineDrawY); this._ctx.restore(); } } diff --git a/src/renderer/CharAtlas.ts b/src/renderer/CharAtlas.ts index bf080cce..6808f85e 100644 --- a/src/renderer/CharAtlas.ts +++ b/src/renderer/CharAtlas.ts @@ -64,7 +64,7 @@ export function acquireCharAtlas(terminal: ITerminal, colors: IColorSet, scaledC } const newEntry: ICharAtlasCacheEntry = { - bitmap: generator.generate(scaledCharWidth, scaledCharHeight, terminal.options.fontSize, terminal.options.fontFamily, colors.foreground, colors.ansi), + bitmap: generator.generate(scaledCharWidth, scaledCharHeight, terminal.options.fontSize, terminal.options.fontFamily, colors.background, colors.foreground, colors.ansi), config: newConfig, ownedBy: [terminal] }; @@ -75,8 +75,9 @@ export function acquireCharAtlas(terminal: ITerminal, colors: IColorSet, scaledC function generateConfig(scaledCharWidth: number, scaledCharHeight: number, terminal: ITerminal, colors: IColorSet): ICharAtlasConfig { const clonedColors = { foreground: colors.foreground, - background: null, + background: colors.background, cursor: null, + cursorAccent: null, selection: null, ansi: colors.ansi.slice(0, 16) }; @@ -99,7 +100,8 @@ function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean { a.fontSize === b.fontSize && a.scaledCharWidth === b.scaledCharWidth && a.scaledCharHeight === b.scaledCharHeight && - a.colors.foreground === b.colors.foreground; + a.colors.foreground === b.colors.foreground && + a.colors.background === b.colors.background; } let generator: CharAtlasGenerator; @@ -120,16 +122,19 @@ class CharAtlasGenerator { constructor(private _document: Document) { this._canvas = this._document.createElement('canvas'); - this._ctx = this._canvas.getContext('2d'); + this._ctx = this._canvas.getContext('2d', {alpha: false}); this._ctx.scale(window.devicePixelRatio, window.devicePixelRatio); } - public generate(scaledCharWidth: number, scaledCharHeight: number, fontSize: number, fontFamily: string, foreground: string, ansiColors: string[]): HTMLCanvasElement | Promise { + public generate(scaledCharWidth: number, scaledCharHeight: number, fontSize: number, fontFamily: string, background: string, foreground: string, ansiColors: string[]): HTMLCanvasElement | Promise { const cellWidth = scaledCharWidth + CHAR_ATLAS_CELL_SPACING; const cellHeight = scaledCharHeight + CHAR_ATLAS_CELL_SPACING; this._canvas.width = 255 * cellWidth; this._canvas.height = (/*default+default bold*/2 + /*0-15*/16) * cellHeight; + this._ctx.fillStyle = background; + this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height); + this._ctx.save(); this._ctx.fillStyle = foreground; this._ctx.font = `${fontSize * window.devicePixelRatio}px ${fontFamily}`; diff --git a/src/renderer/ColorManager.ts b/src/renderer/ColorManager.ts index b9afd0e1..ea5f1cb0 100644 --- a/src/renderer/ColorManager.ts +++ b/src/renderer/ColorManager.ts @@ -3,12 +3,13 @@ * @license MIT */ -import { IColorSet } from './Interfaces'; +import { IColorSet, IColorManager } from './Interfaces'; import { ITheme } from '../Interfaces'; const DEFAULT_FOREGROUND = '#ffffff'; const DEFAULT_BACKGROUND = '#000000'; const DEFAULT_CURSOR = '#ffffff'; +const DEFAULT_CURSOR_ACCENT = '#000000'; const DEFAULT_SELECTION = 'rgba(255, 255, 255, 0.3)'; export const DEFAULT_ANSI_COLORS = [ // dark: @@ -64,7 +65,7 @@ function toPaddedHex(c: number): string { /** * Manages the source of truth for a terminal's colors. */ -export class ColorManager { +export class ColorManager implements IColorManager { public colors: IColorSet; constructor() { @@ -72,6 +73,7 @@ export class ColorManager { foreground: DEFAULT_FOREGROUND, background: DEFAULT_BACKGROUND, cursor: DEFAULT_CURSOR, + cursorAccent: DEFAULT_CURSOR_ACCENT, selection: DEFAULT_SELECTION, ansi: generate256Colors(DEFAULT_ANSI_COLORS) }; @@ -86,6 +88,7 @@ export class ColorManager { this.colors.foreground = theme.foreground || DEFAULT_FOREGROUND; this.colors.background = theme.background || DEFAULT_BACKGROUND; this.colors.cursor = theme.cursor || DEFAULT_CURSOR; + this.colors.cursorAccent = theme.cursorAccent || DEFAULT_CURSOR_ACCENT; this.colors.selection = theme.selection || DEFAULT_SELECTION; this.colors.ansi[0] = theme.black || DEFAULT_ANSI_COLORS[0]; this.colors.ansi[1] = theme.red || DEFAULT_ANSI_COLORS[1]; diff --git a/src/renderer/CursorRenderLayer.ts b/src/renderer/CursorRenderLayer.ts index b136a13a..e57c326d 100644 --- a/src/renderer/CursorRenderLayer.ts +++ b/src/renderer/CursorRenderLayer.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IColorSet } from './Interfaces'; +import { IColorSet, IRenderDimensions } from './Interfaces'; import { IBuffer, ICharMeasure, ITerminal, ITerminalOptions } from '../Interfaces'; import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_CHAR_INDEX } from '../Buffer'; import { GridCache } from './GridCache'; @@ -31,7 +31,7 @@ export class CursorRenderLayer extends BaseRenderLayer { private _isFocused: boolean; constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { - super(container, 'cursor', zIndex, colors); + super(container, 'cursor', zIndex, true, colors); this._state = { x: null, y: null, @@ -47,8 +47,8 @@ export class CursorRenderLayer extends BaseRenderLayer { // TODO: Consider initial options? Maybe onOptionsChanged should be called at the end of open? } - public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { - super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + public resize(terminal: ITerminal, dim: IRenderDimensions, charSizeChanged: boolean): void { + super.resize(terminal, dim, charSizeChanged); // Resizing the canvas discards the contents of the canvas so clear state this._state = { x: null, @@ -135,7 +135,7 @@ export class CursorRenderLayer extends BaseRenderLayer { if (!terminal.isFocused) { this._clearCursor(); this._ctx.save(); - this._ctx.fillStyle = this.colors.cursor; + this._ctx.fillStyle = this._colors.cursor; this._renderBlurCursor(terminal, terminal.buffer.x, viewportRelativeCursorY, charData); this._ctx.restore(); this._state.x = terminal.buffer.x; @@ -190,30 +190,30 @@ export class CursorRenderLayer extends BaseRenderLayer { private _renderBarCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { this._ctx.save(); - this._ctx.fillStyle = this.colors.cursor; + this._ctx.fillStyle = this._colors.cursor; this.fillLeftLineAtCell(x, y); this._ctx.restore(); } private _renderBlockCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { this._ctx.save(); - this._ctx.fillStyle = this.colors.cursor; + this._ctx.fillStyle = this._colors.cursor; this.fillCells(x, y, charData[CHAR_DATA_WIDTH_INDEX], 1); - this._ctx.fillStyle = this.colors.background; + this._ctx.fillStyle = this._colors.cursorAccent; this.fillCharTrueColor(terminal, charData, x, y); this._ctx.restore(); } private _renderUnderlineCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { this._ctx.save(); - this._ctx.fillStyle = this.colors.cursor; + this._ctx.fillStyle = this._colors.cursor; this.fillBottomLineAtCells(x, y); this._ctx.restore(); } private _renderBlurCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { this._ctx.save(); - this._ctx.strokeStyle = this.colors.cursor; + this._ctx.strokeStyle = this._colors.cursor; this.strokeRectAtCell(x, y, charData[CHAR_DATA_WIDTH_INDEX], 1); this._ctx.restore(); } diff --git a/src/renderer/Interfaces.ts b/src/renderer/Interfaces.ts index 05f98300..bb9e36be 100644 --- a/src/renderer/Interfaces.ts +++ b/src/renderer/Interfaces.ts @@ -3,9 +3,12 @@ * @license MIT */ -import { ITerminal, ITerminalOptions, ITheme } from '../Interfaces'; +import { ITerminal, ITerminalOptions, ITheme, IEventEmitter } from '../Interfaces'; + +export interface IRenderer extends IEventEmitter { + dimensions: IRenderDimensions; + colorManager: IColorManager; -export interface IRenderer { setTheme(theme: ITheme): IColorSet; onWindowResize(devicePixelRatio: number): void; onResize(cols: number, rows: number, didCharSizeChange: boolean): void; @@ -59,7 +62,7 @@ export interface IRenderLayer { /** * Resize the render layer. */ - resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void; + resize(terminal: ITerminal, dim: IRenderDimensions, charSizeChanged: boolean): void; /** * Clear the state of the render layer. @@ -67,11 +70,26 @@ export interface IRenderLayer { reset(terminal: ITerminal): void; } +export interface IColorManager { + colors: IColorSet; +} export interface IColorSet { foreground: string; background: string; cursor: string; + cursorAccent: string; selection: string; ansi: string[]; } + +export interface IRenderDimensions { + scaledCharWidth: number; + scaledCharHeight: number; + scaledLineHeight: number; + scaledLineDrawY: number; + scaledCanvasWidth: number; + scaledCanvasHeight: number; + canvasWidth: number; + canvasHeight: number; +} diff --git a/src/renderer/LinkRenderLayer.ts b/src/renderer/LinkRenderLayer.ts index 9b2cabb3..8f3b63a5 100644 --- a/src/renderer/LinkRenderLayer.ts +++ b/src/renderer/LinkRenderLayer.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IColorSet } from './Interfaces'; +import { IColorSet, IRenderDimensions } from './Interfaces'; import { IBuffer, ICharMeasure, ITerminal, ILinkifierAccessor } from '../Interfaces'; import { CHAR_DATA_ATTR_INDEX } from '../Buffer'; import { GridCache } from './GridCache'; @@ -15,13 +15,13 @@ export class LinkRenderLayer extends BaseRenderLayer { private _state: LinkHoverEvent = null; constructor(container: HTMLElement, zIndex: number, colors: IColorSet, terminal: ILinkifierAccessor) { - super(container, 'link', zIndex, colors); + super(container, 'link', zIndex, true, colors); terminal.linkifier.on(LinkHoverEventTypes.HOVER, (e: LinkHoverEvent) => this._onLinkHover(e)); terminal.linkifier.on(LinkHoverEventTypes.LEAVE, (e: LinkHoverEvent) => this._onLinkLeave(e)); } - public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { - super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + public resize(terminal: ITerminal, dim: IRenderDimensions, charSizeChanged: boolean): void { + super.resize(terminal, dim, charSizeChanged); // Resizing the canvas discards the contents of the canvas so clear state this._state = null; } @@ -38,7 +38,7 @@ export class LinkRenderLayer extends BaseRenderLayer { } private _onLinkHover(e: LinkHoverEvent): void { - this._ctx.fillStyle = this.colors.foreground; + this._ctx.fillStyle = this._colors.foreground; this.fillBottomLineAtCells(e.x, e.y, e.length); this._state = e; } diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 131a8b78..1bdd3b4a 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -5,16 +5,16 @@ import { ITerminal, ITheme } from '../Interfaces'; import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from '../Buffer'; -import { BackgroundRenderLayer } from './BackgroundRenderLayer'; -import { ForegroundRenderLayer } from './ForegroundRenderLayer'; +import { TextRenderLayer } from './TextRenderLayer'; import { SelectionRenderLayer } from './SelectionRenderLayer'; import { CursorRenderLayer } from './CursorRenderLayer'; import { ColorManager } from './ColorManager'; import { BaseRenderLayer } from './BaseRenderLayer'; -import { IRenderLayer, IColorSet, IRenderer } from './Interfaces'; +import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions } from './Interfaces'; import { LinkRenderLayer } from './LinkRenderLayer'; +import { EventEmitter } from '../EventEmitter'; -export class Renderer implements IRenderer { +export class Renderer extends EventEmitter implements IRenderer { /** A queue of the rows to be refreshed */ private _refreshRowsQueue: {start: number, end: number}[] = []; private _refreshAnimationFrame = null; @@ -22,17 +22,31 @@ export class Renderer implements IRenderer { private _renderLayers: IRenderLayer[]; private _devicePixelRatio: number; - private _colorManager: ColorManager; + public colorManager: ColorManager; + public dimensions: IRenderDimensions; - constructor(private _terminal: ITerminal) { - this._colorManager = new ColorManager(); + constructor(private _terminal: ITerminal, theme: ITheme) { + super(); + this.colorManager = new ColorManager(); + if (theme) { + this.colorManager.setTheme(theme); + } this._renderLayers = [ - new BackgroundRenderLayer(this._terminal.element, 0, this._colorManager.colors), - new SelectionRenderLayer(this._terminal.element, 1, this._colorManager.colors), - new ForegroundRenderLayer(this._terminal.element, 2, this._colorManager.colors), - new LinkRenderLayer(this._terminal.element, 3, this._colorManager.colors, this._terminal), - new CursorRenderLayer(this._terminal.element, 4, this._colorManager.colors) + new TextRenderLayer(this._terminal.element, 0, this.colorManager.colors), + new SelectionRenderLayer(this._terminal.element, 1, this.colorManager.colors), + new LinkRenderLayer(this._terminal.element, 2, this.colorManager.colors, this._terminal), + new CursorRenderLayer(this._terminal.element, 3, this.colorManager.colors) ]; + this.dimensions = { + scaledCharWidth: null, + scaledCharHeight: null, + scaledLineHeight: null, + scaledLineDrawY: null, + scaledCanvasWidth: null, + scaledCanvasHeight: null, + canvasWidth: null, + canvasHeight: null + }; this._devicePixelRatio = window.devicePixelRatio; } @@ -46,29 +60,68 @@ export class Renderer implements IRenderer { } public setTheme(theme: ITheme): IColorSet { - this._colorManager.setTheme(theme); + this.colorManager.setTheme(theme); // Clear layers and force a full render this._renderLayers.forEach(l => { - l.onThemeChanged(this._terminal, this._colorManager.colors); + l.onThemeChanged(this._terminal, this.colorManager.colors); l.reset(this._terminal); }); this._terminal.refresh(0, this._terminal.rows - 1); - return this._colorManager.colors; + return this.colorManager.colors; } public onResize(cols: number, rows: number, didCharSizeChange: boolean): void { if (!this._terminal.charMeasure.width || !this._terminal.charMeasure.height) { return; } - const width = this._terminal.charMeasure.width * cols; - const height = Math.floor(this._terminal.charMeasure.height * this._terminal.options.lineHeight) * rows; + + // 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. + this.dimensions.scaledCharWidth = Math.floor(this._terminal.charMeasure.width * window.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._terminal.charMeasure.height * window.devicePixelRatio); + + // Calculate the scaled line 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.scaledLineHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.options.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.scaledLineDrawY = this._terminal.options.lineHeight === 1 ? 0 : Math.round((this.dimensions.scaledLineHeight - this.dimensions.scaledCharHeight) / 2); + + // Recalculate the canvas dimensions; scaled* define the actual number of + // pixel in the canvas + this.dimensions.scaledCanvasHeight = this._terminal.rows * this.dimensions.scaledLineHeight; + this.dimensions.scaledCanvasWidth = this._terminal.cols * this.dimensions.scaledCharWidth; + + // 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 / window.devicePixelRatio); + this.dimensions.canvasWidth = Math.round(this.dimensions.scaledCanvasWidth / window.devicePixelRatio); + // Resize all render layers - this._renderLayers.forEach(l => l.resize(this._terminal, width, height, didCharSizeChange)); + this._renderLayers.forEach(l => l.resize(this._terminal, this.dimensions, didCharSizeChange)); + // Force a refresh this._terminal.refresh(0, this._terminal.rows - 1); + + this.emit('resize', { + width: this.dimensions.canvasWidth, + height: this.dimensions.canvasHeight + }); } public onCharSizeChanged(): void { diff --git a/src/renderer/SelectionRenderLayer.ts b/src/renderer/SelectionRenderLayer.ts index 39569c9b..54eb7fc4 100644 --- a/src/renderer/SelectionRenderLayer.ts +++ b/src/renderer/SelectionRenderLayer.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IColorSet } from './Interfaces'; +import { IColorSet, IRenderDimensions } from './Interfaces'; import { IBuffer, ICharMeasure, ITerminal } from '../Interfaces'; import { CHAR_DATA_ATTR_INDEX } from '../Buffer'; import { GridCache } from './GridCache'; @@ -14,15 +14,15 @@ export class SelectionRenderLayer extends BaseRenderLayer { private _state: {start: [number, number], end: [number, number]}; constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { - super(container, 'selection', zIndex, colors); + super(container, 'selection', zIndex, true, colors); this._state = { start: null, end: null }; } - public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { - super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + public resize(terminal: ITerminal, dim: IRenderDimensions, charSizeChanged: boolean): void { + super.resize(terminal, dim, charSizeChanged); // Resizing the canvas discards the contents of the canvas so clear state this._state = { start: null, @@ -68,7 +68,7 @@ export class SelectionRenderLayer extends BaseRenderLayer { // Draw first row const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; const startRowEndCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : terminal.cols; - this._ctx.fillStyle = this.colors.selection; + this._ctx.fillStyle = this._colors.selection; this.fillCells(startCol, viewportCappedStartRow, startRowEndCol - startCol, 1); // Draw middle rows diff --git a/src/renderer/ForegroundRenderLayer.ts b/src/renderer/TextRenderLayer.ts similarity index 51% rename from src/renderer/ForegroundRenderLayer.ts rename to src/renderer/TextRenderLayer.ts index 883be5d9..e319efe7 100644 --- a/src/renderer/ForegroundRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IColorSet } from './Interfaces'; +import { IColorSet, IRenderDimensions } from './Interfaces'; import { IBuffer, ICharMeasure, ITerminal } from '../Interfaces'; import { CHAR_DATA_ATTR_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX } from '../Buffer'; import { FLAGS } from './Types'; @@ -16,18 +16,29 @@ import { BaseRenderLayer, INVERTED_DEFAULT_COLOR } from './BaseRenderLayer'; * when the character changes (a regular space ' ' character may not as it's * drawn state is a cleared cell). */ -const EMOJI_OWNED_CHAR_DATA: CharData = [null, '', 0, -1]; +const OVERLAP_OWNED_CHAR_DATA: CharData = [null, '', 0, -1]; -export class ForegroundRenderLayer extends BaseRenderLayer { +export class TextRenderLayer extends BaseRenderLayer { private _state: GridCache; + private _characterWidth: number; + private _characterFont: string; + private _characterOverlapCache: { [key: string]: boolean } = {}; constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { - super(container, 'fg', zIndex, colors); + super(container, 'text', zIndex, false, colors); this._state = new GridCache(); } - public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { - super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + public resize(terminal: ITerminal, dim: IRenderDimensions, charSizeChanged: boolean): void { + super.resize(terminal, dim, charSizeChanged); + + // Clear the character width cache if the font or width has changed + const terminalFont = `${terminal.options.fontSize * window.devicePixelRatio}px ${terminal.options.fontFamily}`; + if (this._characterWidth !== dim.scaledCharWidth || this._characterFont !== terminalFont) { + this._characterWidth = dim.scaledCharWidth; + this._characterFont = terminalFont; + this._characterOverlapCache = {}; + } // Resizing the canvas discards the contents of the canvas so clear state this._state.clear(); this._state.resize(terminal.cols, terminal.rows); @@ -63,12 +74,12 @@ export class ForegroundRenderLayer extends BaseRenderLayer { } // If the character is a space and the character to the left is an - // emoji, skip the character and allow the emoji char to take full - // control over this character's cell. + // overlapping character, skip the character and allow the overlapping + // char to take full control over this character's cell. if (code === 32 /*' '*/) { if (x > 0) { const previousChar: CharData = line[x - 1]; - if (this._isEmoji(previousChar[CHAR_DATA_CHAR_INDEX])) { + if (this._isOverlapping(previousChar)) { continue; } } @@ -82,48 +93,65 @@ export class ForegroundRenderLayer extends BaseRenderLayer { continue; } - // Clear the old character if present - if (state && state[CHAR_DATA_CODE_INDEX] !== 32 /*' '*/) { + // Clear the old character was not a space with the default background + const wasInverted = !!(state && state[CHAR_DATA_ATTR_INDEX] && state[CHAR_DATA_ATTR_INDEX] >> 18 & FLAGS.INVERSE); + if (state && !(state[CHAR_DATA_CODE_INDEX] === 32 /*' '*/ && (state[CHAR_DATA_ATTR_INDEX] & 0x1ff) >= 256 && !wasInverted)) { this._clearChar(x, y); } this._state.cache[x][y] = charData; const flags = attr >> 18; + let bg = attr & 0x1ff; // Skip rendering if the character is invisible - if (!code || code === 32 /*' '*/ || (flags & FLAGS.INVISIBLE)) { + const isDefaultBackground = bg >= 256; + const isInvisible = flags & FLAGS.INVISIBLE; + const isInverted = flags & FLAGS.INVERSE; + if (!code || (code === 32 /*' '*/ && isDefaultBackground && !isInverted) || isInvisible) { continue; } - // If the character is an emoji and the character to the right is a + // If the character is an overlapping char and the character to the right is a // space, take ownership of the cell to the right. - if (this._isEmoji(char)) { - // If the character is an emoji, we want to force a re-render on every + if (width !== 0 && this._isOverlapping(charData)) { + // If the character is overlapping, we want to force a re-render on every // frame. This is specifically to work around the case where two - // emoji's `a` and `b` are adjacent, the cursor is moved to b and a + // overlaping chars `a` and `b` are adjacent, the cursor is moved to b and a // space is added. Without this, the first half of `b` would never // get removed, and `a` would not re-render because it thinks it's // already in the correct state. - this._state.cache[x][y] = EMOJI_OWNED_CHAR_DATA; + this._state.cache[x][y] = OVERLAP_OWNED_CHAR_DATA; if (x < line.length && line[x + 1][CHAR_DATA_CODE_INDEX] === 32 /*' '*/) { width = 2; this._clearChar(x + 1, y); - // The emoji owned char data will force a clear and render when the - // emoji is no longer to the left of the character and also when the - // space changes to another character. - this._state.cache[x + 1][y] = EMOJI_OWNED_CHAR_DATA; + // The overlapping char's char data will force a clear and render when the + // overlapping char is no longer to the left of the character and also when + // the space changes to another character. + this._state.cache[x + 1][y] = OVERLAP_OWNED_CHAR_DATA; } } let fg = (attr >> 9) & 0x1ff; // If inverse flag is on, the foreground should become the background. - if (flags & FLAGS.INVERSE) { - fg = attr & 0x1ff; - // TODO: Is this case still needed + if (isInverted) { + const temp = bg; + bg = fg; + fg = temp; if (fg === 256) { fg = INVERTED_DEFAULT_COLOR; } + if (bg === 257) { + bg = INVERTED_DEFAULT_COLOR; + } + } + + // Draw background + if (bg < 256) { + this._ctx.save(); + this._ctx.fillStyle = (bg === INVERTED_DEFAULT_COLOR ? this._colors.foreground : this._colors.ansi[bg]); + this.fillCells(x, y, width, 1); + this._ctx.restore(); } this._ctx.save(); @@ -137,29 +165,55 @@ export class ForegroundRenderLayer extends BaseRenderLayer { if (flags & FLAGS.UNDERLINE) { if (fg === INVERTED_DEFAULT_COLOR) { - this._ctx.fillStyle = this.colors.background; + this._ctx.fillStyle = this._colors.background; } else if (fg < 256) { // 256 color support - this._ctx.fillStyle = this.colors.ansi[fg]; + this._ctx.fillStyle = this._colors.ansi[fg]; } else { - this._ctx.fillStyle = this.colors.foreground; + this._ctx.fillStyle = this._colors.foreground; } this.fillBottomLineAtCells(x, y); } - this.drawChar(terminal, char, code, width, x, y, fg, !!(flags & FLAGS.BOLD)); + this.drawChar(terminal, char, code, width, x, y, fg, bg, !!(flags & FLAGS.BOLD)); this._ctx.restore(); } } } - /** - * Whether the character is an emoji. - * @param char The character to search. - */ - private _isEmoji(char: string): boolean { - return char.search(/([\uD800-\uDBFF][\uDC00-\uDFFF])/g) >= 0; + /** + * Whether a character is overlapping to the + * next cell. + */ + private _isOverlapping(charData: CharData): boolean { + // We assume that any ascii character will not overlap + const code = charData[CHAR_DATA_CODE_INDEX]; + if (code < 256) { + return false; + } + + // Deliver from cache if available + const char = charData[CHAR_DATA_CHAR_INDEX]; + if (this._characterOverlapCache.hasOwnProperty(char)) { + return this._characterOverlapCache[char]; + } + + // Setup the font + this._ctx.save(); + this._ctx.font = this._characterFont; + + // Measure the width of the character, but Math.floor it + // because that is what the renderer does when it calculates + // the character dimensions we are comparing against + const overlaps = Math.floor(this._ctx.measureText(char).width) > this._characterWidth; + + // Restore the original context + this._ctx.restore(); + + // Cache and return + this._characterOverlapCache[char] = overlaps; + return overlaps; } /** diff --git a/src/utils/CharMeasure.ts b/src/utils/CharMeasure.ts index e8fcff86..e5b03f87 100644 --- a/src/utils/CharMeasure.ts +++ b/src/utils/CharMeasure.ts @@ -60,7 +60,7 @@ export class CharMeasure extends EventEmitter implements ICharMeasure { return; } if (this._width !== geometry.width || this._height !== geometry.height) { - this._width = Math.ceil(geometry.width); + this._width = geometry.width; this._height = Math.ceil(geometry.height); this.emit('charsizechanged'); } diff --git a/src/utils/Mouse.test.ts b/src/utils/Mouse.test.ts index 4163263b..b96dd68a 100644 --- a/src/utils/Mouse.test.ts +++ b/src/utils/Mouse.test.ts @@ -58,6 +58,6 @@ describe('getCoords', () => { assert.deepEqual(coords, [1, 1]); // Event are double the cols/rows coords = getCoords({ pageX: CHAR_WIDTH * 20, pageY: CHAR_HEIGHT * 20 }, document.createElement('div'), charMeasure, 1, 10, 10); - assert.deepEqual(coords, [11, 11], 'coordinates should never come back as larger than the terminal'); + assert.deepEqual(coords, [10, 10], 'coordinates should never come back as larger than the terminal'); }); }); diff --git a/src/utils/Mouse.ts b/src/utils/Mouse.ts index aef76d5b..7bc39b7f 100644 --- a/src/utils/Mouse.ts +++ b/src/utils/Mouse.ts @@ -53,8 +53,8 @@ export function getCoords(event: {pageX: number, pageY: number}, element: HTMLEl coords[1] = Math.ceil(coords[1] / Math.ceil(charMeasure.height * lineHeight)); // Ensure coordinates are within the terminal viewport. - coords[0] = Math.min(Math.max(coords[0], 1), colCount + 1); - coords[1] = Math.min(Math.max(coords[1], 1), rowCount + 1); + coords[0] = Math.min(Math.max(coords[0], 1), colCount); + coords[1] = Math.min(Math.max(coords[1], 1), rowCount); return coords; } diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index eb2f2501..beed0aaa 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -7,9 +7,10 @@ import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManag import { LineData } from '../Types'; import { Buffer } from '../Buffer'; import * as Browser from './Browser'; -import { IColorSet, IRenderer } from '../renderer/Interfaces'; +import { IColorSet, IRenderer, IRenderDimensions, IColorManager } from '../renderer/Interfaces'; export class MockTerminal implements ITerminal { + renderer: IRenderer; linkifier: ILinkifier; isFocused: boolean; options: ITerminalOptions = {}; @@ -215,6 +216,17 @@ export class MockBuffer implements IBuffer { } export class MockRenderer implements IRenderer { + colorManager: IColorManager; + on(type: string, listener: IListenerType): void { + throw new Error('Method not implemented.'); + } + off(type: string, listener: IListenerType): void { + throw new Error('Method not implemented.'); + } + emit(type: string, data?: any): void { + throw new Error('Method not implemented.'); + } + dimensions: IRenderDimensions; setTheme(theme: ITheme): IColorSet { return {}; } onResize(cols: number, rows: number, didCharSizeChange: boolean): void {} onCharSizeChanged(): void {} diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 7a4d6940..de2a261b 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -88,6 +88,8 @@ interface ITheme { background?: string, /** The cursor color */ cursor?: string, + /** The accent color of the cursor (used as the foreground color for a block cursor) */ + cursorAccent?: string, /** The selection color (can be transparent) */ selection?: string, /** ANSI black (eg. `\x1b[30m`) */