diff --git a/demo/main.js b/demo/main.js index 9fa5964d..95b15a0d 100644 --- a/demo/main.js +++ b/demo/main.js @@ -39,8 +39,8 @@ function setTerminalSize() { var rows = parseInt(rowsElement.value, 10); var viewportElement = document.querySelector('.xterm-viewport'); var scrollBarWidth = viewportElement.offsetWidth - viewportElement.clientWidth; - var width = (cols * term.charMeasure.width + 20 /*room for scrollbar*/).toString() + 'px'; - var height = (rows * term.charMeasure.height).toString() + 'px'; + var width = (cols * term.renderer.dimensions.actualCellWidth + 20 /*room for scrollbar*/).toString() + 'px'; + var height = (rows * term.renderer.dimensions.actualCellHeight).toString() + 'px'; terminalContainer.style.width = width; terminalContainer.style.height = height; @@ -120,9 +120,9 @@ function createTerminal() { fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows, {method: 'POST'}).then(function (res) { - res.text().then(function (pid) { - window.pid = pid; - socketURL += pid; + res.text().then(function (processId) { + pid = processId; + socketURL += processId; socket = new WebSocket(socketURL); socket.onopen = runRealTerminal; socket.onclose = runFakeTerminal; diff --git a/fixtures/typings-test/typings-test.ts b/fixtures/typings-test/typings-test.ts index 887eb322..5a5f6c50 100644 --- a/fixtures/typings-test/typings-test.ts +++ b/fixtures/typings-test/typings-test.ts @@ -36,12 +36,12 @@ namespace properties { namespace static_methods { { - Terminal.loadAddon('attach'); - Terminal.loadAddon('fit'); - Terminal.loadAddon('fullscreen'); - Terminal.loadAddon('search'); - Terminal.loadAddon('terminado'); - Terminal.loadAddon('winptyCompat'); + Terminal.applyAddon({}); + Terminal.applyAddon({}); + Terminal.applyAddon({}); + Terminal.applyAddon({}); + Terminal.applyAddon({}); + Terminal.applyAddon({}); } } @@ -63,7 +63,7 @@ namespace methods_core { // no arg t.on('blur', () => {}); t.on('focus', () => {}); - t.on('lineFeed', () => {}); + t.on('linefeed', () => {}); t.on('selection', () => {}); // args t.on('data', () => {}); @@ -89,7 +89,7 @@ namespace methods_core { // no arg t.off('blur', () => {}); t.off('focus', () => {}); - t.off('lineFeed', () => {}); + t.off('linefeed', () => {}); t.off('selection', () => {}); // args t.off('data', () => {}); @@ -170,9 +170,9 @@ namespace methods_core { t.setOption('handler', (data: string) => console.log(data)); t.setOption('bellSound', 'foo'); t.setOption('bellStyle', 'none'); - t.setOption('bellStyle', 'visual'); + // t.setOption('bellStyle', 'visual'); t.setOption('bellStyle', 'sound'); - t.setOption('bellStyle', 'both'); + // t.setOption('bellStyle', 'both'); t.setOption('fontSize', 1); t.setOption('lineHeight', 1); t.setOption('fontFamily', 'foo'); diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 40dde4a4..2fe3a5f5 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -138,9 +138,9 @@ export class InputHandler implements IInputHandler { /** * This event is emitted whenever the terminal outputs a LF or NL. * - * @event lineFeed + * @event linefeed */ - this._terminal.emit('lineFeed'); + this._terminal.emit('linefeed'); } /** diff --git a/src/Terminal.ts b/src/Terminal.ts index d45b12af..833c18c6 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -319,7 +319,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT * Focus the terminal. Delegates focus handling to the terminal's DOM element. */ public focus(): void { - this.textarea.focus(); + if (this.textarea) { + this.textarea.focus(); + } } public get isFocused(): boolean { @@ -588,19 +590,19 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.element = this.document.createElement('div'); this.element.classList.add('terminal'); this.element.classList.add('xterm'); - this.element.setAttribute('tabindex', '0'); + this.parent.appendChild(this.element); + // Performance: Use a document fragment to build the terminal + // viewport and helper elements detached from the DOM + const fragment = document.createDocumentFragment(); this.viewportElement = document.createElement('div'); this.viewportElement.classList.add('xterm-viewport'); - this.element.appendChild(this.viewportElement); + fragment.appendChild(this.viewportElement); this.viewportScrollArea = document.createElement('div'); this.viewportScrollArea.classList.add('xterm-scroll-area'); this.viewportElement.appendChild(this.viewportScrollArea); - // preload audio - this.syncBellSound(); - this._mouseZoneManager = new MouseZoneManager(this); this.on('scroll', () => this._mouseZoneManager.clearAll()); this.linkifier.attachToDom(this._mouseZoneManager); @@ -609,8 +611,8 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT // capturing DOM Events. Then produce the helpers. this.helperContainer = document.createElement('div'); this.helperContainer.classList.add('xterm-helpers'); - // TODO: This should probably be inserted once it's filled to prevent an additional layout - this.element.appendChild(this.helperContainer); + fragment.appendChild(this.helperContainer); + this.textarea = document.createElement('textarea'); this.textarea.classList.add('xterm-helper-textarea'); this.textarea.setAttribute('autocorrect', 'off'); @@ -628,11 +630,14 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.charSizeStyleElement = document.createElement('style'); this.helperContainer.appendChild(this.charSizeStyleElement); - - this.parent.appendChild(this.element); - this.charMeasure = new CharMeasure(document, this.helperContainer); + // Preload audio, this relied on helperContainer + this.syncBellSound(); + + // Performance: Add viewport and helper elements from the fragment + this.element.appendChild(fragment); + this.renderer = new Renderer(this, this.options.theme); this.options.theme = null; this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure); @@ -681,6 +686,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT // Listen for mouse events and translate // them into terminal mouse protocols. this.bindMouse(); + } /** @@ -2124,6 +2130,11 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT } private syncBellSound(): void { + // Don't update anything if the terminal has not been opened yet + if (!this.element) { + return; + } + if (this.soundBell() && this.bellAudioElement) { this.bellAudioElement.setAttribute('src', this.options.bellSound); } else if (this.soundBell()) { diff --git a/src/addons/fit/fit.ts b/src/addons/fit/fit.ts index 14addfdf..f8e02015 100644 --- a/src/addons/fit/fit.ts +++ b/src/addons/fit/fit.ts @@ -26,27 +26,22 @@ export function proposeGeometry(term) { var availableHeight = parentElementHeight - elementPaddingVer; var availableWidth = parentElementWidth - elementPaddingHor; var geometry = { - cols: Math.floor(availableWidth / term.charMeasure.width), - rows: Math.floor(availableHeight / Math.floor(term.charMeasure.height * term.getOption('lineHeight'))) + cols: Math.floor(availableWidth / term.renderer.dimensions.actualCellWidth), + rows: Math.floor(availableHeight / term.renderer.dimensions.actualCellHeight) }; return geometry; }; export function fit(term) { - // Wrap fit in a setTimeout as charMeasure needs time to get initialized - // after calling Terminal.open - setTimeout(function () { - var geometry = exports.proposeGeometry(term); - - if (geometry) { - // Force a full render - if (term.rows !== geometry.rows || term.cols !== geometry.cols) { - term.renderer.clear(); - term.resize(geometry.cols, geometry.rows); - } + var geometry = exports.proposeGeometry(term); + if (geometry) { + // Force a full render + if (term.rows !== geometry.rows || term.cols !== geometry.cols) { + term.renderer.clear(); + term.resize(geometry.cols, geometry.rows); } - }, 0); + } }; export function apply(terminalConstructor) { diff --git a/src/addons/winptyCompat/winptyCompat.ts b/src/addons/winptyCompat/winptyCompat.ts index c84a04a6..723bf011 100644 --- a/src/addons/winptyCompat/winptyCompat.ts +++ b/src/addons/winptyCompat/winptyCompat.ts @@ -20,7 +20,7 @@ export function winptyCompatInit(terminal): void { // space. This is certainly not without its problems, but generally on // Windows when text reaches the end of the terminal it's likely going to be // wrapped. - terminal.on('lineFeed', () => { + terminal.on('linefeed', () => { const line = terminal.buffer.lines.get(terminal.buffer.ybase + terminal.buffer.y - 1); const lastChar = line[terminal.cols - 1]; diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index ea373e5e..36727eac 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -52,6 +52,7 @@ export class Renderer extends EventEmitter implements IRenderer { actualCellHeight: null }; this._devicePixelRatio = window.devicePixelRatio; + this._updateDimensions(); } public onWindowResize(devicePixelRatio: number): void { @@ -78,59 +79,8 @@ export class Renderer extends EventEmitter implements IRenderer { } public onResize(cols: number, rows: number, didCharSizeChange: boolean): void { - if (!this._terminal.charMeasure.width || !this._terminal.charMeasure.height) { - return; - } - - // Calculate the scaled character width. Width is floored as it must be - // drawn to an integer grid in order for the CharAtlas "stamps" to not be - // blurry. When text is drawn to the grid not using the CharAtlas, it is - // clipped to ensure there is no overlap with the next cell. - 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 cell height, if lineHeight is not 1 then the value - // will be floored because since lineHeight can never be lower then 1, there - // is a guarentee that the scaled line height will always be larger than - // scaled char height. - this.dimensions.scaledCellHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.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.scaledCharTop = this._terminal.options.lineHeight === 1 ? 0 : Math.round((this.dimensions.scaledCellHeight - this.dimensions.scaledCharHeight) / 2); - - // Calculate the scaled cell width, taking the letterSpacing into account. - this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth + Math.round(this._terminal.options.letterSpacing); - - // Calculate the x coordinate with a cell that text should draw from in - // order to draw in the center of a cell. - this.dimensions.scaledCharLeft = Math.floor(this._terminal.options.letterSpacing / 2); - - // Recalculate the canvas dimensions; scaled* define the actual number of - // pixel in the canvas - this.dimensions.scaledCanvasHeight = this._terminal.rows * this.dimensions.scaledCellHeight; - this.dimensions.scaledCanvasWidth = this._terminal.cols * this.dimensions.scaledCellWidth; - - // The the size of the canvas on the page. It's very important that this - // rounds to nearest integer and not ceils as browsers often set - // window.devicePixelRatio as something like 1.100000023841858, when it's - // actually 1.1. Ceiling causes blurriness as the backing canvas image is 1 - // pixel too large for the canvas element size. - this.dimensions.canvasHeight = Math.round(this.dimensions.scaledCanvasHeight / window.devicePixelRatio); - this.dimensions.canvasWidth = Math.round(this.dimensions.scaledCanvasWidth / window.devicePixelRatio); - - // Get the _actual_ dimensions of an individual cell. This needs to be - // derived from the canvasWidth/Height calculated above which takes into - // account window.devicePixelRatio. CharMeasure.width/height by itself is - // insufficient when the page is not at 100% zoom level as CharMeasure is - // measured in CSS pixels, but the actual char size on the canvas can - // differ. - this.dimensions.actualCellHeight = this.dimensions.canvasHeight / this._terminal.rows; - this.dimensions.actualCellWidth = this.dimensions.canvasWidth / this._terminal.cols; + // Update character and canvas dimensions + this._updateDimensions(); // Resize all render layers this._renderLayers.forEach(l => l.resize(this._terminal, this.dimensions, didCharSizeChange)); @@ -218,4 +168,66 @@ export class Renderer extends EventEmitter implements IRenderer { this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); this._terminal.emit('refresh', {start, end}); } + + /** + * Recalculates the character and canvas dimensions. + */ + private _updateDimensions(): void { + // Perform a new measure if the CharMeasure dimensions are not yet available + if (!this._terminal.charMeasure.width || !this._terminal.charMeasure.height) { + return; + } + + // Calculate the scaled character width. Width is floored as it must be + // drawn to an integer grid in order for the CharAtlas "stamps" to not be + // blurry. When text is drawn to the grid not using the CharAtlas, it is + // clipped to ensure there is no overlap with the next cell. + 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 cell height, if lineHeight is not 1 then the value + // will be floored because since lineHeight can never be lower then 1, there + // is a guarentee that the scaled line height will always be larger than + // scaled char height. + this.dimensions.scaledCellHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.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.scaledCharTop = this._terminal.options.lineHeight === 1 ? 0 : Math.round((this.dimensions.scaledCellHeight - this.dimensions.scaledCharHeight) / 2); + + // Calculate the scaled cell width, taking the letterSpacing into account. + this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth + Math.round(this._terminal.options.letterSpacing); + + // Calculate the x coordinate with a cell that text should draw from in + // order to draw in the center of a cell. + this.dimensions.scaledCharLeft = Math.floor(this._terminal.options.letterSpacing / 2); + + // Recalculate the canvas dimensions; scaled* define the actual number of + // pixel in the canvas + this.dimensions.scaledCanvasHeight = this._terminal.rows * this.dimensions.scaledCellHeight; + this.dimensions.scaledCanvasWidth = this._terminal.cols * this.dimensions.scaledCellWidth; + + // The the size of the canvas on the page. It's very important that this + // rounds to nearest integer and not ceils as browsers often set + // window.devicePixelRatio as something like 1.100000023841858, when it's + // actually 1.1. Ceiling causes blurriness as the backing canvas image is 1 + // pixel too large for the canvas element size. + this.dimensions.canvasHeight = Math.round(this.dimensions.scaledCanvasHeight / window.devicePixelRatio); + this.dimensions.canvasWidth = Math.round(this.dimensions.scaledCanvasWidth / window.devicePixelRatio); + + // Get the _actual_ dimensions of an individual cell. This needs to be + // derived from the canvasWidth/Height calculated above which takes into + // account window.devicePixelRatio. CharMeasure.width/height by itself is + // insufficient when the page is not at 100% zoom level as CharMeasure is + // measured in CSS pixels, but the actual char size on the canvas can + // differ. + this.dimensions.actualCellHeight = this.dimensions.canvasHeight / this._terminal.rows; + this.dimensions.actualCellWidth = this.dimensions.canvasWidth / this._terminal.cols; + + } + } diff --git a/src/utils/CharMeasure.test.ts b/src/utils/CharMeasure.test.ts index 5d2358f4..f50ce96b 100644 --- a/src/utils/CharMeasure.test.ts +++ b/src/utils/CharMeasure.test.ts @@ -25,41 +25,18 @@ describe('CharMeasure', () => { }); describe('measure', () => { - it('should set _measureElement on first call', () => { - charMeasure.measure({}); - assert.isDefined((charMeasure)._measureElement, 'CharMeasure.measure should have created _measureElement'); + it('should have _measureElement', () => { + assert.isDefined((charMeasure)._measureElement, 'new CharMeasure() should have created _measureElement'); }); - it('should be performed async on first call', done => { - assert.equal(charMeasure.width, null); - charMeasure.measure({}); + it('should be performed sync', () => { // Mock getBoundingClientRect since jsdom doesn't have a layout engine (charMeasure)._measureElement.getBoundingClientRect = () => { return { width: 1, height: 1 }; }; - assert.equal(charMeasure.width, null); - setTimeout(() => { - assert.equal(charMeasure.width, 1); - done(); - }, 0); - }); - - it('should be performed sync on successive calls', done => { charMeasure.measure({}); - // Mock getBoundingClientRect since jsdom doesn't have a layout engine - (charMeasure)._measureElement.getBoundingClientRect = () => { - return { width: 1, height: 1 }; - }; - setTimeout(() => { - const firstWidth = charMeasure.width; - // Mock getBoundingClientRect since jsdom doesn't have a layout engine - (charMeasure)._measureElement.getBoundingClientRect = () => { - return { width: 2, height: 2 }; - }; - charMeasure.measure({}); - assert.equal(charMeasure.width, firstWidth * 2); - done(); - }, 0); + assert.equal(charMeasure.height, 1); + assert.equal(charMeasure.width, 1); }); it('should NOT do a measure when the parent is hidden', done => { diff --git a/src/utils/CharMeasure.ts b/src/utils/CharMeasure.ts index 779ecbe8..62291ab2 100644 --- a/src/utils/CharMeasure.ts +++ b/src/utils/CharMeasure.ts @@ -22,6 +22,14 @@ export class CharMeasure extends EventEmitter implements ICharMeasure { super(); this._document = document; this._parentElement = parentElement; + this._measureElement = this._document.createElement('span'); + this._measureElement.style.position = 'absolute'; + this._measureElement.style.top = '0'; + this._measureElement.style.left = '-9999em'; + this._measureElement.style.lineHeight = 'normal'; + this._measureElement.textContent = 'W'; + this._measureElement.setAttribute('aria-hidden', 'true'); + this._parentElement.appendChild(this._measureElement); } public get width(): number { @@ -33,24 +41,6 @@ export class CharMeasure extends EventEmitter implements ICharMeasure { } public measure(options: ITerminalOptions): void { - if (!this._measureElement) { - this._measureElement = this._document.createElement('span'); - this._measureElement.style.position = 'absolute'; - this._measureElement.style.top = '0'; - this._measureElement.style.left = '-9999em'; - this._measureElement.style.lineHeight = 'normal'; - this._measureElement.textContent = 'W'; - this._measureElement.setAttribute('aria-hidden', 'true'); - this._parentElement.appendChild(this._measureElement); - // Perform _doMeasure async if the element was just attached as sometimes - // getBoundingClientRect does not return accurate values without this. - setTimeout(() => this._doMeasure(options), 0); - } else { - this._doMeasure(options); - } - } - - private _doMeasure(options: ITerminalOptions): void { this._measureElement.style.fontFamily = options.fontFamily; this._measureElement.style.fontSize = `${options.fontSize}px`; const geometry = this._measureElement.getBoundingClientRect(); @@ -65,4 +55,5 @@ export class CharMeasure extends EventEmitter implements ICharMeasure { this.emit('charsizechanged'); } } + } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 43be69c2..83199e08 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -20,7 +20,7 @@ declare module 'xterm' { /** * The type of the bell notification the terminal will use. */ - bellStyle?: 'none' | 'visual' | 'sound' | 'both'; + bellStyle?: 'none' /*| 'visual'*/ | 'sound' /*| 'both'*/; /** * The number of columns in the terminal. @@ -218,7 +218,7 @@ declare module 'xterm' { * @param type The type of the event. * @param listener The listener. */ - on(type: 'blur' | 'focus' | 'lineFeed' | 'selection', listener: () => void): void; + on(type: 'blur' | 'focus' | 'linefeed' | 'selection', listener: () => void): void; /** * Registers an event listener. * @param type The type of the event. @@ -273,7 +273,7 @@ declare module 'xterm' { * @param type The type of the event. * @param listener The listener. */ - off(type: 'blur' | 'focus' | 'lineFeed' | 'selection' | 'data' | 'key' | 'keypress' | 'keydown' | 'refresh' | 'resize' | 'scroll' | 'title' | string, listener: (...args: any[]) => void): void; + off(type: 'blur' | 'focus' | 'linefeed' | 'selection' | 'data' | 'key' | 'keypress' | 'keydown' | 'refresh' | 'resize' | 'scroll' | 'title' | string, listener: (...args: any[]) => void): void; /** * Resizes the terminal. @@ -500,10 +500,10 @@ declare module 'xterm' { reset(): void /** - * Loads an addon, attaching it to the Terminal prototype and making it - * available to all newly created Terminals. - * @param addon The addon to load. + * Applies an addon to the Terminal prototype, making it available to all + * newly created Terminals. + * @param addon The addon to apply. */ - static loadAddon(addon: 'attach' | 'fit' | 'fullscreen' | 'search' | 'terminado' | 'winptyCompat'): void; + static applyAddon(addon: any): void; } }