diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4b3a9f2..e7924027 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,8 @@ opening an issue, read these pointers. ## Contributing code -- Make sure you have a [GitHub account](https://github.com/join) +You can find issues to work on by looking at the [help wanted](https://github.com/xtermjs/xterm.js/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) or [good first issue](https://github.com/xtermjs/xterm.js/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) issues. It's a good idea to comment on the issue saying that you're taking it, just in case someone else comes along and you duplicate work. Once you have your issue, here are the steps to contribute: + - Fork [xterm.js](https://github.com/sourcelair/xterm.js/) ([how to fork a repo](https://help.github.com/articles/fork-a-repo)) - Get the [xterm.js demo](https://github.com/xtermjs/xterm.js/wiki/Contributing#running-the-demo) running diff --git a/Dockerfile b/Dockerfile index 1e3a262c..1c72e679 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,4 @@ RUN npm install COPY . /usr/src/app # Run the tests and build, to make sure everything is working nicely -RUN npm run build && npm run webpack && npm run test +RUN npm run build && npm run test diff --git a/README.md b/README.md index 6b9b877c..551a94f4 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ computational environment for Jupyter, supporting interactive data science and s - [**rtty**](https://github.com/zhaojh329/rtty): A reverse proxy WebTTY. It is composed of the client and the server. - [**Pisth**](https://github.com/ColdGrub1384/Pisth): An SFTP and SSH client for iOS - [**abstruse**](https://github.com/bleenco/abstruse): Abstruse CI is a continuous integration platform based on Node.JS and Docker. -- [**Microsoft SQL Operations Studio**](https://github.com/Microsoft/sqlopsstudio): A data management tool that enables working with SQL Server, Azure SQL DB and SQL DW from Windows, macOS and Linux +- [**Azure Data Studio**](https://github.com/Microsoft/azuredatastudio): A data management tool that enables working with SQL Server, Azure SQL DB and SQL DW from Windows, macOS and Linux. - [**FreeMAN**](https://github.com/matthew-matvei/freeman): A free, cross-platform file manager for power users - [**Fluent Terminal**](https://github.com/felixse/FluentTerminal): A terminal emulator based on UWP and web technologies. - [**Hyper**](https://hyper.is): A terminal built on web technologies @@ -171,6 +171,7 @@ computational environment for Jupyter, supporting interactive data science and s - [**SSH Web Client**](https://github.com/roke22/PHP-SSH2-Web-Client): SSH Web Client with PHP. - [**Shellvault**](https://www.shellvault.io): The cloud-based SSH terminal you can access from anywhere. - [**Juno**](http://junolab.org/): A flexible Julia IDE, based on Atom. +- [**webssh**](https://github.com/huashengdun/webssh): Web based ssh client. [And much more...](https://github.com/xtermjs/xterm.js/network/dependents) diff --git a/demo/server.js b/demo/server.js index 0aa9deef..ebe2f644 100644 --- a/demo/server.js +++ b/demo/server.js @@ -58,9 +58,25 @@ app.ws('/terminals/:pid', function (ws, req) { console.log('Connected to terminal ' + term.pid); ws.send(logs[term.pid]); + function buffer(socket, timeout) { + let s = ''; + let sender = null; + return (data) => { + s += data; + if (!sender) { + sender = setTimeout(() => { + socket.send(s); + s = ''; + sender = null; + }, timeout); + } + }; + } + const send = buffer(ws, 5); + term.on('data', function(data) { try { - ws.send(data); + send(data); } catch (ex) { // The WebSocket is not open, ignore } diff --git a/package.json b/package.json index b10b6b54..ad763f0b 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "build": "gulp build", "prepublish": "npm run build", "coveralls": "nyc report --reporter=text-lcov | coveralls", - "webpack": "gulp webpack", "watch": "concurrently --kill-others-on-fail --names \"lib,css\" \"tsc -w\" \"gulp watch-css\"", "watch-addons": "concurrently --kill-others-on-fail --names \"attach,fit,fullscreen,search,terminado,webLinks,winptyCompat,zmodem\" \"tsc -w -p ./src/addons/attach\" \"tsc -w -p ./src/addons/fit\" \"tsc -w -p ./src/addons/fullscreen\" \"tsc -w -p ./src/addons/search\" \"tsc -w -p ./src/addons/terminado\" \"tsc -w -p ./src/addons/webLinks\" \"tsc -w -p ./src/addons/winptyCompat\" \"tsc -w -p ./src/addons/zmodem\"", "layering": "concurrently --kill-others-on-fail --names \"common,core\" \"tsc -p ./src/common\" \"tsc -p ./src/core\"" diff --git a/src/BufferLine.test.ts b/src/BufferLine.test.ts index 2db69176..b824cc38 100644 --- a/src/BufferLine.test.ts +++ b/src/BufferLine.test.ts @@ -133,31 +133,71 @@ describe('BufferLine', function(): void { const line3 = line.clone(); chai.expect(TestBufferLine.prototype.toArray.apply(line3)).eql(line.toArray()); }); - it('resize enlarge', function(): void { - const line = new TestBufferLine(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)]); - chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('resize shrink(true)', function(): void { - const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], true); - chai.expect(line.toArray()).eql(Array(5).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('resize shrink(false)', function(): void { - const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); - chai.expect(line.toArray()).eql(Array(5).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('resize shrink(false) + shrink(false)', function(): void { - const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); - chai.expect(line.toArray()).eql(Array(5).fill([1, 'a', 0, 'a'.charCodeAt(0)])); - }); - it('resize shrink(false) + enlarge', function(): void { - const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); - line.resize(15, [1, 'a', 0, 'a'.charCodeAt(0)]); - chai.expect(line.toArray()).eql(Array(15).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + describe('resize', function(): void { + it('enlarge(false)', function(): void { + const line = new TestBufferLine(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)]); + chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('enlarge(true)', function(): void { + const line = new TestBufferLine(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], true); + chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('shrink(true) - should apply new size', function(): void { + const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], true); + chai.expect(line.toArray()).eql(Array(5).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('shrink(false) - should not apply new size', function(): void { + const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); + chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('shrink(false) + shrink(false) - should not apply new size', function(): void { + const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); + chai.expect(line.toArray()).eql(Array(20).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('shrink(false) + enlarge(false) to smaller than before', function(): void { + const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(15, [1, 'a', 0, 'a'.charCodeAt(0)]); + chai.expect(line.toArray()).eql(Array(20).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('shrink(false) + enlarge(false) to bigger than before', function(): void { + const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(25, [1, 'a', 0, 'a'.charCodeAt(0)]); + chai.expect(line.toArray()).eql(Array(25).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('shrink(false) + resize shrink=true should enforce shrinking', function(): void { + const line = new TestBufferLine(20, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], true); + chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('enlarge from 0 length', function(): void { + const line = new TestBufferLine(0, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('shrink to 0 length', function(): void { + const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(0, [1, 'a', 0, 'a'.charCodeAt(0)], true); + chai.expect(line.toArray()).eql(Array(0).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); + it('shrink(false) to 0 and enlarge to different sizes', function(): void { + const line = new TestBufferLine(10, [1, 'a', 0, 'a'.charCodeAt(0)], false); + line.resize(0, [1, 'a', 0, 'a'.charCodeAt(0)], false); + chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + line.resize(5, [1, 'a', 0, 'a'.charCodeAt(0)], false); + chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + line.resize(7, [1, 'a', 0, 'a'.charCodeAt(0)], false); + chai.expect(line.toArray()).eql(Array(10).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + line.resize(7, [1, 'a', 0, 'a'.charCodeAt(0)], true); + chai.expect(line.toArray()).eql(Array(7).fill([1, 'a', 0, 'a'.charCodeAt(0)])); + }); }); }); diff --git a/src/BufferLine.ts b/src/BufferLine.ts index 53f5d4d4..7c697334 100644 --- a/src/BufferLine.ts +++ b/src/BufferLine.ts @@ -77,15 +77,15 @@ export class BufferLine implements IBufferLine { /** resize line to cols filling new cells with fill */ public resize(cols: number, fillCharData: CharData, shrink: boolean = false): void { + while (this._data.length < cols) { + this._data.push(fillCharData); + } if (shrink) { while (this._data.length > cols) { this._data.pop(); } } - while (this._data.length < cols) { - this._data.push(fillCharData); - } - this.length = cols; + this.length = this._data.length; } public fill(fillCharData: CharData): void { @@ -94,11 +94,8 @@ export class BufferLine implements IBufferLine { } } - public copyFrom(line: IBufferLine): void { - this._data = []; - for (let i = 0; i < line.length; ++i) { - this._push(line.get(i)); - } + public copyFrom(line: BufferLine): void { + this._data = line._data.slice(0); this.length = line.length; this.isWrapped = line.isWrapped; } @@ -141,11 +138,13 @@ export class BufferLineTypedArray implements IBufferLine { if (!fillCharData) { fillCharData = [0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]; } - this._data = new Uint32Array(cols * CELL_SIZE); - for (let i = 0; i < cols; ++i) { - this.set(i, fillCharData); + if (cols) { + this._data = new Uint32Array(cols * CELL_SIZE); + for (let i = 0; i < cols; ++i) { + this.set(i, fillCharData); + } } - this.length = cols || 0; + this.length = cols; } public get(index: number): CharData { @@ -212,12 +211,12 @@ export class BufferLineTypedArray implements IBufferLine { } public resize(cols: number, fillCharData: CharData, shrink: boolean = false): void { - if (cols === this.length) { + if (cols === this.length || (!shrink && cols < this.length)) { return; } if (cols > this.length) { const data = new Uint32Array(cols * CELL_SIZE); - if (this._data) { + if (this.length) { if (cols * CELL_SIZE < this._data.length) { data.set(this._data.subarray(0, cols * CELL_SIZE)); } else { diff --git a/src/CharWidth.test.ts b/src/CharWidth.test.ts index 3242dd77..d4ddd24c 100644 --- a/src/CharWidth.test.ts +++ b/src/CharWidth.test.ts @@ -5,7 +5,7 @@ import { TestTerminal } from './utils/TestUtils.test'; import { assert } from 'chai'; -import { getStringCellWidth } from './CharWidth'; +import { getStringCellWidth, wcwidth } from './CharWidth'; import { IBuffer } from './Types'; import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from './Buffer'; @@ -77,3 +77,178 @@ describe('getStringCellWidth', function(): void { }); // TODO: multiline tests once #1685 is resolved }); + +it('wcwidth should match all values from the old implementation', function(): void { + // old implementation + const wcwidthOld = (function(opts: {nul: number, control: number}): (ucs: number) => number { + // extracted from https://www.cl.cam.ac.uk/%7Emgk25/ucs/wcwidth.c + // combining characters + const COMBINING_BMP = [ + [0x0300, 0x036F], [0x0483, 0x0486], [0x0488, 0x0489], + [0x0591, 0x05BD], [0x05BF, 0x05BF], [0x05C1, 0x05C2], + [0x05C4, 0x05C5], [0x05C7, 0x05C7], [0x0600, 0x0603], + [0x0610, 0x0615], [0x064B, 0x065E], [0x0670, 0x0670], + [0x06D6, 0x06E4], [0x06E7, 0x06E8], [0x06EA, 0x06ED], + [0x070F, 0x070F], [0x0711, 0x0711], [0x0730, 0x074A], + [0x07A6, 0x07B0], [0x07EB, 0x07F3], [0x0901, 0x0902], + [0x093C, 0x093C], [0x0941, 0x0948], [0x094D, 0x094D], + [0x0951, 0x0954], [0x0962, 0x0963], [0x0981, 0x0981], + [0x09BC, 0x09BC], [0x09C1, 0x09C4], [0x09CD, 0x09CD], + [0x09E2, 0x09E3], [0x0A01, 0x0A02], [0x0A3C, 0x0A3C], + [0x0A41, 0x0A42], [0x0A47, 0x0A48], [0x0A4B, 0x0A4D], + [0x0A70, 0x0A71], [0x0A81, 0x0A82], [0x0ABC, 0x0ABC], + [0x0AC1, 0x0AC5], [0x0AC7, 0x0AC8], [0x0ACD, 0x0ACD], + [0x0AE2, 0x0AE3], [0x0B01, 0x0B01], [0x0B3C, 0x0B3C], + [0x0B3F, 0x0B3F], [0x0B41, 0x0B43], [0x0B4D, 0x0B4D], + [0x0B56, 0x0B56], [0x0B82, 0x0B82], [0x0BC0, 0x0BC0], + [0x0BCD, 0x0BCD], [0x0C3E, 0x0C40], [0x0C46, 0x0C48], + [0x0C4A, 0x0C4D], [0x0C55, 0x0C56], [0x0CBC, 0x0CBC], + [0x0CBF, 0x0CBF], [0x0CC6, 0x0CC6], [0x0CCC, 0x0CCD], + [0x0CE2, 0x0CE3], [0x0D41, 0x0D43], [0x0D4D, 0x0D4D], + [0x0DCA, 0x0DCA], [0x0DD2, 0x0DD4], [0x0DD6, 0x0DD6], + [0x0E31, 0x0E31], [0x0E34, 0x0E3A], [0x0E47, 0x0E4E], + [0x0EB1, 0x0EB1], [0x0EB4, 0x0EB9], [0x0EBB, 0x0EBC], + [0x0EC8, 0x0ECD], [0x0F18, 0x0F19], [0x0F35, 0x0F35], + [0x0F37, 0x0F37], [0x0F39, 0x0F39], [0x0F71, 0x0F7E], + [0x0F80, 0x0F84], [0x0F86, 0x0F87], [0x0F90, 0x0F97], + [0x0F99, 0x0FBC], [0x0FC6, 0x0FC6], [0x102D, 0x1030], + [0x1032, 0x1032], [0x1036, 0x1037], [0x1039, 0x1039], + [0x1058, 0x1059], [0x1160, 0x11FF], [0x135F, 0x135F], + [0x1712, 0x1714], [0x1732, 0x1734], [0x1752, 0x1753], + [0x1772, 0x1773], [0x17B4, 0x17B5], [0x17B7, 0x17BD], + [0x17C6, 0x17C6], [0x17C9, 0x17D3], [0x17DD, 0x17DD], + [0x180B, 0x180D], [0x18A9, 0x18A9], [0x1920, 0x1922], + [0x1927, 0x1928], [0x1932, 0x1932], [0x1939, 0x193B], + [0x1A17, 0x1A18], [0x1B00, 0x1B03], [0x1B34, 0x1B34], + [0x1B36, 0x1B3A], [0x1B3C, 0x1B3C], [0x1B42, 0x1B42], + [0x1B6B, 0x1B73], [0x1DC0, 0x1DCA], [0x1DFE, 0x1DFF], + [0x200B, 0x200F], [0x202A, 0x202E], [0x2060, 0x2063], + [0x206A, 0x206F], [0x20D0, 0x20EF], [0x302A, 0x302F], + [0x3099, 0x309A], [0xA806, 0xA806], [0xA80B, 0xA80B], + [0xA825, 0xA826], [0xFB1E, 0xFB1E], [0xFE00, 0xFE0F], + [0xFE20, 0xFE23], [0xFEFF, 0xFEFF], [0xFFF9, 0xFFFB] + ]; + const COMBINING_HIGH = [ + [0x10A01, 0x10A03], [0x10A05, 0x10A06], [0x10A0C, 0x10A0F], + [0x10A38, 0x10A3A], [0x10A3F, 0x10A3F], [0x1D167, 0x1D169], + [0x1D173, 0x1D182], [0x1D185, 0x1D18B], [0x1D1AA, 0x1D1AD], + [0x1D242, 0x1D244], [0xE0001, 0xE0001], [0xE0020, 0xE007F], + [0xE0100, 0xE01EF] + ]; + // binary search + function bisearch(ucs: number, data: number[][]): boolean { + let min = 0; + let max = data.length - 1; + let mid; + if (ucs < data[0][0] || ucs > data[max][1]) { + return false; + } + while (max >= min) { + mid = (min + max) >> 1; + if (ucs > data[mid][1]) { + min = mid + 1; + } else if (ucs < data[mid][0]) { + max = mid - 1; + } else { + return true; + } + } + return false; + } + function wcwidthBMP(ucs: number): number { + // test for 8-bit control characters + if (ucs === 0) { + return opts.nul; + } + if (ucs < 32 || (ucs >= 0x7f && ucs < 0xa0)) { + return opts.control; + } + // binary search in table of non-spacing characters + if (bisearch(ucs, COMBINING_BMP)) { + return 0; + } + // if we arrive here, ucs is not a combining or C0/C1 control character + if (isWideBMP(ucs)) { + return 2; + } + return 1; + } + function isWideBMP(ucs: number): boolean { + return ( + ucs >= 0x1100 && ( + ucs <= 0x115f || // Hangul Jamo init. consonants + ucs === 0x2329 || + ucs === 0x232a || + (ucs >= 0x2e80 && ucs <= 0xa4cf && ucs !== 0x303f) || // CJK..Yi + (ucs >= 0xac00 && ucs <= 0xd7a3) || // Hangul Syllables + (ucs >= 0xf900 && ucs <= 0xfaff) || // CJK Compat Ideographs + (ucs >= 0xfe10 && ucs <= 0xfe19) || // Vertical forms + (ucs >= 0xfe30 && ucs <= 0xfe6f) || // CJK Compat Forms + (ucs >= 0xff00 && ucs <= 0xff60) || // Fullwidth Forms + (ucs >= 0xffe0 && ucs <= 0xffe6))); + } + function wcwidthHigh(ucs: number): 0 | 1 | 2 { + if (bisearch(ucs, COMBINING_HIGH)) { + return 0; + } + if ((ucs >= 0x20000 && ucs <= 0x2fffd) || (ucs >= 0x30000 && ucs <= 0x3fffd)) { + return 2; + } + return 1; + } + const control = opts.control | 0; + let table: number[] | Uint32Array = null; + function initTable(): number[] | Uint32Array { + // lookup table for BMP + const CODEPOINTS = 65536; // BMP holds 65536 codepoints + const BITWIDTH = 2; // a codepoint can have a width of 0, 1 or 2 + const ITEMSIZE = 32; // using uint32_t + const CONTAINERSIZE = CODEPOINTS * BITWIDTH / ITEMSIZE; + const CODEPOINTS_PER_ITEM = ITEMSIZE / BITWIDTH; + table = (typeof Uint32Array === 'undefined') + ? new Array(CONTAINERSIZE) + : new Uint32Array(CONTAINERSIZE); + for (let i = 0; i < CONTAINERSIZE; ++i) { + let num = 0; + let pos = CODEPOINTS_PER_ITEM; + while (pos--) { + num = (num << 2) | wcwidthBMP(CODEPOINTS_PER_ITEM * i + pos); + } + table[i] = num; + } + return table; + } + // get width from lookup table + // position in container : num / CODEPOINTS_PER_ITEM + // ==> n = table[Math.floor(num / 16)] + // ==> n = table[num >> 4] + // 16 codepoints per number: FFEEDDCCBBAA99887766554433221100 + // position in number : (num % CODEPOINTS_PER_ITEM) * BITWIDTH + // ==> m = (n % 16) * 2 + // ==> m = (num & 15) << 1 + // right shift to position m + // ==> n = n >> m e.g. m=12 000000000000FFEEDDCCBBAA99887766 + // we are only interested in 2 LSBs, cut off higher bits + // ==> n = n & 3 e.g. 000000000000000000000000000000XX + return function (num: number): number { + num = num | 0; // get asm.js like optimization under V8 + if (num < 32) { + return control | 0; + } + if (num < 127) { + return 1; + } + const t = table || initTable(); + if (num < 65536) { + return t[num >> 4] >> ((num & 15) << 1) & 3; + } + // do a full search for high codepoints + return wcwidthHigh(num); + }; + })({nul: 0, control: 0}); // configurable options + + // test full BMP range old vs new implmenetation + for (let i = 0; i < 65536; ++i) { + assert.equal(wcwidth(i), wcwidthOld(i), `mismatch for i: ${i}`); + } +}); diff --git a/src/CharWidth.ts b/src/CharWidth.ts index d54e9392..fd6ac55f 100644 --- a/src/CharWidth.ts +++ b/src/CharWidth.ts @@ -3,171 +3,139 @@ * @license MIT */ +import { fill } from './core/TypedArrayUtils'; + export const wcwidth = (function(opts: {nul: number, control: number}): (ucs: number) => number { - // extracted from https://www.cl.cam.ac.uk/%7Emgk25/ucs/wcwidth.c - // combining characters - const COMBINING_BMP = [ - [0x0300, 0x036F], [0x0483, 0x0486], [0x0488, 0x0489], - [0x0591, 0x05BD], [0x05BF, 0x05BF], [0x05C1, 0x05C2], - [0x05C4, 0x05C5], [0x05C7, 0x05C7], [0x0600, 0x0603], - [0x0610, 0x0615], [0x064B, 0x065E], [0x0670, 0x0670], - [0x06D6, 0x06E4], [0x06E7, 0x06E8], [0x06EA, 0x06ED], - [0x070F, 0x070F], [0x0711, 0x0711], [0x0730, 0x074A], - [0x07A6, 0x07B0], [0x07EB, 0x07F3], [0x0901, 0x0902], - [0x093C, 0x093C], [0x0941, 0x0948], [0x094D, 0x094D], - [0x0951, 0x0954], [0x0962, 0x0963], [0x0981, 0x0981], - [0x09BC, 0x09BC], [0x09C1, 0x09C4], [0x09CD, 0x09CD], - [0x09E2, 0x09E3], [0x0A01, 0x0A02], [0x0A3C, 0x0A3C], - [0x0A41, 0x0A42], [0x0A47, 0x0A48], [0x0A4B, 0x0A4D], - [0x0A70, 0x0A71], [0x0A81, 0x0A82], [0x0ABC, 0x0ABC], - [0x0AC1, 0x0AC5], [0x0AC7, 0x0AC8], [0x0ACD, 0x0ACD], - [0x0AE2, 0x0AE3], [0x0B01, 0x0B01], [0x0B3C, 0x0B3C], - [0x0B3F, 0x0B3F], [0x0B41, 0x0B43], [0x0B4D, 0x0B4D], - [0x0B56, 0x0B56], [0x0B82, 0x0B82], [0x0BC0, 0x0BC0], - [0x0BCD, 0x0BCD], [0x0C3E, 0x0C40], [0x0C46, 0x0C48], - [0x0C4A, 0x0C4D], [0x0C55, 0x0C56], [0x0CBC, 0x0CBC], - [0x0CBF, 0x0CBF], [0x0CC6, 0x0CC6], [0x0CCC, 0x0CCD], - [0x0CE2, 0x0CE3], [0x0D41, 0x0D43], [0x0D4D, 0x0D4D], - [0x0DCA, 0x0DCA], [0x0DD2, 0x0DD4], [0x0DD6, 0x0DD6], - [0x0E31, 0x0E31], [0x0E34, 0x0E3A], [0x0E47, 0x0E4E], - [0x0EB1, 0x0EB1], [0x0EB4, 0x0EB9], [0x0EBB, 0x0EBC], - [0x0EC8, 0x0ECD], [0x0F18, 0x0F19], [0x0F35, 0x0F35], - [0x0F37, 0x0F37], [0x0F39, 0x0F39], [0x0F71, 0x0F7E], - [0x0F80, 0x0F84], [0x0F86, 0x0F87], [0x0F90, 0x0F97], - [0x0F99, 0x0FBC], [0x0FC6, 0x0FC6], [0x102D, 0x1030], - [0x1032, 0x1032], [0x1036, 0x1037], [0x1039, 0x1039], - [0x1058, 0x1059], [0x1160, 0x11FF], [0x135F, 0x135F], - [0x1712, 0x1714], [0x1732, 0x1734], [0x1752, 0x1753], - [0x1772, 0x1773], [0x17B4, 0x17B5], [0x17B7, 0x17BD], - [0x17C6, 0x17C6], [0x17C9, 0x17D3], [0x17DD, 0x17DD], - [0x180B, 0x180D], [0x18A9, 0x18A9], [0x1920, 0x1922], - [0x1927, 0x1928], [0x1932, 0x1932], [0x1939, 0x193B], - [0x1A17, 0x1A18], [0x1B00, 0x1B03], [0x1B34, 0x1B34], - [0x1B36, 0x1B3A], [0x1B3C, 0x1B3C], [0x1B42, 0x1B42], - [0x1B6B, 0x1B73], [0x1DC0, 0x1DCA], [0x1DFE, 0x1DFF], - [0x200B, 0x200F], [0x202A, 0x202E], [0x2060, 0x2063], - [0x206A, 0x206F], [0x20D0, 0x20EF], [0x302A, 0x302F], - [0x3099, 0x309A], [0xA806, 0xA806], [0xA80B, 0xA80B], - [0xA825, 0xA826], [0xFB1E, 0xFB1E], [0xFE00, 0xFE0F], - [0xFE20, 0xFE23], [0xFEFF, 0xFEFF], [0xFFF9, 0xFFFB] - ]; - const COMBINING_HIGH = [ - [0x10A01, 0x10A03], [0x10A05, 0x10A06], [0x10A0C, 0x10A0F], - [0x10A38, 0x10A3A], [0x10A3F, 0x10A3F], [0x1D167, 0x1D169], - [0x1D173, 0x1D182], [0x1D185, 0x1D18B], [0x1D1AA, 0x1D1AD], - [0x1D242, 0x1D244], [0xE0001, 0xE0001], [0xE0020, 0xE007F], - [0xE0100, 0xE01EF] - ]; - // binary search - function bisearch(ucs: number, data: number[][]): boolean { - let min = 0; - let max = data.length - 1; - let mid; - if (ucs < data[0][0] || ucs > data[max][1]) { - return false; - } - while (max >= min) { - mid = (min + max) >> 1; - if (ucs > data[mid][1]) { - min = mid + 1; - } else if (ucs < data[mid][0]) { - max = mid - 1; - } else { - return true; - } - } + // extracted from https://www.cl.cam.ac.uk/%7Emgk25/ucs/wcwidth.c + // combining characters + const COMBINING_BMP = [ + [0x0300, 0x036F], [0x0483, 0x0486], [0x0488, 0x0489], + [0x0591, 0x05BD], [0x05BF, 0x05BF], [0x05C1, 0x05C2], + [0x05C4, 0x05C5], [0x05C7, 0x05C7], [0x0600, 0x0603], + [0x0610, 0x0615], [0x064B, 0x065E], [0x0670, 0x0670], + [0x06D6, 0x06E4], [0x06E7, 0x06E8], [0x06EA, 0x06ED], + [0x070F, 0x070F], [0x0711, 0x0711], [0x0730, 0x074A], + [0x07A6, 0x07B0], [0x07EB, 0x07F3], [0x0901, 0x0902], + [0x093C, 0x093C], [0x0941, 0x0948], [0x094D, 0x094D], + [0x0951, 0x0954], [0x0962, 0x0963], [0x0981, 0x0981], + [0x09BC, 0x09BC], [0x09C1, 0x09C4], [0x09CD, 0x09CD], + [0x09E2, 0x09E3], [0x0A01, 0x0A02], [0x0A3C, 0x0A3C], + [0x0A41, 0x0A42], [0x0A47, 0x0A48], [0x0A4B, 0x0A4D], + [0x0A70, 0x0A71], [0x0A81, 0x0A82], [0x0ABC, 0x0ABC], + [0x0AC1, 0x0AC5], [0x0AC7, 0x0AC8], [0x0ACD, 0x0ACD], + [0x0AE2, 0x0AE3], [0x0B01, 0x0B01], [0x0B3C, 0x0B3C], + [0x0B3F, 0x0B3F], [0x0B41, 0x0B43], [0x0B4D, 0x0B4D], + [0x0B56, 0x0B56], [0x0B82, 0x0B82], [0x0BC0, 0x0BC0], + [0x0BCD, 0x0BCD], [0x0C3E, 0x0C40], [0x0C46, 0x0C48], + [0x0C4A, 0x0C4D], [0x0C55, 0x0C56], [0x0CBC, 0x0CBC], + [0x0CBF, 0x0CBF], [0x0CC6, 0x0CC6], [0x0CCC, 0x0CCD], + [0x0CE2, 0x0CE3], [0x0D41, 0x0D43], [0x0D4D, 0x0D4D], + [0x0DCA, 0x0DCA], [0x0DD2, 0x0DD4], [0x0DD6, 0x0DD6], + [0x0E31, 0x0E31], [0x0E34, 0x0E3A], [0x0E47, 0x0E4E], + [0x0EB1, 0x0EB1], [0x0EB4, 0x0EB9], [0x0EBB, 0x0EBC], + [0x0EC8, 0x0ECD], [0x0F18, 0x0F19], [0x0F35, 0x0F35], + [0x0F37, 0x0F37], [0x0F39, 0x0F39], [0x0F71, 0x0F7E], + [0x0F80, 0x0F84], [0x0F86, 0x0F87], [0x0F90, 0x0F97], + [0x0F99, 0x0FBC], [0x0FC6, 0x0FC6], [0x102D, 0x1030], + [0x1032, 0x1032], [0x1036, 0x1037], [0x1039, 0x1039], + [0x1058, 0x1059], [0x1160, 0x11FF], [0x135F, 0x135F], + [0x1712, 0x1714], [0x1732, 0x1734], [0x1752, 0x1753], + [0x1772, 0x1773], [0x17B4, 0x17B5], [0x17B7, 0x17BD], + [0x17C6, 0x17C6], [0x17C9, 0x17D3], [0x17DD, 0x17DD], + [0x180B, 0x180D], [0x18A9, 0x18A9], [0x1920, 0x1922], + [0x1927, 0x1928], [0x1932, 0x1932], [0x1939, 0x193B], + [0x1A17, 0x1A18], [0x1B00, 0x1B03], [0x1B34, 0x1B34], + [0x1B36, 0x1B3A], [0x1B3C, 0x1B3C], [0x1B42, 0x1B42], + [0x1B6B, 0x1B73], [0x1DC0, 0x1DCA], [0x1DFE, 0x1DFF], + [0x200B, 0x200F], [0x202A, 0x202E], [0x2060, 0x2063], + [0x206A, 0x206F], [0x20D0, 0x20EF], [0x302A, 0x302F], + [0x3099, 0x309A], [0xA806, 0xA806], [0xA80B, 0xA80B], + [0xA825, 0xA826], [0xFB1E, 0xFB1E], [0xFE00, 0xFE0F], + [0xFE20, 0xFE23], [0xFEFF, 0xFEFF], [0xFFF9, 0xFFFB] + ]; + const COMBINING_HIGH = [ + [0x10A01, 0x10A03], [0x10A05, 0x10A06], [0x10A0C, 0x10A0F], + [0x10A38, 0x10A3A], [0x10A3F, 0x10A3F], [0x1D167, 0x1D169], + [0x1D173, 0x1D182], [0x1D185, 0x1D18B], [0x1D1AA, 0x1D1AD], + [0x1D242, 0x1D244], [0xE0001, 0xE0001], [0xE0020, 0xE007F], + [0xE0100, 0xE01EF] + ]; + // binary search + function bisearch(ucs: number, data: number[][]): boolean { + let min = 0; + let max = data.length - 1; + let mid; + if (ucs < data[0][0] || ucs > data[max][1]) { return false; } - function wcwidthBMP(ucs: number): number { - // test for 8-bit control characters - if (ucs === 0) { - return opts.nul; - } - if (ucs < 32 || (ucs >= 0x7f && ucs < 0xa0)) { - return opts.control; - } - // binary search in table of non-spacing characters - if (bisearch(ucs, COMBINING_BMP)) { - return 0; - } - // if we arrive here, ucs is not a combining or C0/C1 control character - if (isWideBMP(ucs)) { - return 2; + while (max >= min) { + mid = (min + max) >> 1; + if (ucs > data[mid][1]) { + min = mid + 1; + } else if (ucs < data[mid][0]) { + max = mid - 1; + } else { + return true; } + } + return false; + } + function wcwidthHigh(ucs: number): 0 | 1 | 2 { + if (bisearch(ucs, COMBINING_HIGH)) { + return 0; + } + if ((ucs >= 0x20000 && ucs <= 0x2fffd) || (ucs >= 0x30000 && ucs <= 0x3fffd)) { + return 2; + } + return 1; + } + const control = opts.control | 0; + + // create lookup table for BMP plane + const table = new Uint8Array(65536); + fill(table, 1); + table[0] = opts.nul; + // control chars + fill(table, opts.control, 1, 32); + fill(table, opts.control, 0x7f, 0xa0); + + // apply wide char rules first + // wide chars + fill(table, 2, 0x1100, 0x1160); + table[0x2329] = 2; + table[0x232a] = 2; + fill(table, 2, 0x2e80, 0xa4d0); + table[0x303f] = 1; // wrongly in last line + + fill(table, 2, 0xac00, 0xd7a4); + fill(table, 2, 0xf900, 0xfb00); + fill(table, 2, 0xfe10, 0xfe1a); + fill(table, 2, 0xfe30, 0xfe70); + fill(table, 2, 0xff00, 0xff61); + fill(table, 2, 0xffe0, 0xffe7); + + // apply combining last to ensure we overwrite + // wrongly wide set chars: + // the original algo evals combining first and falls + // through to wide check so we simply do here the opposite + // combining 0 + for (let r = 0; r < COMBINING_BMP.length; ++r) { + fill(table, 0, COMBINING_BMP[r][0], COMBINING_BMP[r][1] + 1); + } + + return function (num: number): number { + if (num < 32) { + return control | 0; + } + if (num < 127) { return 1; } - function isWideBMP(ucs: number): boolean { - return ( - ucs >= 0x1100 && ( - ucs <= 0x115f || // Hangul Jamo init. consonants - ucs === 0x2329 || - ucs === 0x232a || - (ucs >= 0x2e80 && ucs <= 0xa4cf && ucs !== 0x303f) || // CJK..Yi - (ucs >= 0xac00 && ucs <= 0xd7a3) || // Hangul Syllables - (ucs >= 0xf900 && ucs <= 0xfaff) || // CJK Compat Ideographs - (ucs >= 0xfe10 && ucs <= 0xfe19) || // Vertical forms - (ucs >= 0xfe30 && ucs <= 0xfe6f) || // CJK Compat Forms - (ucs >= 0xff00 && ucs <= 0xff60) || // Fullwidth Forms - (ucs >= 0xffe0 && ucs <= 0xffe6))); + if (num < 65536) { + return table[num]; } - function wcwidthHigh(ucs: number): 0 | 1 | 2 { - if (bisearch(ucs, COMBINING_HIGH)) { - return 0; - } - if ((ucs >= 0x20000 && ucs <= 0x2fffd) || (ucs >= 0x30000 && ucs <= 0x3fffd)) { - return 2; - } - return 1; - } - const control = opts.control | 0; - let table: number[] | Uint32Array = null; - function initTable(): number[] | Uint32Array { - // lookup table for BMP - const CODEPOINTS = 65536; // BMP holds 65536 codepoints - const BITWIDTH = 2; // a codepoint can have a width of 0, 1 or 2 - const ITEMSIZE = 32; // using uint32_t - const CONTAINERSIZE = CODEPOINTS * BITWIDTH / ITEMSIZE; - const CODEPOINTS_PER_ITEM = ITEMSIZE / BITWIDTH; - table = (typeof Uint32Array === 'undefined') - ? new Array(CONTAINERSIZE) - : new Uint32Array(CONTAINERSIZE); - for (let i = 0; i < CONTAINERSIZE; ++i) { - let num = 0; - let pos = CODEPOINTS_PER_ITEM; - while (pos--) { - num = (num << 2) | wcwidthBMP(CODEPOINTS_PER_ITEM * i + pos); - } - table[i] = num; - } - return table; - } - // get width from lookup table - // position in container : num / CODEPOINTS_PER_ITEM - // ==> n = table[Math.floor(num / 16)] - // ==> n = table[num >> 4] - // 16 codepoints per number: FFEEDDCCBBAA99887766554433221100 - // position in number : (num % CODEPOINTS_PER_ITEM) * BITWIDTH - // ==> m = (n % 16) * 2 - // ==> m = (num & 15) << 1 - // right shift to position m - // ==> n = n >> m e.g. m=12 000000000000FFEEDDCCBBAA99887766 - // we are only interested in 2 LSBs, cut off higher bits - // ==> n = n & 3 e.g. 000000000000000000000000000000XX - return function (num: number): number { - num = num | 0; // get asm.js like optimization under V8 - if (num < 32) { - return control | 0; - } - if (num < 127) { - return 1; - } - const t = table || initTable(); - if (num < 65536) { - return t[num >> 4] >> ((num & 15) << 1) & 3; - } - // do a full search for high codepoints - return wcwidthHigh(num); - }; + // do a full search for high codepoints + return wcwidthHigh(num); + }; })({nul: 0, control: 0}); // configurable options /** @@ -175,17 +143,27 @@ export const wcwidth = (function(opts: {nul: number, control: number}): (ucs: nu */ export function getStringCellWidth(s: string): number { let result = 0; - for (let i = 0; i < s.length; ++i) { + const length = s.length; + for (let i = 0; i < length; ++i) { let code = s.charCodeAt(i); + // surrogate pair first if (0xD800 <= code && code <= 0xDBFF) { - const low = s.charCodeAt(i + 1); - if (isNaN(low)) { - return result; + if (++i >= length) { + // this should not happen with strings retrieved from + // Buffer.translateToString as it converts from UTF-32 + // and therefore always should contain the second part + // for any other string we still have to handle it somehow: + // simply treat the lonely surrogate first as a single char (UCS-2 behavior) + return result + wcwidth(code); + } + const second = s.charCodeAt(i); + // convert surrogate pair to high codepoint only for valid second part (UTF-16) + // otherwise treat them independently (UCS-2 behavior) + if (0xDC00 <= second && second <= 0xDFFF) { + code = (code - 0xD800) * 0x400 + second - 0xDC00 + 0x10000; + } else { + result += wcwidth(second); } - code = ((code - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000; - } - if (0xDC00 <= code && code <= 0xDFFF) { - continue; } result += wcwidth(code); } diff --git a/src/EscapeSequenceParser.test.ts b/src/EscapeSequenceParser.test.ts index 5db6d7ff..e92f4125 100644 --- a/src/EscapeSequenceParser.test.ts +++ b/src/EscapeSequenceParser.test.ts @@ -369,7 +369,8 @@ describe('EscapeSequenceParser', function (): void { parser.currentState = ParserState.ESCAPE_INTERMEDIATE; parser.parse(collect[i]); chai.expect(parser.currentState).equal(ParserState.GROUND); - testTerminal.compare([['esc', '', collect[i]]]); + // '\x5c' --> ESC + \ (7bit ST) parser does not expose this as it already got handled + testTerminal.compare((collect[i] === '\x5c') ? [] : [['esc', '', collect[i]]]); parser.reset(); testTerminal.clear(); } @@ -1051,6 +1052,13 @@ describe('EscapeSequenceParser', function (): void { ['csi', '<', [0, 0], 'c'] ], null); }); + it('7bit ST should be swallowed', function(): void { + test('abc\x9d123tzf\x1b\\defg', [ + ['print', 'abc'], + ['osc', '123tzf'], + ['print', 'defg'] + ], null); + }); }); }); @@ -1089,7 +1097,7 @@ describe('EscapeSequenceParser', function (): void { parser.reset(); testTerminal.clear(); parser.currentState = ParserState.GROUND; - parser.parse('\x1e'); + parser.parse('\x9c'); chai.expect(parser.currentState).equal(ParserState.GROUND); testTerminal.compare([]); parser.reset(); diff --git a/src/EscapeSequenceParser.ts b/src/EscapeSequenceParser.ts index bf744e05..b38c50f5 100644 --- a/src/EscapeSequenceParser.ts +++ b/src/EscapeSequenceParser.ts @@ -66,7 +66,7 @@ export class TransitionTable { const PRINTABLES = r(0x20, 0x7f); const EXECUTABLES = r(0x00, 0x18); EXECUTABLES.push(0x19); -EXECUTABLES.concat(r(0x1c, 0x20)); +EXECUTABLES.push.apply(EXECUTABLES, r(0x1c, 0x20)); const DEFAULT_TRANSITION = ParserAction.ERROR << 4 | ParserState.GROUND; /** @@ -261,6 +261,9 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._dcsHandlers = Object.create(null); this._activeDcsHandler = null; this._errorHandler = this._errorHandlerFb; + + // swallow 7bit ST (ESC+\) + this.setEscHandler('\\', () => {}); } public dispose(): void { diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index e4314330..aaaf57c3 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -438,6 +438,36 @@ describe('InputHandler', () => { termNew.buffer.x = 40; inputHandlerNew.eraseInDisplay([2]); expect(termContent(termNew)).eql(termContent(termOld)); + + // reset and add a wrapped line + termNew.buffer.y = 0; + termNew.buffer.x = 0; + inputHandlerNew.parse(Array(termNew.cols + 1).join('a')); // line 0 + inputHandlerNew.parse(Array(termNew.cols + 10).join('a')); // line 1 and 2 + for (let i = 3; i < termOld.rows; ++i) inputHandlerNew.parse(Array(termNew.cols + 1).join('a')); + + // params[1] left and above with wrap + // confirm precondition that line 2 is wrapped + expect(termNew.buffer.lines.get(2).isWrapped).true; + termNew.buffer.y = 2; + termNew.buffer.x = 40; + inputHandlerNew.eraseInDisplay([1]); + expect(termNew.buffer.lines.get(2).isWrapped).false; + + // reset and add a wrapped line + termNew.buffer.y = 0; + termNew.buffer.x = 0; + inputHandlerNew.parse(Array(termNew.cols + 1).join('a')); // line 0 + inputHandlerNew.parse(Array(termNew.cols + 10).join('a')); // line 1 and 2 + for (let i = 3; i < termOld.rows; ++i) inputHandlerNew.parse(Array(termNew.cols + 1).join('a')); + + // params[1] left and above with wrap + // confirm precondition that line 2 is wrapped + expect(termNew.buffer.lines.get(2).isWrapped).true; + termNew.buffer.y = 1; + termNew.buffer.x = 90; // Cursor is beyond last column + inputHandlerNew.eraseInDisplay([1]); + expect(termNew.buffer.lines.get(2).isWrapped).false; }); }); it('convertEol setting', function(): void { diff --git a/src/InputHandler.ts b/src/InputHandler.ts index ddb20527..b4ce054f 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -113,7 +113,7 @@ class DECRQSS implements IDcsHandler { * each function's header comment. */ export class InputHandler extends Disposable implements IInputHandler { - private _surrogateHigh: string; + private _surrogateFirst: string; constructor( protected _terminal: IInputHandlingTerminal, @@ -123,7 +123,7 @@ export class InputHandler extends Disposable implements IInputHandler { this.register(this._parser); - this._surrogateHigh = ''; + this._surrogateFirst = ''; /** * custom fallback handlers @@ -311,9 +311,9 @@ export class InputHandler extends Disposable implements IInputHandler { } // apply leftover surrogate high from last write - if (this._surrogateHigh) { - data = this._surrogateHigh + data; - this._surrogateHigh = ''; + if (this._surrogateFirst) { + data = this._surrogateFirst + data; + this._surrogateFirst = ''; } this._parser.parse(data); @@ -327,7 +327,6 @@ export class InputHandler extends Disposable implements IInputHandler { public print(data: string, start: number, end: number): void { let char: string; let code: number; - let low: number; let chWidth: number; const buffer: IBuffer = this._terminal.buffer; const charset: ICharset = this._terminal.charset; @@ -345,20 +344,25 @@ export class InputHandler extends Disposable implements IInputHandler { // surrogate pair handling if (0xD800 <= code && code <= 0xDBFF) { - // we got a surrogate high - // get surrogate low (next 2 bytes) - low = data.charCodeAt(stringPosition + 1); - if (isNaN(low)) { - // end of data stream, save surrogate high - this._surrogateHigh = char; + if (++stringPosition >= end) { + // end of input: + // handle pairs as true UTF-16 and wait for the second part + // since we expect the input comming from a stream there is + // a small chance that the surrogate pair got split + // therefore we dont process the first char here, instead + // it gets added as first char to the next processed chunk + this._surrogateFirst = char; continue; } - code = ((code - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000; - char += data.charAt(stringPosition + 1); - } - // surrogate low - already handled above - if (0xDC00 <= code && code <= 0xDFFF) { - continue; + const second = data.charCodeAt(stringPosition); + // if the second part is in surrogate pair range create the high codepoint + // otherwise fall back to UCS-2 behavior (handle codepoints independently) + if (0xDC00 <= second && second <= 0xDFFF) { + code = (code - 0xD800) * 0x400 + second - 0xDC00 + 0x10000; + char += data.charAt(stringPosition); + } else { + stringPosition--; + } } // calculate print space @@ -722,12 +726,25 @@ export class InputHandler extends Disposable implements IInputHandler { * @param start first cell index to be erased * @param end end - 1 is last erased cell */ - private _eraseInBufferLine(y: number, start: number, end: number): void { - this._terminal.buffer.lines.get(this._terminal.buffer.ybase + y).replaceCells( + private _eraseInBufferLine(y: number, start: number, end: number, clearWrap: boolean = false): void { + const line = this._terminal.buffer.lines.get(this._terminal.buffer.ybase + y); + line.replaceCells( start, end, [this._terminal.eraseAttr(), NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE] ); + if (clearWrap) { + line.isWrapped = false; + } + } + + /** + * Helper method to reset cells in a terminal row. + * The cell gets replaced with the eraseChar of the terminal and the isWrapped property is set to false. + * @param y row index + */ + private _resetBufferLine(y: number): void { + this._eraseInBufferLine(y, 0, this._terminal.cols, true); } /** @@ -748,18 +765,23 @@ export class InputHandler extends Disposable implements IInputHandler { case 0: j = this._terminal.buffer.y; this._terminal.updateRange(j); - this._eraseInBufferLine(j++, this._terminal.buffer.x, this._terminal.cols); + this._eraseInBufferLine(j++, this._terminal.buffer.x, this._terminal.cols, this._terminal.buffer.x === 0); for (; j < this._terminal.rows; j++) { - this._eraseInBufferLine(j, 0, this._terminal.cols); + this._resetBufferLine(j); } this._terminal.updateRange(j); break; case 1: j = this._terminal.buffer.y; this._terminal.updateRange(j); - this._eraseInBufferLine(j, 0, this._terminal.buffer.x + 1); + // Deleted front part of line and everything before. This line will no longer be wrapped. + this._eraseInBufferLine(j, 0, this._terminal.buffer.x + 1, true); + if (this._terminal.buffer.x + 1 >= this._terminal.cols) { + // Deleted entire previous line. This next line can no longer be wrapped. + this._terminal.buffer.lines.get(j + 1).isWrapped = false; + } while (j--) { - this._eraseInBufferLine(j, 0, this._terminal.cols); + this._resetBufferLine(j); } this._terminal.updateRange(0); break; @@ -767,7 +789,7 @@ export class InputHandler extends Disposable implements IInputHandler { j = this._terminal.rows; this._terminal.updateRange(j - 1); while (j--) { - this._eraseInBufferLine(j, 0, this._terminal.cols); + this._resetBufferLine(j); } this._terminal.updateRange(0); break; diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index c42735d5..8735e894 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -30,6 +30,7 @@ class TestSelectionManager extends SelectionManager { public selectLineAt(line: number): void { this._selectLineAt(line); } public selectWordAt(coords: [number, number]): void { this._selectWordAt(coords, true); } + public areCoordsInSelection(coords: [number, number], start: [number, number], end: [number, number]): boolean { return this._areCoordsInSelection(coords, start, end); } // Disable DOM interaction public enable(): void {} @@ -478,5 +479,17 @@ describe('SelectionManager', () => { assert.equal(selectionManager.selectionText, 'a\n😁\nc'); }); }); + + describe('_areCoordsInSelection', () => { + it('should return whether coords are in the selection', () => { + assert.isFalse(selectionManager.areCoordsInSelection([0, 0], [2, 0], [2, 1])); + assert.isFalse(selectionManager.areCoordsInSelection([1, 0], [2, 0], [2, 1])); + assert.isTrue(selectionManager.areCoordsInSelection([2, 0], [2, 0], [2, 1])); + assert.isTrue(selectionManager.areCoordsInSelection([10, 0], [2, 0], [2, 1])); + assert.isTrue(selectionManager.areCoordsInSelection([0, 1], [2, 0], [2, 1])); + assert.isTrue(selectionManager.areCoordsInSelection([1, 1], [2, 0], [2, 1])); + assert.isFalse(selectionManager.areCoordsInSelection([2, 1], [2, 0], [2, 1])); + }); + }); }); diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index bfb57177..86be0c48 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -289,9 +289,14 @@ export class SelectionManager extends EventEmitter implements ISelectionManager return false; } + return this._areCoordsInSelection(coords, start, end); + } + + protected _areCoordsInSelection(coords: [number, number], start: [number, number], end: [number, number]): boolean { return (coords[1] > start[1] && coords[1] < end[1]) || - (start[1] === end[1] && coords[1] === start[1] && coords[0] > start[0] && coords[0] < end[0]) || - (start[1] < end[1] && coords[1] === end[1] && coords[0] < end[0]); + (start[1] === end[1] && coords[1] === start[1] && coords[0] >= start[0] && coords[0] < end[0]) || + (start[1] < end[1] && coords[1] === end[1] && coords[0] < end[0]) || + (start[1] < end[1] && coords[1] === start[1] && coords[0] >= start[0]); } /** diff --git a/src/SoundManager.ts b/src/SoundManager.ts index 4139c207..6084edcb 100644 --- a/src/SoundManager.ts +++ b/src/SoundManager.ts @@ -12,7 +12,19 @@ import { ITerminal, ISoundManager } from './Types'; export const DEFAULT_BELL_SOUND = 'data:audio/wav;base64,UklGRigBAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQBAADpAFgCwAMlBZoG/wdmCcoKRAypDQ8PbRDBEQQTOxRtFYcWlBePGIUZXhoiG88bcBz7HHIdzh0WHlMeZx51HmkeUx4WHs8dah0AHXwc3hs9G4saxRnyGBIYGBcQFv8U4RPAEoYRQBACD70NWwwHC6gJOwjWBloF7gOBAhABkf8b/qv8R/ve+Xf4Ife79W/0JfPZ8Z/wde9N7ijtE+wU6xvqM+lb6H7nw+YX5mrlxuQz5Mzje+Ma49fioeKD4nXiYeJy4pHitOL04j/jn+MN5IPkFOWs5U3mDefM55/ogOl36m7rdOyE7abuyu8D8Unyj/Pg9D/2qfcb+Yn6/vuK/Qj/lAAlAg=='; export class SoundManager implements ISoundManager { - private _audioContext: AudioContext; + private static _audioContext: AudioContext; + + static get audioContext(): AudioContext | null { + if (!SoundManager._audioContext) { + const audioContextCtor: typeof AudioContext = (window).AudioContext || (window).webkitAudioContext; + if (!audioContextCtor) { + console.warn('Web Audio API is not supported by this browser. Consider upgrading to the latest version'); + return null; + } + SoundManager._audioContext = new audioContextCtor(); + } + return SoundManager._audioContext; + } constructor( private _terminal: ITerminal @@ -20,22 +32,16 @@ export class SoundManager implements ISoundManager { } public playBellSound(): void { - const audioContextCtor: typeof AudioContext = (window).AudioContext || (window).webkitAudioContext; - if (!this._audioContext && audioContextCtor) { - this._audioContext = new audioContextCtor(); - } - - if (this._audioContext) { - const bellAudioSource = this._audioContext.createBufferSource(); - const context = this._audioContext; - this._audioContext.decodeAudioData(this._base64ToArrayBuffer(this._removeMimeType(this._terminal.options.bellSound)), (buffer) => { - bellAudioSource.buffer = buffer; - bellAudioSource.connect(context.destination); - bellAudioSource.start(0); - }); - } else { - console.warn('Sorry, but the Web Audio API is not supported by your browser. Please, consider upgrading to the latest version'); + const ctx = SoundManager.audioContext; + if (!ctx) { + return; } + const bellAudioSource = ctx.createBufferSource(); + ctx.decodeAudioData(this._base64ToArrayBuffer(this._removeMimeType(this._terminal.options.bellSound)), (buffer) => { + bellAudioSource.buffer = buffer; + bellAudioSource.connect(ctx.destination); + bellAudioSource.start(0); + }); } private _base64ToArrayBuffer(base64: string): ArrayBuffer { diff --git a/src/Terminal.ts b/src/Terminal.ts index c995af62..38eb3d5b 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -21,11 +21,11 @@ * http://linux.die.net/man/7/urxvt */ -import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharData, CharacterJoinerHandler } from './Types'; +import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharData, CharacterJoinerHandler, IBufferLine } from './Types'; import { IMouseZoneManager } from './ui/Types'; import { IRenderer } from './renderer/Types'; import { BufferSet } from './BufferSet'; -import { Buffer, MAX_BUFFER_SIZE, DEFAULT_ATTR, NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR } from './Buffer'; +import { Buffer, MAX_BUFFER_SIZE, DEFAULT_ATTR, NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, CHAR_DATA_ATTR_INDEX } from './Buffer'; import { CompositionHelper } from './CompositionHelper'; import { EventEmitter } from './common/EventEmitter'; import { Viewport } from './Viewport'; @@ -208,6 +208,9 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II private _screenDprMonitor: ScreenDprMonitor; private _theme: ITheme; + // bufferline to clone/copy from for new blank lines + private _blankLine: IBufferLine = null; + public cols: number; public rows: number; @@ -342,7 +345,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } public get isFocused(): boolean { - return document.activeElement === this.textarea; + return document.activeElement === this.textarea && document.hasFocus(); } /** @@ -473,6 +476,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II if (this._theme) { this.renderer.setTheme(this._theme); } + this.mouseHelper.setRenderer(this.renderer); break; case 'scrollback': this.buffers.resize(this.cols, this.rows); @@ -496,6 +500,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II case 'experimentalBufferLineImpl': this.buffers.normal.setBufferLineFactory(value); this.buffers.alt.setBufferLineFactory(value); + this._blankLine = null; break; } // Inform renderer of changes @@ -984,7 +989,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II : 65; break; case 'wheel': - button = (ev).wheelDeltaY > 0 + button = (ev).deltaY < 0 ? 64 : 65; break; @@ -1173,20 +1178,40 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II * Scroll the terminal down 1 row, creating a blank line. * @param isWrapped Whether the new line is wrapped from the previous line. */ - public scroll(isWrapped?: boolean): void { - const newLine = this.buffer.getBlankLine(DEFAULT_ATTR, isWrapped); + public scroll(isWrapped: boolean = false): void { + let newLine: IBufferLine; + const useRecycling = this.options.experimentalBufferLineImpl === 'TypedArray'; + if (useRecycling) { + newLine = this._blankLine; + if (!newLine || newLine.length !== this.cols || newLine.get(0)[CHAR_DATA_ATTR_INDEX] !== this.eraseAttr()) { + newLine = this.buffer.getBlankLine(this.eraseAttr(), isWrapped); + this._blankLine = newLine; + } + newLine.isWrapped = isWrapped; + } else { + newLine = this.buffer.getBlankLine(this.eraseAttr(), isWrapped); + } + const topRow = this.buffer.ybase + this.buffer.scrollTop; const bottomRow = this.buffer.ybase + this.buffer.scrollBottom; if (this.buffer.scrollTop === 0) { // Determine whether the buffer is going to be trimmed after insertion. - const willBufferBeTrimmed = this.buffer.lines.length === this.buffer.lines.maxLength; + const willBufferBeTrimmed = this.buffer.lines.isFull; // Insert the line using the fastest method if (bottomRow === this.buffer.lines.length - 1) { - this.buffer.lines.push(newLine); + if (useRecycling) { + if (willBufferBeTrimmed) { + this.buffer.lines.recycle().copyFrom(newLine); + } else { + this.buffer.lines.push(newLine.clone()); + } + } else { + this.buffer.lines.push(newLine); + } } else { - this.buffer.lines.splice(bottomRow + 1, 0, newLine); + this.buffer.lines.splice(bottomRow + 1, 0, (useRecycling) ? newLine.clone() : newLine); } // Only adjust ybase and ydisp when the buffer is not trimmed @@ -1208,7 +1233,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // scrollback, instead we can just shift them in-place. const scrollRegionHeight = bottomRow - topRow + 1/*as it's zero-based*/; this.buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1); - this.buffer.lines.set(bottomRow, newLine); + this.buffer.lines.set(bottomRow, (useRecycling) ? newLine.clone() : newLine); } // Move the viewport to the bottom of the buffer unless the user is diff --git a/src/addons/attach/attach.test.ts b/src/addons/attach/attach.test.ts index 018cfb31..e280b656 100644 --- a/src/addons/attach/attach.test.ts +++ b/src/addons/attach/attach.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { assert, expect } from 'chai'; +import { assert } from 'chai'; import * as attach from './attach'; diff --git a/src/addons/fit/fit.test.ts b/src/addons/fit/fit.test.ts index 9a6d89fd..781b5010 100644 --- a/src/addons/fit/fit.test.ts +++ b/src/addons/fit/fit.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { assert, expect } from 'chai'; +import { assert } from 'chai'; import * as fit from './fit'; diff --git a/src/addons/fullscreen/fullscreen.test.ts b/src/addons/fullscreen/fullscreen.test.ts index bb98bd30..6d41bdfd 100644 --- a/src/addons/fullscreen/fullscreen.test.ts +++ b/src/addons/fullscreen/fullscreen.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { assert, expect } from 'chai'; +import { assert } from 'chai'; import * as fullscreen from './fullscreen'; diff --git a/src/addons/fullscreen/fullscreen.ts b/src/addons/fullscreen/fullscreen.ts index 297a7f5b..4d05e904 100644 --- a/src/addons/fullscreen/fullscreen.ts +++ b/src/addons/fullscreen/fullscreen.ts @@ -11,17 +11,18 @@ import { Terminal } from 'xterm'; * @param fullscreen Toggle fullscreen on (true) or off (false) */ export function toggleFullScreen(term: Terminal, fullscreen: boolean): void { - let fn: string; + let fn: (...tokens: string[]) => void; if (typeof fullscreen === 'undefined') { - fn = (term.element.classList.contains('fullscreen')) ? 'remove' : 'add'; + fn = (term.element.classList.contains('fullscreen')) ? + term.element.classList.remove : term.element.classList.add; } else if (!fullscreen) { - fn = 'remove'; + fn = term.element.classList.remove; } else { - fn = 'add'; + fn = term.element.classList.add; } - term.element.classList[fn]('fullscreen'); + fn('fullscreen'); } export function apply(terminalConstructor: typeof Terminal): void { diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts index d07c6b2c..7919932a 100644 --- a/src/addons/search/SearchHelper.ts +++ b/src/addons/search/SearchHelper.ts @@ -4,7 +4,7 @@ */ import { ISearchHelper, ISearchAddonTerminal, ISearchOptions, ISearchResult } from './Interfaces'; -const nonWordCharacters = ' ~!@#$%^&*()_+`-=[]{}|\;:"\',./<>?'; +const nonWordCharacters = ' ~!@#$%^&*()+`-=[]{}|\;:"\',./<>?'; /** * A class that knows how to search the terminal and how to display the results. @@ -19,7 +19,7 @@ export class SearchHelper implements ISearchHelper { /** * Find the next instance of the term, then scroll to and select it. If it * doesn't exist, do nothing. - * @param term Tne search term. + * @param term The search term. * @param searchOptions Search options. * @return Whether a result was found. */ @@ -33,7 +33,9 @@ export class SearchHelper implements ISearchHelper { let startRow = this._terminal._core.buffer.ydisp; if (this._terminal._core.selectionManager.selectionEnd) { // Start from the selection end if there is a selection - startRow = this._terminal._core.selectionManager.selectionEnd[1]; + if (this._terminal.getSelection().length !== 0) { + startRow = this._terminal._core.selectionManager.selectionEnd[1]; + } } // Search from ydisp + 1 to end @@ -61,7 +63,7 @@ export class SearchHelper implements ISearchHelper { /** * Find the previous instance of the term, then scroll to and select it. If it * doesn't exist, do nothing. - * @param term Tne search term. + * @param term The search term. * @param searchOptions Search options. * @return Whether a result was found. */ @@ -75,7 +77,9 @@ export class SearchHelper implements ISearchHelper { let startRow = this._terminal._core.buffer.ydisp; if (this._terminal._core.selectionManager.selectionStart) { // Start from the selection end if there is a selection - startRow = this._terminal._core.selectionManager.selectionStart[1]; + if (this._terminal.getSelection().length !== 0) { + startRow = this._terminal._core.selectionManager.selectionStart[1]; + } } // Search from ydisp + 1 to end @@ -108,7 +112,7 @@ export class SearchHelper implements ISearchHelper { */ private _isWholeWord(searchIndex: number, line: string, term: string): boolean { return (((searchIndex === 0) || (nonWordCharacters.indexOf(line[searchIndex - 1]) !== -1)) && - (((searchIndex + term.length) === line.length) || (nonWordCharacters.indexOf(line[searchIndex + term.length]) !== -1))); + (((searchIndex + term.length) === line.length) || (nonWordCharacters.indexOf(line[searchIndex + term.length]) !== -1))); } /** @@ -116,7 +120,7 @@ export class SearchHelper implements ISearchHelper { * subsequent terminal lines if the text is wrapped. If the provided line number is part of a wrapped text line that * started on an earlier line then it is skipped since it will be properly searched when the terminal line that the * text starts on is searched. - * @param term Tne search term. + * @param term The search term. * @param y The line to search. * @param searchOptions Search options. * @return The search result if it was found. @@ -191,7 +195,7 @@ export class SearchHelper implements ISearchHelper { do { const nextLine = this._terminal._core.buffer.lines.get(lineIndex + 1); lineWrapsToNext = nextLine ? nextLine.isWrapped : false; - lineString += this._terminal._core.buffer.translateBufferLineToString(lineIndex, !lineWrapsToNext && trimRight); + lineString += this._terminal._core.buffer.translateBufferLineToString(lineIndex, !lineWrapsToNext && trimRight).substring(0, this._terminal.cols); lineIndex++; } while (lineWrapsToNext); @@ -205,6 +209,7 @@ export class SearchHelper implements ISearchHelper { */ private _selectResult(result: ISearchResult): boolean { if (!result) { + this._terminal.clearSelection(); return false; } this._terminal._core.selectionManager.setSelection(result.col, result.row, result.term.length); diff --git a/src/addons/search/search.test.ts b/src/addons/search/search.test.ts index 1fe18b6a..3e0b8154 100644 --- a/src/addons/search/search.test.ts +++ b/src/addons/search/search.test.ts @@ -2,6 +2,7 @@ * Copyright (c) 2018 The xterm.js authors. All rights reserved. * @license MIT */ +declare var require: any; import { assert, expect } from 'chai'; import * as search from './search'; @@ -81,6 +82,9 @@ describe('search addon', () => { expect(hello3).eql(undefined); expect(llo).eql(undefined); expect(goodbye).eql({col: 0, row: 5, term: 'goodbye'}); + term.core.resize(9, 5); + const hello0Resize = term.searchHelper.findInLine('Hello', 0); + expect(hello0Resize).eql({col: 8, row: 0, term: 'Hello'}); }); it('should respect search regex', () => { search.apply(MockTerminal); diff --git a/src/addons/search/search.ts b/src/addons/search/search.ts index 9be0b33b..c274f28a 100644 --- a/src/addons/search/search.ts +++ b/src/addons/search/search.ts @@ -10,7 +10,7 @@ import { ISearchAddonTerminal, ISearchOptions } from './Interfaces'; /** * Find the next instance of the term, then scroll to and select it. If it * doesn't exist, do nothing. - * @param term Tne search term. + * @param term The search term. * @param searchOptions Search options * @return Whether a result was found. */ @@ -25,7 +25,7 @@ export function findNext(terminal: Terminal, term: string, searchOptions: ISearc /** * Find the previous instance of the term, then scroll to and select it. If it * doesn't exist, do nothing. - * @param term Tne search term. + * @param term The search term. * @param searchOptions Search options * @return Whether a result was found. */ diff --git a/src/addons/search/tsconfig.json b/src/addons/search/tsconfig.json index 9998dc1b..c34a0bc5 100644 --- a/src/addons/search/tsconfig.json +++ b/src/addons/search/tsconfig.json @@ -18,8 +18,5 @@ }, "include": [ "**/*.ts" - ], - "exclude": [ - "**/*.test.ts" ] } diff --git a/src/addons/terminado/terminado.test.ts b/src/addons/terminado/terminado.test.ts index 2e4a53c5..e46eafdf 100644 --- a/src/addons/terminado/terminado.test.ts +++ b/src/addons/terminado/terminado.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { assert, expect } from 'chai'; +import { assert } from 'chai'; import * as terminado from './terminado'; diff --git a/src/addons/webLinks/webLinks.test.ts b/src/addons/webLinks/webLinks.test.ts index c84ee1a5..8ada2510 100644 --- a/src/addons/webLinks/webLinks.test.ts +++ b/src/addons/webLinks/webLinks.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { assert, expect } from 'chai'; +import { assert } from 'chai'; import * as webLinks from './webLinks'; @@ -39,4 +39,28 @@ describe('webLinks addon', () => { assert.equal(uri, 'http://foo.com/a~b#c~d?e~f'); }); + + it('should allow : character in URI path', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://foo.com/colon:test '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/colon:test'); + }); + + it('should not allow : character at the end of a URI path', () => { + const term = new MockTerminal(); + webLinks.webLinksInit(term); + + const row = ' http://foo.com/colon:test: '; + + const match = row.match(term.regex); + const uri = match[term.options.matchIndex]; + + assert.equal(uri, 'http://foo.com/colon:test'); + }); }); diff --git a/src/addons/webLinks/webLinks.ts b/src/addons/webLinks/webLinks.ts index a007bbd6..75d79104 100644 --- a/src/addons/webLinks/webLinks.ts +++ b/src/addons/webLinks/webLinks.ts @@ -14,7 +14,7 @@ const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})'; const localHostClause = '(localhost)'; const portClause = '(:\\d{1,5})'; const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?'; -const pathClause = '(\\/[\\/\\w\\.\\-%~]*)*'; +const pathClause = '(\\/[\\/\\w\\.\\-%~:]*)*([^:\\s])'; const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*'; const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?'; const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?'; diff --git a/src/addons/winptyCompat/winptyCompat.test.ts b/src/addons/winptyCompat/winptyCompat.test.ts index 0c9269ed..c3a7e479 100644 --- a/src/addons/winptyCompat/winptyCompat.test.ts +++ b/src/addons/winptyCompat/winptyCompat.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { assert, expect } from 'chai'; +import { assert } from 'chai'; import * as winptyCompat from './winptyCompat'; diff --git a/src/addons/zmodem/zmodem.test.ts b/src/addons/zmodem/zmodem.test.ts index 682e62c8..d0c7c5fb 100644 --- a/src/addons/zmodem/zmodem.test.ts +++ b/src/addons/zmodem/zmodem.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { assert, expect } from 'chai'; +import { assert } from 'chai'; import * as zmodem from './zmodem'; diff --git a/src/addons/zmodem/zmodem.ts b/src/addons/zmodem/zmodem.ts index 23204f0f..70fc6e98 100644 --- a/src/addons/zmodem/zmodem.ts +++ b/src/addons/zmodem/zmodem.ts @@ -34,7 +34,7 @@ import { Terminal } from 'xterm'; * via `detach()` and a re-`attach()`.) */ -let zmodem; +let zmodem: any; export interface IZmodemOptions { noTerminalWriteOutsideSession?: boolean; @@ -44,7 +44,7 @@ function zmodemAttach(ws: WebSocket, opts: IZmodemOptions = {}): void { const term = this; const senderFunc = (octets: ArrayLike) => ws.send(new Uint8Array(octets)); - let zsentry; + let zsentry: any; function shouldWrite(): boolean { return !!zsentry.get_confirmed_session() || !opts.noTerminalWriteOutsideSession; diff --git a/src/common/CircularList.ts b/src/common/CircularList.ts index 542dbf12..9faf534a 100644 --- a/src/common/CircularList.ts +++ b/src/common/CircularList.ts @@ -90,16 +90,34 @@ export class CircularList extends EventEmitter implements ICircularList { public push(value: T): void { this._array[this._getCyclicIndex(this._length)] = value; if (this._length === this._maxLength) { - this._startIndex++; - if (this._startIndex === this._maxLength) { - this._startIndex = 0; - } + this._startIndex = ++this._startIndex % this._maxLength; this.emit('trim', 1); } else { this._length++; } } + /** + * Advance ringbuffer index and return current element for recycling. + * Note: The buffer must be full for this method to work. + * @throws When the buffer is not full. + */ + public recycle(): T { + if (this._length !== this._maxLength) { + throw new Error('Can only recycle when the buffer is full'); + } + this._startIndex = ++this._startIndex % this._maxLength; + this.emit('trim', 1); + return this._array[this._getCyclicIndex(this._length - 1)]!; + } + + /** + * Ringbuffer is at max length. + */ + public get isFull(): boolean { + return this._length === this._maxLength; + } + /** * Removes and returns the last value on the list. * @return The popped value. @@ -136,10 +154,10 @@ export class CircularList extends EventEmitter implements ICircularList { } // Adjust length as needed - if (this._length + items.length > this.maxLength) { - const countToTrim = (this._length + items.length) - this.maxLength; + if (this._length + items.length > this._maxLength) { + const countToTrim = (this._length + items.length) - this._maxLength; this._startIndex += countToTrim; - this._length = this.maxLength; + this._length = this._maxLength; this.emit('trim', countToTrim); } else { this._length += items.length; @@ -178,7 +196,7 @@ export class CircularList extends EventEmitter implements ICircularList { const expandListBy = (start + count + offset) - this._length; if (expandListBy > 0) { this._length += expandListBy; - while (this._length > this.maxLength) { + while (this._length > this._maxLength) { this._length--; this._startIndex++; this.emit('trim', 1); @@ -198,6 +216,6 @@ export class CircularList extends EventEmitter implements ICircularList { * @returns The cyclic index. */ private _getCyclicIndex(index: number): number { - return (this._startIndex + index) % this.maxLength; + return (this._startIndex + index) % this._maxLength; } } diff --git a/src/common/Types.ts b/src/common/Types.ts index aabe721e..8a416bf1 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -24,10 +24,12 @@ export interface IKeyboardEvent { export interface ICircularList extends IEventEmitter { length: number; maxLength: number; + isFull: boolean; get(index: number): T | undefined; set(index: number, value: T): void; push(value: T): void; + recycle(): T | undefined; pop(): T | undefined; splice(start: number, deleteCount: number, ...items: T[]): void; trimStart(count: number): void; diff --git a/src/core/TypedArrayUtils.test.ts b/src/core/TypedArrayUtils.test.ts new file mode 100644 index 00000000..ef86d314 --- /dev/null +++ b/src/core/TypedArrayUtils.test.ts @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { assert } from 'chai'; +import { fill } from './TypedArrayUtils'; + +type TypedArray = Uint8Array | Uint16Array | Uint32Array | Uint8ClampedArray + | Int8Array | Int16Array | Int32Array + | Float32Array | Float64Array; + +// we explicitly test against the looping version in the test cases +function loopFill(array: TypedArray, value: number, start: number = 0, end?: number | undefined): TypedArray { + if (start >= array.length) { + return array; + } + start = (array.length + start) % array.length; + if (end === undefined) { + end = array.length; + } + if (end >= array.length) { + end = array.length; + } else { + end = (array.length + end) % array.length; + } + for (let i = start; i < end; ++i) { + array[i] = value; + } + return array; +} + +describe('polyfill conformance tests', function(): void { + + function deepEquals(a: TypedArray, b: TypedArray): void { + assert.equal(a.length, b.length); + for (let i = 0; i < a.length; ++i) { + assert.equal(a[i], b[i]); + } + } + + describe('TypedArray.fill', function(): void { + it('should work with all typed array types', function(): void { + const u81 = new Uint8Array(5); + const u82 = new Uint8Array(5); + deepEquals(fill(u81, 2), u82.fill(2)); + deepEquals(fill(u81, -1), u82.fill(-1)); + const u161 = new Uint16Array(5); + const u162 = new Uint16Array(5); + deepEquals(fill(u161, 2), u162.fill(2)); + deepEquals(fill(u161, 65535), u162.fill(65535)); + deepEquals(fill(u161, -1), u162.fill(-1)); + const u321 = new Uint32Array(5); + const u322 = new Uint32Array(5); + deepEquals(fill(u321, 2), u322.fill(2)); + deepEquals(fill(u321, 65537), u322.fill(65537)); + deepEquals(fill(u321, -1), u322.fill(-1)); + const i81 = new Int8Array(5); + const i82 = new Int8Array(5); + deepEquals(fill(i81, 2), i82.fill(2)); + deepEquals(fill(i81, -1), i82.fill(-1)); + const i161 = new Int16Array(5); + const i162 = new Int16Array(5); + deepEquals(fill(i161, 2), i162.fill(2)); + deepEquals(fill(i161, 65535), i162.fill(65535)); + deepEquals(fill(i161, -1), i162.fill(-1)); + const i321 = new Int32Array(5); + const i322 = new Int32Array(5); + deepEquals(fill(i321, 2), i322.fill(2)); + deepEquals(fill(i321, 65537), i322.fill(65537)); + deepEquals(fill(i321, -1), i322.fill(-1)); + const f321 = new Float32Array(5); + const f322 = new Float32Array(5); + deepEquals(fill(f321, 1.2345), f322.fill(1.2345)); + const f641 = new Float64Array(5); + const f642 = new Float64Array(5); + deepEquals(fill(f641, 1.2345), f642.fill(1.2345)); + const u8Clamped1 = new Uint8ClampedArray(5); + const u8Clamped2 = new Uint8ClampedArray(5); + deepEquals(fill(u8Clamped1, 2), u8Clamped2.fill(2)); + deepEquals(fill(u8Clamped1, 257), u8Clamped2.fill(257)); + }); + it('should work with all typed array types - explicit looping', function(): void { + const u81 = new Uint8Array(5); + const u82 = new Uint8Array(5); + deepEquals(loopFill(u81, 2), u82.fill(2)); + deepEquals(loopFill(u81, -1), u82.fill(-1)); + const u161 = new Uint16Array(5); + const u162 = new Uint16Array(5); + deepEquals(loopFill(u161, 2), u162.fill(2)); + deepEquals(loopFill(u161, 65535), u162.fill(65535)); + deepEquals(loopFill(u161, -1), u162.fill(-1)); + const u321 = new Uint32Array(5); + const u322 = new Uint32Array(5); + deepEquals(loopFill(u321, 2), u322.fill(2)); + deepEquals(loopFill(u321, 65537), u322.fill(65537)); + deepEquals(loopFill(u321, -1), u322.fill(-1)); + const i81 = new Int8Array(5); + const i82 = new Int8Array(5); + deepEquals(loopFill(i81, 2), i82.fill(2)); + deepEquals(loopFill(i81, -1), i82.fill(-1)); + const i161 = new Int16Array(5); + const i162 = new Int16Array(5); + deepEquals(loopFill(i161, 2), i162.fill(2)); + deepEquals(loopFill(i161, 65535), i162.fill(65535)); + deepEquals(loopFill(i161, -1), i162.fill(-1)); + const i321 = new Int32Array(5); + const i322 = new Int32Array(5); + deepEquals(loopFill(i321, 2), i322.fill(2)); + deepEquals(loopFill(i321, 65537), i322.fill(65537)); + deepEquals(loopFill(i321, -1), i322.fill(-1)); + const f321 = new Float32Array(5); + const f322 = new Float32Array(5); + deepEquals(loopFill(f321, 1.2345), f322.fill(1.2345)); + const f641 = new Float64Array(5); + const f642 = new Float64Array(5); + deepEquals(loopFill(f641, 1.2345), f642.fill(1.2345)); + const u8Clamped1 = new Uint8ClampedArray(5); + const u8Clamped2 = new Uint8ClampedArray(5); + deepEquals(loopFill(u8Clamped1, 2), u8Clamped2.fill(2)); + deepEquals(loopFill(u8Clamped1, 257), u8Clamped2.fill(257)); + }); + it('start offset', function(): void { + for (let i = -2; i < 10; ++i) { + const u81 = new Uint8Array(5); + const u82 = new Uint8Array(5); + const u83 = new Uint8Array(5); + deepEquals(fill(u81, 2, i), u83.fill(2, i)); + deepEquals(fill(u81, -1, i), u83.fill(-1, i)); + deepEquals(loopFill(u82, 2, i), u83.fill(2, i)); + deepEquals(loopFill(u82, -1, i), u83.fill(-1, i)); + } + }); + it('end offset', function(): void { + for (let i = -2; i < 10; ++i) { + const u81 = new Uint8Array(5); + const u82 = new Uint8Array(5); + const u83 = new Uint8Array(5); + deepEquals(fill(u81, 2, 0, i), u83.fill(2, 0, i)); + deepEquals(fill(u81, -1, 0, i), u83.fill(-1, 0, i)); + deepEquals(loopFill(u82, 2, 0, i), u83.fill(2, 0, i)); + deepEquals(loopFill(u82, -1, 0, i), u83.fill(-1, 0, i)); + } + }); + it('start/end offset', function(): void { + for (let i = -2; i < 10; ++i) { + for (let j = -2; j < 10; ++j) { + const u81 = new Uint8Array(5); + const u82 = new Uint8Array(5); + const u83 = new Uint8Array(5); + deepEquals(fill(u81, 2, i, j), u83.fill(2, i, j)); + deepEquals(fill(u81, -1, i, j), u83.fill(-1, i, j)); + deepEquals(loopFill(u82, 2, i, j), u83.fill(2, i, j)); + deepEquals(loopFill(u82, -1, i, j), u83.fill(-1, i, j)); + } + } + }); + }); +}); diff --git a/src/core/TypedArrayUtils.ts b/src/core/TypedArrayUtils.ts new file mode 100644 index 00000000..56e9d7b0 --- /dev/null +++ b/src/core/TypedArrayUtils.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * @license MIT + */ + +/** + * polyfill for TypedArray.fill + * This is needed to support .fill in all safari versions and IE 11. + */ + +type TypedArray = Uint8Array | Uint16Array | Uint32Array | Uint8ClampedArray + | Int8Array | Int16Array | Int32Array + | Float32Array | Float64Array; + +export function fill(array: TypedArray, value: number, start: number = 0, end?: number | undefined): TypedArray { + // all modern engines that support .fill + if (array.fill) { + return array.fill(value, start, end); + } + // safari and IE 11 + // since IE 11 does not support Array.prototype.fill either + // we cannot use the suggested polyfill from MDN + // instead we simply fall back to looping + if (start >= array.length) { + return array; + } + start = (array.length + start) % array.length; + if (end === undefined) { + end = array.length; + } + if (end >= array.length) { + end = array.length; + } else { + end = (array.length + end) % array.length; + } + for (let i = start; i < end; ++i) { + array[i] = value; + } + return array; +} diff --git a/src/renderer/atlas/DynamicCharAtlas.ts b/src/renderer/atlas/DynamicCharAtlas.ts index 950fdd1d..b6b323f0 100644 --- a/src/renderer/atlas/DynamicCharAtlas.ts +++ b/src/renderer/atlas/DynamicCharAtlas.ts @@ -129,12 +129,17 @@ export default class DynamicCharAtlas extends BaseCharAtlas { return true; } + // Exit early for uncachable glyphs + if (!this._canCache(glyph)) { + return false; + } + const glyphKey = getGlyphCacheKey(glyph); const cacheValue = this._cacheMap.get(glyphKey); if (cacheValue !== null && cacheValue !== undefined) { this._drawFromCache(ctx, cacheValue, x, y); return true; - } else if (this._canCache(glyph) && this._drawToCacheCount < FRAME_CACHE_DRAW_LIMIT) { + } else if (this._drawToCacheCount < FRAME_CACHE_DRAW_LIMIT) { let index; if (this._cacheMap.size < this._cacheMap.capacity) { index = this._cacheMap.size; diff --git a/src/renderer/dom/DomRenderer.ts b/src/renderer/dom/DomRenderer.ts index 069e9a13..a0cefd67 100644 --- a/src/renderer/dom/DomRenderer.ts +++ b/src/renderer/dom/DomRenderer.ts @@ -95,22 +95,25 @@ export class DomRenderer extends EventEmitter implements IRenderer { } 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.scaledCharWidth = Math.floor(this._terminal.charMeasure.width * window.devicePixelRatio); + this.dimensions.scaledCharHeight = Math.ceil(this._terminal.charMeasure.height * window.devicePixelRatio); + this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth + Math.round(this._terminal.options.letterSpacing); + this.dimensions.scaledCellHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.options.lineHeight); 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.dimensions.canvasWidth = Math.round(this.dimensions.scaledCanvasWidth / window.devicePixelRatio); + this.dimensions.canvasHeight = Math.round(this.dimensions.scaledCanvasHeight / window.devicePixelRatio); + this.dimensions.actualCellWidth = this.dimensions.canvasWidth / this._terminal.cols; + this.dimensions.actualCellHeight = this.dimensions.canvasHeight / this._terminal.rows; this._rowElements.forEach(element => { element.style.width = `${this.dimensions.canvasWidth}px`; - element.style.height = `${this._terminal.charMeasure.height}px`; + element.style.height = `${this.dimensions.actualCellHeight}px`; + element.style.lineHeight = `${this.dimensions.actualCellHeight}px`; + // Make sure rows don't overflow onto following row + element.style.overflow = 'hidden'; }); if (!this._dimensionsStyleElement) { @@ -123,14 +126,14 @@ export class DomRenderer extends EventEmitter implements IRenderer { ` display: inline-block;` + ` height: 100%;` + ` vertical-align: top;` + - ` width: ${this._terminal.charMeasure.width}px` + + ` width: ${this.dimensions.actualCellWidth}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`; + this._terminal.screenElement.style.width = `${this.dimensions.canvasWidth}px`; + this._terminal.screenElement.style.height = `${this.dimensions.canvasHeight}px`; } public setTheme(theme: ITheme | undefined): IColorSet { @@ -294,10 +297,10 @@ export class DomRenderer extends EventEmitter implements IRenderer { */ 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`; + element.style.height = `${rowCount * this.dimensions.actualCellHeight}px`; + element.style.top = `${row * this.dimensions.actualCellHeight}px`; + element.style.left = `${colStart * this.dimensions.actualCellWidth}px`; + element.style.width = `${this.dimensions.actualCellWidth * (colEnd - colStart)}px`; return element; } @@ -333,7 +336,7 @@ export class DomRenderer extends EventEmitter implements IRenderer { const row = y + terminal.buffer.ydisp; const lineData = terminal.buffer.lines.get(row); const cursorStyle = terminal.options.cursorStyle; - rowElement.appendChild(this._rowFactory.createRow(lineData, row === cursorAbsoluteY, cursorStyle, cursorX, terminal.charMeasure.width, terminal.cols)); + rowElement.appendChild(this._rowFactory.createRow(lineData, row === cursorAbsoluteY, cursorStyle, cursorX, this.dimensions.actualCellWidth, terminal.cols)); } this._terminal.emit('refresh', {start, end}); @@ -356,7 +359,11 @@ export class DomRenderer extends EventEmitter implements IRenderer { private _setCellUnderline(x: number, x2: number, y: number, y2: number, cols: number, enabled: boolean): void { while (x !== x2 || y !== y2) { - const span = this._rowElements[y].children[x]; + const row = this._rowElements[y]; + if (!row) { + return; + } + const span = row.children[x]; span.style.textDecoration = enabled ? 'underline' : 'none'; x = (x + 1) % cols; if (x === 0) { diff --git a/src/renderer/dom/DomRendererRowFactory.test.ts b/src/renderer/dom/DomRendererRowFactory.test.ts index 5bc849ed..67342da0 100644 --- a/src/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/renderer/dom/DomRendererRowFactory.test.ts @@ -24,11 +24,10 @@ describe('DomRendererRowFactory', () => { }); describe('createRow', () => { - it('should create an element for every character in the row', () => { + it('should not create anything for an empty row', () => { const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - ' ' + - ' ' + '' ); }); @@ -46,8 +45,7 @@ describe('DomRendererRowFactory', () => { for (const style of ['block', 'bar', 'underline']) { const fragment = rowFactory.createRow(lineData, true, style, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - ` ` + - ' ' + ` ` ); } }); @@ -66,8 +64,7 @@ describe('DomRendererRowFactory', () => { lineData.set(0, [DEFAULT_ATTR | (FLAGS.BOLD << 18), 'a', 1, 'a'.charCodeAt(0)]); const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + - ' ' + 'a' ); }); @@ -75,8 +72,7 @@ describe('DomRendererRowFactory', () => { lineData.set(0, [DEFAULT_ATTR | (FLAGS.ITALIC << 18), 'a', 1, 'a'.charCodeAt(0)]); const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + - ' ' + 'a' ); }); @@ -86,8 +82,7 @@ describe('DomRendererRowFactory', () => { lineData.set(0, [defaultAttrNoFgColor | (i << 9), 'a', 1, 'a'.charCodeAt(0)]); const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - `a` + - ' ' + `a` ); } }); @@ -98,8 +93,7 @@ describe('DomRendererRowFactory', () => { lineData.set(0, [defaultAttrNoBgColor | (i << 0), 'a', 1, 'a'.charCodeAt(0)]); const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - `a` + - ' ' + `a` ); } }); @@ -108,8 +102,7 @@ describe('DomRendererRowFactory', () => { lineData.set(0, [(FLAGS.INVERSE << 18) | (2 << 9) | (1 << 0), 'a', 1, 'a'.charCodeAt(0)]); const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + - ' ' + 'a' ); }); @@ -117,8 +110,7 @@ describe('DomRendererRowFactory', () => { lineData.set(0, [(FLAGS.INVERSE << 18) | (DEFAULT_ATTR << 9) | (1 << 0), 'a', 1, 'a'.charCodeAt(0)]); const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + - ' ' + 'a' ); }); @@ -126,8 +118,7 @@ describe('DomRendererRowFactory', () => { lineData.set(0, [(FLAGS.INVERSE << 18) | (1 << 9) | (DEFAULT_COLOR << 0), 'a', 1, 'a'.charCodeAt(0)]); const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + - ' ' + 'a' ); }); @@ -136,8 +127,7 @@ describe('DomRendererRowFactory', () => { lineData.set(0, [(FLAGS.BOLD << 18) | (i << 9) | (DEFAULT_COLOR << 0), 'a', 1, 'a'.charCodeAt(0)]); const fragment = rowFactory.createRow(lineData, false, undefined, 0, 5, 20); assert.equal(getFragmentHtml(fragment), - `a` + - ' ' + `a` ); } }); diff --git a/src/renderer/dom/DomRendererRowFactory.ts b/src/renderer/dom/DomRendererRowFactory.ts index 9f8ac357..54f088d4 100644 --- a/src/renderer/dom/DomRendererRowFactory.ts +++ b/src/renderer/dom/DomRendererRowFactory.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_ATTR_INDEX, CHAR_DATA_WIDTH_INDEX } from '../../Buffer'; +import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_ATTR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CODE_INDEX, NULL_CELL_CODE } from '../../Buffer'; import { FLAGS } from '../Types'; import { IBufferLine } from '../../Types'; import { DEFAULT_COLOR, INVERTED_DEFAULT_COLOR } from '../atlas/Types'; @@ -23,18 +23,27 @@ export class DomRendererRowFactory { public createRow(lineData: IBufferLine, isCursorRow: boolean, cursorStyle: string | undefined, cursorX: number, cellWidth: number, cols: number): DocumentFragment { const fragment = this._document.createDocumentFragment(); - let colCount = 0; - - for (let x = 0; x < lineData.length; x++) { - // Don't allow any buffer to the right to be displayed - if (colCount >= cols) { - continue; - } + // Find the line length first, this prevents the need to output a bunch of + // empty cells at the end. This cannot easily be integrated into the main + // loop below because of the colCount feature (which can be removed after we + // properly support reflow and disallow data to go beyond the right-side of + // the viewport). + let lineLength = 0; + for (let x = Math.min(lineData.length, cols) - 1; x >= 0; x--) { const charData = lineData.get(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]; + const code = charData[CHAR_DATA_CODE_INDEX]; + if (code !== NULL_CELL_CODE || (isCursorRow && x === cursorX)) { + lineLength = x + 1; + break; + } + } + + for (let x = 0; x < lineLength; x++) { + const charData = lineData.get(x); + const char = charData[CHAR_DATA_CHAR_INDEX]; + const attr = charData[CHAR_DATA_ATTR_INDEX]; + const width = 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) { @@ -100,7 +109,6 @@ export class DomRendererRowFactory { charElement.classList.add(`xterm-bg-${bg}`); } fragment.appendChild(charElement); - colCount += width; } return fragment; } diff --git a/src/utils/MouseHelper.ts b/src/utils/MouseHelper.ts index 15f05742..ca1bb27e 100644 --- a/src/utils/MouseHelper.ts +++ b/src/utils/MouseHelper.ts @@ -9,6 +9,10 @@ import { IRenderer } from '../renderer/Types'; export class MouseHelper { constructor(private _renderer: IRenderer) {} + public setRenderer(renderer: IRenderer): void { + this._renderer = renderer; + } + public static getCoordsRelativeToElement(event: {pageX: number, pageY: number}, element: HTMLElement): [number, number] { // Ignore browsers that don't support MouseEvent.pageX if (event.pageX === null || event.pageX === undefined) { diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index f575855a..353e615f 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -121,7 +121,7 @@ export class MockTerminal implements ITerminal { handler(data: string): void { throw new Error('Method not implemented.'); } - on(event: string, callback: () => void): void { + on(event: string, callback: (...args: any[]) => void): void { throw new Error('Method not implemented.'); } off(type: string, listener: XtermListener): void { diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index b5a8a109..c6b6b1e5 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -161,7 +161,6 @@ declare module 'xterm' { * when canvas is too slow for the environment. The following features do * not work when the DOM renderer is used: * - * - Line height * - Letter spacing * - Cursor blink */ @@ -388,37 +387,37 @@ declare module 'xterm' { * @param type The type of the event. * @param listener The listener. */ - on(type: 'key', listener: (key?: string, event?: KeyboardEvent) => void): void; + on(type: 'key', listener: (key: string, event: KeyboardEvent) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. */ - on(type: 'keypress' | 'keydown', listener: (event?: KeyboardEvent) => void): void; + on(type: 'keypress' | 'keydown', listener: (event: KeyboardEvent) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. */ - on(type: 'refresh', listener: (data?: {start: number, end: number}) => void): void; + on(type: 'refresh', listener: (data: {start: number, end: number}) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. */ - on(type: 'resize', listener: (data?: {cols: number, rows: number}) => void): void; + on(type: 'resize', listener: (data: {cols: number, rows: number}) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. */ - on(type: 'scroll', listener: (ydisp?: number) => void): void; + on(type: 'scroll', listener: (ydisp: number) => void): void; /** * Registers an event listener. * @param type The type of the event. * @param listener The listener. */ - on(type: 'title', listener: (title?: string) => void): void; + on(type: 'title', listener: (title: string) => void): void; /** * Registers an event listener. * @param type The type of the event.