diff --git a/README.md b/README.md index 39c80a46..82bc3f08 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # [![xterm.js logo](logo-full.png)](https://xtermjs.org) -[![xterm.js build status](https://api.travis-ci.org/xtermjs/xterm.js.svg)](https://travis-ci.org/xtermjs/xterm.js) [![Coverage Status](https://coveralls.io/repos/github/xtermjs/xterm.js/badge.svg?branch=master)](https://coveralls.io/github/xtermjs/xterm.js?branch=master) [![Gitter](https://badges.gitter.im/sourcelair/xterm.js.svg)](https://gitter.im/sourcelair/xterm.js?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![jsDelivr Hits](https://data.jsdelivr.com/v1/package/npm/xterm/badge?style=rounded)](https://www.jsdelivr.com/package/npm/xterm) +[![Travis CI build status](https://api.travis-ci.org/xtermjs/xterm.js.svg)](https://travis-ci.org/xtermjs/xterm.js) [![VSTS Build status](https://xtermjs.visualstudio.com/_apis/public/build/definitions/3e323cf7-5760-460d-af64-ee5675baf366/1/badge)](https://xtermjs.visualstudio.com/xterm.js/_build/index?definitionId=1) [![Coverage Status](https://coveralls.io/repos/github/xtermjs/xterm.js/badge.svg?branch=master)](https://coveralls.io/github/xtermjs/xterm.js?branch=master) [![Gitter](https://badges.gitter.im/sourcelair/xterm.js.svg)](https://gitter.im/sourcelair/xterm.js?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![jsDelivr Hits](https://data.jsdelivr.com/v1/package/npm/xterm/badge?style=rounded)](https://www.jsdelivr.com/package/npm/xterm) Xterm.js is a terminal front-end component written in JavaScript that works in the browser. diff --git a/package.json b/package.json index 44cc2e88..5c7574ee 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "license": "MIT", "devDependencies": { "@types/chai": "^3.4.34", + "@types/glob": "^5.0.35", "@types/jsdom": "^11.0.1", "@types/mocha": "^2.2.33", "@types/node": "6.0.108", diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index 44687f0e..82aa0224 100644 --- a/src/Buffer.test.ts +++ b/src/Buffer.test.ts @@ -48,6 +48,63 @@ describe('Buffer', () => { }); }); + describe('getWrappedRangeForLine', () => { + describe('non-wrapped', () => { + it('should return a single row for the first row', () => { + buffer.fillViewportRows(); + assert.deepEqual(buffer.getWrappedRangeForLine(0), { first: 0, last: 0 }); + }); + it('should return a single row for a middle row', () => { + buffer.fillViewportRows(); + assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 12, last: 12 }); + }); + it('should return a single row for the last row', () => { + buffer.fillViewportRows(); + assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 1), { first: 23, last: 23 }); + }); + }); + describe('wrapped', () => { + it('should return a range for the first row', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(1)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(0), { first: 0, last: 1 }); + }); + it('should return a range for a middle row wrapping upwards', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(12)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 11, last: 12 }); + }); + it('should return a range for a middle row wrapping downwards', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(13)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 12, last: 13 }); + }); + it('should return a range for a middle row wrapping both ways', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(11)).isWrapped = true; + ( buffer.lines.get(12)).isWrapped = true; + ( buffer.lines.get(13)).isWrapped = true; + ( buffer.lines.get(14)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(12), { first: 10, last: 14 }); + }); + it('should return a range for the last row', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(23)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 1), { first: 22, last: 23 }); + }); + it('should return a range for a row that wraps upward to first row', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(1)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(1), { first: 0, last: 1 }); + }); + it('should return a range for a row that wraps downward to last row', () => { + buffer.fillViewportRows(); + ( buffer.lines.get(buffer.lines.length - 1)).isWrapped = true; + assert.deepEqual(buffer.getWrappedRangeForLine(buffer.lines.length - 2), { first: 22, last: 23 }); + }); + }); + }); + describe('resize', () => { describe('column size is reduced', () => { it('should not trim the data in the buffer', () => { diff --git a/src/Buffer.ts b/src/Buffer.ts index 1ea303b2..8cc734f1 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -8,6 +8,7 @@ import { LineData, CharData, ITerminal, IBuffer } from './Types'; import { EventEmitter } from './EventEmitter'; import { IDisposable, IMarker } from 'xterm'; +export const DEFAULT_ATTR = (0 << 18) | (257 << 9) | (256 << 0); export const CHAR_DATA_ATTR_INDEX = 0; export const CHAR_DATA_CHAR_INDEX = 1; export const CHAR_DATA_WIDTH_INDEX = 2; @@ -116,7 +117,7 @@ export class Buffer implements IBuffer { if (this.lines.length > 0) { // Deal with columns increasing (we don't do anything when columns reduce) if (this._terminal.cols < newCols) { - const ch: CharData = [this._terminal.defAttr, ' ', 1, 32]; // does xterm use the default attr? + const ch: CharData = [DEFAULT_ATTR, ' ', 1, 32]; // does xterm use the default attr? for (let i = 0; i < this.lines.length; i++) { while (this.lines.get(i).length < newCols) { this.lines.get(i).push(ch); @@ -259,6 +260,20 @@ export class Buffer implements IBuffer { return lineString.substring(startIndex, endIndex); } + public getWrappedRangeForLine(y: number): { first: number, last: number } { + let first = y; + let last = y; + // Scan upwards for wrapped lines + while (first > 0 && (this.lines.get(first)).isWrapped) { + first--; + } + // Scan downwards for wrapped lines + while (last + 1 < this.lines.length && (this.lines.get(last + 1)).isWrapped) { + last++; + } + return { first, last }; + } + /** * Setup the tab stops. * @param i The index to start setting up tab stops from. diff --git a/src/CompositionHelper.test.ts b/src/CompositionHelper.test.ts index 02231723..053a0c29 100644 --- a/src/CompositionHelper.test.ts +++ b/src/CompositionHelper.test.ts @@ -5,13 +5,14 @@ import { assert } from 'chai'; import { CompositionHelper } from './CompositionHelper'; +import { ITerminal } from './Types'; describe('CompositionHelper', () => { - let terminal; - let compositionHelper; - let compositionView; - let textarea; - let handledText; + let terminal: ITerminal; + let compositionHelper: CompositionHelper; + let compositionView: HTMLElement; + let textarea: HTMLTextAreaElement; + let handledText: string; beforeEach(() => { compositionView = { @@ -27,14 +28,14 @@ describe('CompositionHelper', () => { top: 0 }, textContent: '' - }; + } as any; textarea = { value: '', style: { left: 0, top: 0 } - }; + } as any; terminal = { element: { querySelector: () => { @@ -54,7 +55,7 @@ describe('CompositionHelper', () => { options: { lineHeight: 1 } - }; + } as any; handledText = ''; compositionHelper = new CompositionHelper(textarea, compositionView, terminal); }); @@ -63,7 +64,7 @@ describe('CompositionHelper', () => { it('Should insert simple characters', (done) => { // First character 'ㅇ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -71,7 +72,7 @@ describe('CompositionHelper', () => { assert.equal(handledText, 'ㅇ'); // Second character 'ㅇ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇㅇ'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -87,13 +88,13 @@ describe('CompositionHelper', () => { it('Should insert complex characters', (done) => { // First character '앙' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '아' }); + compositionHelper.compositionupdate({ data: '아' }); textarea.value = '아'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '앙' }); + compositionHelper.compositionupdate({ data: '앙' }); textarea.value = '앙'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -101,13 +102,13 @@ describe('CompositionHelper', () => { assert.equal(handledText, '앙'); // Second character '앙' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = '앙ㅇ'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '아' }); + compositionHelper.compositionupdate({ data: '아' }); textarea.value = '앙아'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '앙' }); + compositionHelper.compositionupdate({ data: '앙' }); textarea.value = '앙앙'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -127,19 +128,19 @@ describe('CompositionHelper', () => { it('Should insert complex characters that change with following character', (done) => { // First character '아' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: '아' }); + compositionHelper.compositionupdate({ data: '아' }); textarea.value = '아'; setTimeout(() => { // wait for any textarea updates // Start second character '아' in first character - compositionHelper.compositionupdate({ data: '앙' }); + compositionHelper.compositionupdate({ data: '앙' }); textarea.value = '앙'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: '아' }); + compositionHelper.compositionupdate({ data: '아' }); textarea.value = '아아'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -156,14 +157,14 @@ describe('CompositionHelper', () => { it('Should insert multi-characters compositions', (done) => { // First character 'だ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'd' }); + compositionHelper.compositionupdate({ data: 'd' }); textarea.value = 'd'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: 'だ' }); + compositionHelper.compositionupdate({ data: 'だ' }); textarea.value = 'だ'; setTimeout(() => { // wait for any textarea updates // Second character 'あ' - compositionHelper.compositionupdate({ data: 'だあ' }); + compositionHelper.compositionupdate({ data: 'だあ' }); textarea.value = 'だあ'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -179,18 +180,18 @@ describe('CompositionHelper', () => { it('Should insert multi-character compositions that are converted to other characters with the same length', (done) => { // First character 'だ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'd' }); + compositionHelper.compositionupdate({ data: 'd' }); textarea.value = 'd'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: 'だ' }); + compositionHelper.compositionupdate({ data: 'だ' }); textarea.value = 'だ'; setTimeout(() => { // wait for any textarea updates // Second character 'ー' - compositionHelper.compositionupdate({ data: 'だー' }); + compositionHelper.compositionupdate({ data: 'だー' }); textarea.value = 'だー'; setTimeout(() => { // wait for any textarea updates // Convert to katakana 'ダー' - compositionHelper.compositionupdate({ data: 'ダー' }); + compositionHelper.compositionupdate({ data: 'ダー' }); textarea.value = 'ダー'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -207,18 +208,18 @@ describe('CompositionHelper', () => { it('Should insert multi-character compositions that are converted to other characters with different lengths', (done) => { // First character 'い' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'い' }); + compositionHelper.compositionupdate({ data: 'い' }); textarea.value = 'い'; setTimeout(() => { // wait for any textarea updates // Second character 'ま' - compositionHelper.compositionupdate({ data: 'いm' }); + compositionHelper.compositionupdate({ data: 'いm' }); textarea.value = 'いm'; setTimeout(() => { // wait for any textarea updates - compositionHelper.compositionupdate({ data: 'いま' }); + compositionHelper.compositionupdate({ data: 'いま' }); textarea.value = 'いま'; setTimeout(() => { // wait for any textarea updates // Convert to kanji '今' - compositionHelper.compositionupdate({ data: '今' }); + compositionHelper.compositionupdate({ data: '今' }); textarea.value = '今'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); @@ -235,7 +236,7 @@ describe('CompositionHelper', () => { it('Should insert non-composition characters input immediately after composition characters', (done) => { // First character 'ㅇ' compositionHelper.compositionstart(); - compositionHelper.compositionupdate({ data: 'ㅇ' }); + compositionHelper.compositionupdate({ data: 'ㅇ' }); textarea.value = 'ㅇ'; setTimeout(() => { // wait for any textarea updates compositionHelper.compositionend(); diff --git a/src/EscapeSequenceParser.test.ts b/src/EscapeSequenceParser.test.ts index d9ea64ce..dffbccbc 100644 --- a/src/EscapeSequenceParser.test.ts +++ b/src/EscapeSequenceParser.test.ts @@ -237,7 +237,7 @@ describe('EscapeSequenceParser', function (): void { '\x89', '\x8a', '\x8b', '\x8c', '\x8d', '\x8e', '\x8f', '\x91', '\x92', '\x93', '\x94', '\x95', '\x96', '\x97', '\x99', '\x9a' ]; - const exceptions = { + const exceptions: { [key: number]: { [key: string]: any[] } } = { 8: { '\x18': [], '\x1a': [] } // simply abort osc state }; parser.reset(); @@ -247,7 +247,7 @@ describe('EscapeSequenceParser', function (): void { parser.currentState = state; parser.parse(exes[i]); chai.expect(parser.currentState).equal(ParserState.GROUND); - testTerminal.compare(((exceptions[state]) ? exceptions[state][exes[i]] : 0) || [['exe', exes[i]]]); + testTerminal.compare((state in exceptions ? exceptions[state][exes[i]] : 0) || [['exe', exes[i]]]); parser.reset(); testTerminal.clear(); } @@ -1099,20 +1099,20 @@ describe('EscapeSequenceParser', function (): void { describe('set/clear handler', function (): void { const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1b]1;foo=bar\x1b\\'; - let parser2 = null; + let parser2: TestEscapeSequenceParser = null; let print = ''; - let esc = []; - let csi = []; - let exe = []; - let osc = []; - let dcs = []; + const esc: string[] = []; + const csi: [string, number[], string][] = []; + const exe: string[] = []; + const osc: [number, string][] = []; + const dcs: ([string] | [string, string] | [string, string, number[], number])[] = []; function clearAccu(): void { print = ''; - esc = []; - csi = []; - exe = []; - osc = []; - dcs = []; + esc.length = 0; + csi.length = 0; + exe.length = 0; + osc.length = 0; + dcs.length = 0; } beforeEach(function (): void { parser2 = new TestEscapeSequenceParser(); diff --git a/src/InputHandler.ts b/src/InputHandler.ts index f5db133f..817efc71 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -7,7 +7,7 @@ import { CharData, IInputHandler, IDcsHandler, IEscapeSequenceParser, IBuffer, ICharset } from './Types'; import { C0, C1 } from './EscapeSequences'; import { CHARSETS, DEFAULT_CHARSET } from './Charsets'; -import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CODE_INDEX } from './Buffer'; +import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CODE_INDEX, DEFAULT_ATTR } from './Buffer'; import { FLAGS } from './renderer/Types'; import { wcwidth } from './CharWidth'; import { EscapeSequenceParser } from './EscapeSequenceParser'; @@ -15,7 +15,7 @@ import { EscapeSequenceParser } from './EscapeSequenceParser'; /** * Map collect to glevel. Used in `selectCharset`. */ -const GLEVEL = {'(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2}; +const GLEVEL: {[key: string]: number} = {'(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2}; /** @@ -77,7 +77,7 @@ class DECRQSS implements IDcsHandler { // TODO: report real settings instead of 0m return this._terminal.send(`${C0.ESC}P1$r0m${C0.ESC}\\`); case ' q': // DECSCUSR - const STYLES = {'block': 2, 'underline': 4, 'bar': 6}; + const STYLES: {[key: string]: number} = {'block': 2, 'underline': 4, 'bar': 6}; let style = STYLES[this._terminal.getOption('cursorStyle')]; style -= this._terminal.getOption('cursorBlink'); return this._terminal.send(`${C0.ESC}P1$r${style} q${C0.ESC}\\`); @@ -970,7 +970,7 @@ export class InputHandler implements IInputHandler { const buffer = this._terminal.buffer; const line = buffer.lines.get(buffer.ybase + buffer.y); - const ch = line[buffer.x - 1] || [this._terminal.defAttr, ' ', 1, 32]; + const ch = line[buffer.x - 1] || [DEFAULT_ATTR, ' ', 1, 32]; while (param--) { line[buffer.x++] = ch; @@ -1553,7 +1553,7 @@ export class InputHandler implements IInputHandler { public charAttributes(params: number[]): void { // Optimize a single SGR0. if (params.length === 1 && params[0] === 0) { - this._terminal.curAttr = this._terminal.defAttr; + this._terminal.curAttr = DEFAULT_ATTR; return; } @@ -1581,9 +1581,9 @@ export class InputHandler implements IInputHandler { bg = p - 100; } else if (p === 0) { // default - flags = this._terminal.defAttr >> 18; - fg = (this._terminal.defAttr >> 9) & 0x1ff; - bg = this._terminal.defAttr & 0x1ff; + flags = DEFAULT_ATTR >> 18; + fg = (DEFAULT_ATTR >> 9) & 0x1ff; + bg = DEFAULT_ATTR & 0x1ff; // flags = 0; // fg = 0x1ff; // bg = 0x1ff; @@ -1627,10 +1627,10 @@ export class InputHandler implements IInputHandler { flags &= ~FLAGS.INVISIBLE; } else if (p === 39) { // reset fg - fg = (this._terminal.defAttr >> 9) & 0x1ff; + fg = (DEFAULT_ATTR >> 9) & 0x1ff; } else if (p === 49) { // reset bg - bg = this._terminal.defAttr & 0x1ff; + bg = DEFAULT_ATTR & 0x1ff; } else if (p === 38) { // fg color 256 if (params[i + 1] === 2) { @@ -1663,8 +1663,8 @@ export class InputHandler implements IInputHandler { } } else if (p === 100) { // reset fg/bg - fg = (this._terminal.defAttr >> 9) & 0x1ff; - bg = this._terminal.defAttr & 0x1ff; + fg = (DEFAULT_ATTR >> 9) & 0x1ff; + bg = DEFAULT_ATTR & 0x1ff; } else { this._terminal.error('Unknown SGR attribute: %d.', p); } @@ -1759,7 +1759,7 @@ export class InputHandler implements IInputHandler { this._terminal.applicationCursor = false; this._terminal.buffer.scrollTop = 0; this._terminal.buffer.scrollBottom = this._terminal.rows - 1; - this._terminal.curAttr = this._terminal.defAttr; + this._terminal.curAttr = DEFAULT_ATTR; this._terminal.buffer.x = this._terminal.buffer.y = 0; // ? this._terminal.charset = null; this._terminal.glevel = 0; // ?? diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index 06cea766..8e89ea3f 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -298,6 +298,16 @@ describe('SelectionManager', () => { assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); assert.deepEqual(selectionManager.model.finalSelectionEnd, [terminal.cols, 0], 'The actual selection spans the entire column'); }); + it('should select the entire wrapped line', () => { + buffer.lines.set(0, stringToRow('foo')); + const line2 = stringToRow('bar'); + (line2).isWrapped = true; + buffer.lines.set(1, line2); + selectionManager.selectLineAt(0); + assert.equal(selectionManager.selectionText, 'foobar', 'The selected text is correct'); + assert.deepEqual(selectionManager.model.finalSelectionStart, [0, 0]); + assert.deepEqual(selectionManager.model.finalSelectionEnd, [terminal.cols, 1], 'The actual selection spans the entire column'); + }); }); describe('selectAll', () => { diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index c150554f..1b056e83 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -803,7 +803,9 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param line The line index. */ protected _selectLineAt(line: number): void { - this._model.selectionStart = [0, line]; - this._model.selectionStartLength = this._terminal.cols; + const wrappedRange = this._buffer.getWrappedRangeForLine(line); + this._model.selectionStart = [0, wrappedRange.first]; + this._model.selectionEnd = [this._terminal.cols, wrappedRange.last]; + this._model.selectionStartLength = 0; } } diff --git a/src/Terminal.integration.ts b/src/Terminal.integration.ts index 54e76518..bb50d2d0 100644 --- a/src/Terminal.integration.ts +++ b/src/Terminal.integration.ts @@ -14,6 +14,11 @@ import * as pty from 'node-pty'; import { assert } from 'chai'; import { Terminal } from './Terminal'; import { CHAR_DATA_CHAR_INDEX } from './Buffer'; +import { IViewport } from './Types'; + +class TestTerminal extends Terminal { + innerWrite(): void { this._innerWrite(); } +} let primitivePty: any; @@ -87,12 +92,12 @@ if (os.platform() !== 'win32') { /** tests */ describe('xterm output comparison', () => { - let xterm; + let xterm: TestTerminal; beforeEach(() => { - xterm = new Terminal({ cols: COLS, rows: ROWS }); + xterm = new TestTerminal({ cols: COLS, rows: ROWS }); xterm.refresh = () => {}; - xterm.viewport = { + xterm.viewport = { syncScrollArea: () => {} }; }); @@ -125,7 +130,7 @@ if (os.platform() !== 'win32') { // Perform a synchronous .write(data) xterm.writeBuffer.push(fromPty); - xterm._innerWrite(); + xterm.innerWrite(); const fromEmulator = terminalToString(xterm); console.log = CONSOLE_LOG; diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index 5b7039e9..f6cbad52 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -176,7 +176,7 @@ describe('term.js addons', () => { describe('scroll', () => { describe('scrollLines', () => { - let startYDisp; + let startYDisp: number; beforeEach(() => { for (let i = 0; i < term.rows * 2; i++) { term.writeln('test'); @@ -211,7 +211,7 @@ describe('term.js addons', () => { }); describe('scrollPages', () => { - let startYDisp; + let startYDisp: number; beforeEach(() => { for (let i = 0; i < term.rows * 3; i++) { term.writeln('test'); @@ -248,7 +248,7 @@ describe('term.js addons', () => { }); describe('scrollToBottom', () => { - let startYDisp; + let startYDisp: number; beforeEach(() => { for (let i = 0; i < term.rows * 3; i++) { term.writeln('test'); @@ -269,7 +269,7 @@ describe('term.js addons', () => { }); describe('scrollToLine', () => { - let startYDisp; + let startYDisp: number; beforeEach(() => { for (let i = 0; i < term.rows * 3; i++) { term.writeln('test'); @@ -712,8 +712,8 @@ describe('term.js addons', () => { }); describe('Third level shift', () => { - let evKeyDown; - let evKeyPress; + let evKeyDown: any; + let evKeyPress: any; beforeEach(() => { term.handler = () => {}; diff --git a/src/Terminal.ts b/src/Terminal.ts index 7d4d9b46..bceeb9da 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -25,7 +25,7 @@ import { ICharset, IInputHandlingTerminal, IViewport, ICompositionHelper, ITermi import { IMouseZoneManager } from './input/Types'; import { IRenderer } from './renderer/Types'; import { BufferSet } from './BufferSet'; -import { Buffer, MAX_BUFFER_SIZE } from './Buffer'; +import { Buffer, MAX_BUFFER_SIZE, DEFAULT_ATTR } from './Buffer'; import { CompositionHelper } from './CompositionHelper'; import { EventEmitter } from './EventEmitter'; import { Viewport } from './Viewport'; @@ -49,9 +49,10 @@ import { AccessibilityManager } from './AccessibilityManager'; import { ScreenDprMonitor } from './utils/ScreenDprMonitor'; import { ITheme, ILocalizableStrings, IMarker, IDisposable } from 'xterm'; import { removeTerminalFromCache } from './renderer/atlas/CharAtlasCache'; +import { DomRenderer } from './renderer/dom/DomRenderer'; // reg + shift key mappings for digits and special chars -const KEYCODE_KEY_MAPPINGS = { +const KEYCODE_KEY_MAPPINGS: { [key: number]: [string, string]} = { // digits 0-9 48: ['0', ')'], 49: ['1', '!'], @@ -128,7 +129,8 @@ const DEFAULT_OPTIONS: ITerminalOptions = { allowTransparency: false, tabStopWidth: 8, theme: null, - rightClickSelectsWord: Browser.isMac + rightClickSelectsWord: Browser.isMac, + rendererType: 'canvas' }; export class Terminal extends EventEmitter implements ITerminal, IDisposable, IInputHandlingTerminal { @@ -195,7 +197,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II private _refreshEnd: number; public savedCols: number; - public defAttr: number; public curAttr: number; public params: (string | number)[]; @@ -282,8 +283,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (this.options[key] == null) { this.options[key] = DEFAULT_OPTIONS[key]; } - // TODO: We should move away from duplicate options on the Terminal object - this[key] = this.options[key]; }); // this.context = options.context || window; @@ -318,8 +317,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // TODO: Can this be just []? this.charsets = [null]; - this.defAttr = (0 << 18) | (257 << 9) | (256 << 0); - this.curAttr = (0 << 18) | (257 << 9) | (256 << 0); + this.curAttr = DEFAULT_ATTR; this.params = []; this.currentParam = 0; @@ -363,8 +361,8 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II * back_color_erase feature for xterm. */ public eraseAttr(): number { - // if (this.is('screen')) return this.defAttr; - return (this.defAttr & ~0x1ff) | (this.curAttr & 0x1ff); + // if (this.is('screen')) return DEFAULT_ATTR; + return (DEFAULT_ATTR & ~0x1ff) | (this.curAttr & 0x1ff); } /** @@ -389,11 +387,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II throw new Error('No option with key "' + key + '"'); } - if (typeof this.options[key] !== 'undefined') { - return this.options[key]; - } - - return this[key]; + return this.options[key]; } /** @@ -470,7 +464,6 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } break; } - this[key] = value; this.options[key] = value; switch (key) { case 'fontFamily': @@ -569,7 +562,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } copyHandler(event, this, this.selectionManager); }); - const pasteHandlerWrapper = event => pasteHandler(event, this); + const pasteHandlerWrapper = (event: ClipboardEvent) => pasteHandler(event, this); on(this.textarea, 'paste', pasteHandlerWrapper); on(this.element, 'paste', pasteHandlerWrapper); @@ -708,7 +701,11 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // Performance: Add viewport and helper elements from the fragment this.element.appendChild(fragment); - this.renderer = new Renderer(this, this.options.theme); + switch (this.options.rendererType) { + case 'canvas': this.renderer = new Renderer(this, this.options.theme); break; + case 'dom': this.renderer = new DomRenderer(this, this.options.theme); break; + default: throw new Error(`Unrecognized rendererType "${this.options.rendererType}"`); + } this.options.theme = null; this.viewport = new Viewport(this, this._viewportElement, this._viewportScrollArea, this.charMeasure); this.viewport.onThemeChanged(this.renderer.colorManager.colors); @@ -721,7 +718,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // dprchange should handle this case, we need this as well for browsers that don't support the // matchMedia query. this._disposables.push(Dom.addDisposableListener(window, 'resize', () => this.renderer.onWindowResize(window.devicePixelRatio))); - this.charMeasure.on('charsizechanged', () => this.renderer.onResize(this.cols, this.rows)); + this.charMeasure.on('charsizechanged', () => this.renderer.onCharSizeChanged()); this.renderer.on('resize', (dimensions) => this.viewport.syncScrollArea()); this.selectionManager = new SelectionManager(this, this.charMeasure); @@ -1300,7 +1297,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } } - private _innerWrite(): void { + protected _innerWrite(): void { const writeBatch = this.writeBuffer.splice(0, WRITE_BATCH_SIZE); while (writeBatch.length > 0) { const data = writeBatch.shift(); @@ -2065,7 +2062,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II * set, the terminal's current column count would be used. */ public blankLine(cur?: boolean, isWrapped?: boolean, cols?: number): LineData { - const attr = cur ? this.eraseAttr() : this.defAttr; + const attr = cur ? this.eraseAttr() : DEFAULT_ATTR; const ch: CharData = [attr, ' ', 1, 32 /* ' '.charCodeAt(0) */]; // width defaults to 1 halfwidth character const line: LineData = []; @@ -2085,14 +2082,14 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } /** - * If cur return the back color xterm feature attribute. Else return defAttr. + * If cur return the back color xterm feature attribute. Else return default attribute. * @param cur */ public ch(cur?: boolean): CharData { if (cur) { return [this.eraseAttr(), ' ', 1, 32 /* ' '.charCodeAt(0) */]; } - return [this.defAttr, ' ', 1, 32 /* ' '.charCodeAt(0) */]; + return [DEFAULT_ATTR, ' ', 1, 32 /* ' '.charCodeAt(0) */]; } /** diff --git a/src/Types.ts b/src/Types.ts index 5075daa9..d9c09660 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -43,7 +43,6 @@ export interface IInputHandlingTerminal extends IEventEmitter { insertMode: boolean; wraparoundMode: boolean; bracketedPasteMode: boolean; - defAttr: number; curAttr: number; savedCols: number; x10Mouse: boolean; @@ -213,7 +212,6 @@ export interface ITerminal extends PublicTerminal, IElementAccessor, IBufferAcce writeBuffer: string[]; cursorHidden: boolean; cursorState: number; - defAttr: number; options: ITerminalOptions; buffer: IBuffer; buffers: IBufferSet; @@ -261,6 +259,7 @@ export interface ICharMeasure { // TODO: The options that are not in the public API should be reviewed export interface ITerminalOptions extends IPublicTerminalOptions { + [key: string]: any; cancelEvents?: boolean; convertEol?: boolean; debug?: boolean; @@ -284,6 +283,7 @@ export interface IBuffer { savedX: number; isCursorInViewport: boolean; translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string; + getWrappedRangeForLine(y: number): { first: number, last: number }; nextStop(x?: number): number; prevStop(x?: number): number; } diff --git a/src/addons/attach/attach.ts b/src/addons/attach/attach.ts index d0e8bd4b..e6d92b56 100644 --- a/src/addons/attach/attach.ts +++ b/src/addons/attach/attach.ts @@ -36,10 +36,11 @@ export function attach(term: Terminal, socket: WebSocket, bidirectional: boolean } }; - let myTextDecoder; + // TODO: This should be typed but there seem to be issues importing the type + let myTextDecoder: any; addonTerminal.__getMessage = function(ev: MessageEvent): void { - let str; + let str: string; if (typeof ev.data === 'object') { if (!myTextDecoder) { diff --git a/src/addons/fit/README.md b/src/addons/fit/README.md new file mode 100644 index 00000000..68f01589 --- /dev/null +++ b/src/addons/fit/README.md @@ -0,0 +1,3 @@ +## fit addon + +The fit addon adjusts the dimensions of the terminal to match best fit its parent element container. `fit` will only work when the element is visible. diff --git a/src/renderer/ColorManager.test.ts b/src/renderer/ColorManager.test.ts index 0012c1ed..26fb2895 100644 --- a/src/renderer/ColorManager.test.ts +++ b/src/renderer/ColorManager.test.ts @@ -36,7 +36,7 @@ describe('ColorManager', () => { for (const key of Object.keys(cm.colors)) { if (key !== 'ansi') { // A #rrggbb or rgba(...) - assert.ok(cm.colors[key].css.length >= 7); + assert.ok((cm.colors)[key].css.length >= 7); } } assert.equal(cm.colors.ansi.length, 256); diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts index edecf8b0..bafc9ad4 100644 --- a/src/renderer/Types.ts +++ b/src/renderer/Types.ts @@ -20,6 +20,10 @@ export const enum FLAGS { ITALIC = 64 } +/** + * Note that IRenderer implementations should emit the refresh event after + * rendering rows to the screen. + */ export interface IRenderer extends IEventEmitter { dimensions: IRenderDimensions; colorManager: IColorManager; diff --git a/src/renderer/atlas/CharAtlasUtils.ts b/src/renderer/atlas/CharAtlasUtils.ts index 39284d32..59ac07df 100644 --- a/src/renderer/atlas/CharAtlasUtils.ts +++ b/src/renderer/atlas/CharAtlasUtils.ts @@ -9,7 +9,7 @@ import { ICharAtlasConfig } from '../../shared/atlas/Types'; export function generateConfig(scaledCharWidth: number, scaledCharHeight: number, terminal: ITerminal, colors: IColorSet): ICharAtlasConfig { // null out some fields that don't matter - const clonedColors = { + const clonedColors = { foreground: colors.foreground, background: colors.background, cursor: null, diff --git a/src/renderer/atlas/LRUMap.ts b/src/renderer/atlas/LRUMap.ts index 4a03f2aa..eccfbfea 100644 --- a/src/renderer/atlas/LRUMap.ts +++ b/src/renderer/atlas/LRUMap.ts @@ -11,7 +11,7 @@ interface ILinkedListNode { } export default class LRUMap { - private _map = {}; + private _map: { [key: string]: ILinkedListNode } = {}; private _head: ILinkedListNode = null; private _tail: ILinkedListNode = null; private _nodePool: ILinkedListNode[] = []; diff --git a/src/renderer/dom/DomRenderer.ts b/src/renderer/dom/DomRenderer.ts new file mode 100644 index 00000000..5b1813b5 --- /dev/null +++ b/src/renderer/dom/DomRenderer.ts @@ -0,0 +1,315 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderer, IRenderDimensions, IColorSet } from '../Types'; +import { ITerminal } from '../../Types'; +import { ITheme } from 'xterm'; +import { EventEmitter } from '../../EventEmitter'; +import { ColorManager } from '../ColorManager'; +import { RenderDebouncer } from '../../utils/RenderDebouncer'; +import { BOLD_CLASS, ITALIC_CLASS, CURSOR_CLASS, DomRendererRowFactory } from './DomRendererRowFactory'; + +const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-'; +const ROW_CONTAINER_CLASS = 'xterm-rows'; +const FG_CLASS_PREFIX = 'xterm-fg-'; +const BG_CLASS_PREFIX = 'xterm-bg-'; +const FOCUS_CLASS = 'xterm-focus'; +const SELECTION_CLASS = 'xterm-selection'; + +let nextTerminalId = 1; + +// TODO: Pull into an addon when TS composite projects allow easier sharing of code (not just +// interfaces) between core and addons + +/** + * A fallback renderer for when canvas is slow. This is not meant to be + * particularly fast or feature complete, more just stable and usable for when + * canvas is not an option. + */ +export class DomRenderer extends EventEmitter implements IRenderer { + private _renderDebouncer: RenderDebouncer; + private _rowFactory: DomRendererRowFactory; + private _terminalClass: number = nextTerminalId++; + + private _themeStyleElement: HTMLStyleElement; + private _dimensionsStyleElement: HTMLStyleElement; + private _rowContainer: HTMLElement; + private _rowElements: HTMLElement[] = []; + private _selectionContainer: HTMLElement; + + public dimensions: IRenderDimensions; + public colorManager: ColorManager; + + constructor(private _terminal: ITerminal, theme: ITheme | undefined) { + super(); + const allowTransparency = this._terminal.options.allowTransparency; + this.colorManager = new ColorManager(document, allowTransparency); + this.setTheme(theme); + + this._rowContainer = document.createElement('div'); + this._rowContainer.classList.add(ROW_CONTAINER_CLASS); + this._rowContainer.style.lineHeight = 'normal'; + this._rowContainer.setAttribute('aria-hidden', 'true'); + this._refreshRowElements(this._terminal.rows, this._terminal.cols); + this._selectionContainer = document.createElement('div'); + this._selectionContainer.classList.add(SELECTION_CLASS); + this._selectionContainer.setAttribute('aria-hidden', 'true'); + + this.dimensions = { + scaledCharWidth: null, + scaledCharHeight: null, + scaledCellWidth: null, + scaledCellHeight: null, + scaledCharLeft: null, + scaledCharTop: null, + scaledCanvasWidth: null, + scaledCanvasHeight: null, + canvasWidth: null, + canvasHeight: null, + actualCellWidth: null, + actualCellHeight: null + }; + this._updateDimensions(); + + this._renderDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this)); + this._rowFactory = new DomRendererRowFactory(document); + + this._terminal.element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass); + this._terminal.screenElement.appendChild(this._rowContainer); + this._terminal.screenElement.appendChild(this._selectionContainer); + } + + private _updateDimensions(): void { + this.dimensions.scaledCharWidth = this._terminal.charMeasure.width * window.devicePixelRatio; + this.dimensions.scaledCharHeight = this._terminal.charMeasure.height * window.devicePixelRatio; + this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth; + this.dimensions.scaledCellHeight = this.dimensions.scaledCharHeight; + this.dimensions.scaledCharLeft = 0; + this.dimensions.scaledCharTop = 0; + this.dimensions.scaledCanvasWidth = this.dimensions.scaledCellWidth * this._terminal.cols; + this.dimensions.scaledCanvasHeight = this.dimensions.scaledCellHeight * this._terminal.rows; + this.dimensions.canvasWidth = this._terminal.charMeasure.width * this._terminal.cols; + this.dimensions.canvasHeight = this._terminal.charMeasure.height * this._terminal.rows; + this.dimensions.actualCellWidth = this._terminal.charMeasure.width; + this.dimensions.actualCellHeight = this._terminal.charMeasure.height; + + this._rowElements.forEach(element => { + element.style.width = `${this.dimensions.canvasWidth}px`; + element.style.height = `${this._terminal.charMeasure.height}px`; + }); + + if (!this._dimensionsStyleElement) { + this._dimensionsStyleElement = document.createElement('style'); + this._terminal.screenElement.appendChild(this._dimensionsStyleElement); + } + + const styles = + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` + + ` display: inline-block;` + + ` height: 100%;` + + ` vertical-align: top;` + + ` width: ${this._terminal.charMeasure.width}px` + + `}`; + + this._dimensionsStyleElement.innerHTML = styles; + + this._selectionContainer.style.height = (this._terminal)._viewportElement.style.height; + this._rowContainer.style.width = `${this.dimensions.canvasWidth}px`; + this._rowContainer.style.height = `${this.dimensions.canvasHeight}px`; + } + + public setTheme(theme: ITheme | undefined): IColorSet { + if (theme) { + this.colorManager.setTheme(theme); + } + + if (!this._themeStyleElement) { + this._themeStyleElement = document.createElement('style'); + this._terminal.screenElement.appendChild(this._themeStyleElement); + } + + // Base CSS + let styles = + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` + + ` color: ${this.colorManager.colors.foreground.css};` + + ` background-color: ${this.colorManager.colors.background.css};` + + ` font-family: ${this._terminal.getOption('fontFamily')};` + + ` font-size: ${this._terminal.getOption('fontSize')}px;` + + `}`; + // Text styles + styles += + `${this._terminalSelector} span:not(.${BOLD_CLASS}) {` + + ` font-weight: ${this._terminal.options.fontWeight};` + + `}` + + `${this._terminalSelector} span.${BOLD_CLASS} {` + + ` font-weight: ${this._terminal.options.fontWeightBold};` + + `}` + + `${this._terminalSelector} span.${ITALIC_CLASS} {` + + ` font-style: italic;` + + `}`; + // Cursor + styles += + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS} {` + + ` background-color: ${this.colorManager.colors.cursor.css};` + + ` color: ${this.colorManager.colors.cursorAccent.css};` + + `}` + + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS} {` + + ` outline: 1px solid #fff;` + + ` outline-offset: -1px;` + + `}`; + // Selection + styles += + `${this._terminalSelector} .${SELECTION_CLASS} {` + + ` position: absolute;` + + ` top: 0;` + + ` left: 0;` + + ` z-index: 1;` + + ` pointer-events: none;` + + `}` + + `${this._terminalSelector} .${SELECTION_CLASS} div {` + + ` position: absolute;` + + ` background-color: ${this.colorManager.colors.selection.css};` + + `}`; + // Colors + this.colorManager.colors.ansi.forEach((c, i) => { + styles += + `${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` + + `${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`; + }); + + this._themeStyleElement.innerHTML = styles; + return this.colorManager.colors; + } + + public onWindowResize(devicePixelRatio: number): void { + this._updateDimensions(); + } + + private _refreshRowElements(cols: number, rows: number): void { + // Add missing elements + for (let i = this._rowElements.length; i <= rows; i++) { + const row = document.createElement('div'); + this._rowContainer.appendChild(row); + this._rowElements.push(row); + } + // Remove excess elements + while (this._rowElements.length > rows) { + this._rowContainer.removeChild(this._rowElements.pop()); + } + } + + public onResize(cols: number, rows: number): void { + this._refreshRowElements(cols, rows); + this._updateDimensions(); + } + + public onCharSizeChanged(): void { + this._updateDimensions(); + } + + public onBlur(): void { + this._rowContainer.classList.remove(FOCUS_CLASS); + } + + public onFocus(): void { + this._rowContainer.classList.add(FOCUS_CLASS); + } + + public onSelectionChanged(start: [number, number], end: [number, number]): void { + // Remove all selections + while (this._selectionContainer.children.length) { + this._selectionContainer.removeChild(this._selectionContainer.children[0]); + } + + // Selection does not exist + if (!start || !end) { + return; + } + + // Translate from buffer position to viewport position + const viewportStartRow = start[1] - this._terminal.buffer.ydisp; + const viewportEndRow = end[1] - this._terminal.buffer.ydisp; + const viewportCappedStartRow = Math.max(viewportStartRow, 0); + const viewportCappedEndRow = Math.min(viewportEndRow, this._terminal.rows - 1); + + // No need to draw the selection + if (viewportCappedStartRow >= this._terminal.rows || viewportCappedEndRow < 0) { + return; + } + + // Create the selections + const documentFragment = document.createDocumentFragment(); + // Draw first row + const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; + const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols; + documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol)); + // Draw middle rows + const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1; + documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._terminal.cols, middleRowsCount)); + // Draw final row + if (viewportCappedStartRow !== viewportCappedEndRow) { + // Only draw viewportEndRow if it's not the same as viewporttartRow + const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._terminal.cols; + documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol)); + } + this._selectionContainer.appendChild(documentFragment); + } + + /** + * Creates a selection element at the specified position. + * @param row The row of the selection. + * @param colStart The start column. + * @param colEnd The end columns. + */ + private _createSelectionElement(row: number, colStart: number, colEnd: number, rowCount: number = 1): HTMLElement { + const element = document.createElement('div'); + element.style.height = `${rowCount * this._terminal.charMeasure.height}px`; + element.style.top = `${row * this._terminal.charMeasure.height}px`; + element.style.left = `${colStart * this._terminal.charMeasure.width}px`; + element.style.width = `${this._terminal.charMeasure.width * (colEnd - colStart)}px`; + return element; + } + + public onCursorMove(): void { + // No-op, the cursor is drawn when rows are drawn + } + + public onOptionsChanged(): void { + // Force a refresh + this._updateDimensions(); + this.setTheme(undefined); + this._terminal.refresh(0, this._terminal.rows - 1); + } + + public clear(): void { + this._rowElements.forEach(e => e.innerHTML = ''); + } + + public refreshRows(start: number, end: number): void { + this._renderDebouncer.refresh(start, end); + } + + private _renderRows(start: number, end: number): void { + const terminal = this._terminal; + + const cursorAbsoluteY = terminal.buffer.ybase + terminal.buffer.y; + const cursorX = this._terminal.buffer.x; + + for (let y = start; y <= end; y++) { + const rowElement = this._rowElements[y]; + rowElement.innerHTML = ''; + + const row = y + terminal.buffer.ydisp; + const lineData = terminal.buffer.lines.get(row); + rowElement.appendChild(this._rowFactory.createRow(lineData, row === cursorAbsoluteY, cursorX, terminal.charMeasure.width)); + } + + this._terminal.emit('refresh', {start, end}); + } + + private get _terminalSelector(): string { + return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`; + } +} diff --git a/src/renderer/dom/DomRendererRowFactory.test.ts b/src/renderer/dom/DomRendererRowFactory.test.ts new file mode 100644 index 00000000..f28830ce --- /dev/null +++ b/src/renderer/dom/DomRendererRowFactory.test.ts @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { DomRendererRowFactory } from './DomRendererRowFactory'; +import { LineData } from '../../Types'; +import { DEFAULT_ATTR } from '../../Buffer'; +import { FLAGS } from '../Types'; + +describe('DomRendererRowFactory', () => { + let dom: jsdom.JSDOM; + let rowFactory: DomRendererRowFactory; + let lineData: LineData; + + beforeEach(() => { + dom = new jsdom.JSDOM(''); + rowFactory = new DomRendererRowFactory(dom.window.document); + lineData = createEmptyLineData(2); + }); + + describe('createRow', () => { + it('should create an element for every character in the row', () => { + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + ' ' + + ' ' + ); + }); + + it('should set correct attributes for double width characters', () => { + lineData[0] = [DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)]; + // There should be no element for the following "empty" cell + lineData[1] = [DEFAULT_ATTR, '', 0, undefined]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + '' + ); + }); + + it('should add class for cursor', () => { + const fragment = rowFactory.createRow(lineData, true, 0, 5); + assert.equal(getFragmentHtml(fragment), + ' ' + + ' ' + ); + }); + + describe('attributes', () => { + it('should add class for bold', () => { + lineData[0] = [DEFAULT_ATTR | (FLAGS.BOLD << 18), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should add class for italic', () => { + lineData[0] = [DEFAULT_ATTR | (FLAGS.ITALIC << 18), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should add classes for 256 foreground colors', () => { + const defaultAttrNoFgColor = (0 << 9) | (256 << 0); + for (let i = 0; i < 256; i++) { + lineData[0] = [defaultAttrNoFgColor | (i << 9), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + `a` + + ' ' + ); + } + }); + + it('should add classes for 256 background colors', () => { + const defaultAttrNoBgColor = (257 << 9) | (0 << 0); + for (let i = 0; i < 256; i++) { + lineData[0] = [defaultAttrNoBgColor | (i << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + `a` + + ' ' + ); + } + }); + + it('should correctly invert colors', () => { + lineData[0] = [(FLAGS.INVERSE << 18) | (2 << 9) | (1 << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should correctly invert default fg color', () => { + lineData[0] = [(FLAGS.INVERSE << 18) | (257 << 9) | (1 << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should correctly invert default bg color', () => { + lineData[0] = [(FLAGS.INVERSE << 18) | (1 << 9) | (256 << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + 'a' + + ' ' + ); + }); + + it('should turn bold fg text bright', () => { + for (let i = 0; i < 8; i++) { + lineData[0] = [(FLAGS.BOLD << 18) | (i << 9) | (256 << 0), 'a', 1, 'a'.charCodeAt(0)]; + const fragment = rowFactory.createRow(lineData, false, 0, 5); + assert.equal(getFragmentHtml(fragment), + `a` + + ' ' + ); + } + }); + }); + }); + + function getFragmentHtml(fragment: DocumentFragment): string { + const element = dom.window.document.createElement('div'); + element.appendChild(fragment); + return element.innerHTML; + } + + function createEmptyLineData(cols: number): LineData { + const lineData: LineData = []; + for (let i = 0; i < cols; i++) { + lineData.push([DEFAULT_ATTR, ' ', 1, 32 /* ' '.charCodeAt(0) */]); + } + return lineData; + } +}); diff --git a/src/renderer/dom/DomRendererRowFactory.ts b/src/renderer/dom/DomRendererRowFactory.ts new file mode 100644 index 00000000..d72629a3 --- /dev/null +++ b/src/renderer/dom/DomRendererRowFactory.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { LineData } from '../../Types'; +import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_ATTR_INDEX, CHAR_DATA_WIDTH_INDEX } from '../../Buffer'; +import { FLAGS } from '../Types'; + +export const BOLD_CLASS = 'xterm-bold'; +export const ITALIC_CLASS = 'xterm-italic'; +export const CURSOR_CLASS = 'xterm-cursor'; + +export class DomRendererRowFactory { + constructor( + private _document: Document + ) { + } + + public createRow(lineData: LineData, isCursorRow: boolean, cursorX: number, cellWidth: number): DocumentFragment { + const fragment = this._document.createDocumentFragment(); + for (let x = 0; x < lineData.length; x++) { + const charData = lineData[x]; + const char: string = charData[CHAR_DATA_CHAR_INDEX]; + const attr: number = charData[CHAR_DATA_ATTR_INDEX]; + const width: number = charData[CHAR_DATA_WIDTH_INDEX]; + + // The character to the left is a wide character, drawing is owned by the char at x-1 + if (width === 0) { + continue; + } + + const charElement = this._document.createElement('span'); + if (width > 1) { + charElement.style.width = `${cellWidth * width}px`; + } + + const flags = attr >> 18; + let bg = attr & 0x1ff; + let fg = (attr >> 9) & 0x1ff; + + if (isCursorRow && x === cursorX) { + charElement.classList.add(CURSOR_CLASS); + } + + // If inverse flag is on, the foreground should become the background. + if (flags & FLAGS.INVERSE) { + const temp = bg; + bg = fg; + fg = temp; + if (fg === 256) { + fg = 0; + } + if (bg === 257) { + bg = 15; + } + } + + if (flags & FLAGS.BOLD) { + // Convert the FG color to the bold variant + if (fg < 8) { + fg += 8; + } + charElement.classList.add(BOLD_CLASS); + } + + if (flags & FLAGS.ITALIC) { + charElement.classList.add(ITALIC_CLASS); + } + + charElement.textContent = char; + if (fg !== 257) { + charElement.classList.add(`xterm-fg-${fg}`); + } + if (bg !== 256) { + charElement.classList.add(`xterm-bg-${bg}`); + } + fragment.appendChild(charElement); + } + return fragment; + } +} diff --git a/src/shared/atlas/CharAtlasGenerator.ts b/src/shared/atlas/CharAtlasGenerator.ts index e0cfbcf7..f78a6d41 100644 --- a/src/shared/atlas/CharAtlasGenerator.ts +++ b/src/shared/atlas/CharAtlasGenerator.ts @@ -106,7 +106,7 @@ export function generateStaticCharAtlasTexture(context: Window, canvasFactory: ( return canvas; } // Transfer to an ImageBitmap is this is an OffscreenCanvas - return new Promise(r => r(canvas.transferToImageBitmap())); + return new Promise((r: (bitmap: ImageBitmap) => void) => r(canvas.transferToImageBitmap())); } const charAtlasImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); diff --git a/src/utils/Clone.test.ts b/src/utils/Clone.test.ts index f5708a9e..b24452c8 100644 --- a/src/utils/Clone.test.ts +++ b/src/utils/Clone.test.ts @@ -39,7 +39,7 @@ describe('clone', () => { }); it('should clone null values', () => { - const test = { + const test: any = { a: null }; diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index e3ae89d3..f4bad717 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -107,7 +107,6 @@ export class MockTerminal implements ITerminal { children: HTMLElement[]; cursorHidden: boolean; cursorState: number; - defAttr: number; scrollback: number; buffers: IBufferSet; buffer: IBuffer; @@ -182,7 +181,6 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { insertMode: boolean; wraparoundMode: boolean; bracketedPasteMode: boolean; - defAttr: number; curAttr: number; savedCols: number; x10Mouse: boolean; @@ -267,7 +265,7 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { throw new Error('Method not implemented.'); } setOption(key: string, value: any): void { - this.options[key] = value; + (this.options)[key] = value; } on(type: string, listener: XtermListener): void { throw new Error('Method not implemented.'); @@ -302,6 +300,9 @@ export class MockBuffer implements IBuffer { translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string { return Buffer.prototype.translateBufferLineToString.apply(this, arguments); } + getWrappedRangeForLine(y: number): { first: number; last: number; } { + throw new Error('Method not implemented.'); + } nextStop(x?: number): number { throw new Error('Method not implemented.'); } diff --git a/tsconfig.json b/tsconfig.json index 2e7404e2..c33793f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "outDir": "lib", "sourceMap": true, "removeComments": true, - "noUnusedLocals": true + "noUnusedLocals": true, + "noImplicitAny": true }, "include": [ "src/**/*", diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index a8724922..bda984b7 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -13,6 +13,11 @@ declare module 'xterm' { */ export type FontWeight = 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; + /** + * A string representing a renderer type. + */ + export type RendererType = 'dom' | 'canvas'; + /** * An object containing start up options for the terminal. */ @@ -119,6 +124,23 @@ declare module 'xterm' { */ macOptionIsMeta?: boolean; + /** + * (EXPERIMENTAL) The type of renderer to use, this allows using the + * fallback DOM renderer when canvas is too slow for the environment. The + * following features do not work when the DOM renderer is used: + * + * - Links + * - Line height + * - Letter spacing + * - Cursor blink + * - Cursor style + * + * This option is marked as experiemental because it will eventually be + * moved to an addon. You can only set this option in the constructor (not + * setOption). + */ + rendererType?: RendererType; + /** * Whether to select the word under the cursor on right click, this is * standard behavior in a lot of macOS applications. @@ -530,7 +552,7 @@ declare module 'xterm' { * Retrieves an option's value from the terminal. * @param key The option key. */ - getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold'| 'termName'): string; + getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold'| 'rendererType' | 'termName'): string; /** * Retrieves an option's value from the terminal. * @param key The option key.