diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index db2968e6..183687ac 100644 --- a/addons/xterm-addon-search/src/SearchAddon.ts +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -320,13 +320,13 @@ export class SearchAddon implements ITerminalAddon { break; } // Adjust the searchIndex to normalize emoji into single chars - const char = cell.char; + const char = cell.getChars(); if (char.length > 1) { resultIndex -= char.length - 1; } // Adjust the searchIndex for empty characters following wide unicode // chars (eg. CJK) - const charWidth = cell.width; + const charWidth = cell.getWidth(); if (charWidth === 0) { resultIndex++; } diff --git a/addons/xterm-addon-serialize/.gitignore b/addons/xterm-addon-serialize/.gitignore index 3063f07d..03c051b3 100644 --- a/addons/xterm-addon-serialize/.gitignore +++ b/addons/xterm-addon-serialize/.gitignore @@ -1,2 +1,3 @@ lib node_modules +out-benchmark diff --git a/addons/xterm-addon-serialize/README.md b/addons/xterm-addon-serialize/README.md new file mode 100644 index 00000000..12e0ac87 --- /dev/null +++ b/addons/xterm-addon-serialize/README.md @@ -0,0 +1,42 @@ +## xterm-addon-serialize + +An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables xterm.js to serialize a terminal framebuffer into string or html. This addon requires xterm.js v4+. + +⚠️ This is an experimental addon that is still under construction ⚠️ + +### Install + +```bash +npm install --save xterm-addon-serialize +``` + +### Usage + +```ts +import { Terminal } from "xterm"; +import { SerializeAddon } from "xterm-addon-serialize"; + +const terminal = new Terminal(); +const serializeAddon = new SerializeAddon(); +terminal.loadAddon(serializeAddon); + +terminal.write("something...", () => { + console.log(serializeAddon.serialize()); +}); +``` + +See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/xterm-addon-serialize/typings/xterm-addon-serialize.d.ts) for more advanced usage. + +### Benchmark + +⚠️ Ensure you have `lolcat`, `hexdump` programs installed in your computer + +```shell +$ git clone https://github.com/xtermjs/xterm.js.git +$ cd xterm.js +$ yarn +$ cd addons/xterm-addon-serialize +$ yarn benchmark && yarn benchmark-baseline +$ # change some code in `xterm-addon-serialize` +$ yarn benchmark-eval +``` diff --git a/addons/xterm-addon-serialize/benchmark/SerializeAddon.benchmark.ts b/addons/xterm-addon-serialize/benchmark/SerializeAddon.benchmark.ts new file mode 100644 index 00000000..025e7194 --- /dev/null +++ b/addons/xterm-addon-serialize/benchmark/SerializeAddon.benchmark.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, ThroughputRuntimeCase } from 'xterm-benchmark'; + +import { spawn } from 'node-pty'; +import { Utf8ToUtf32, stringFromCodePoint } from 'common/input/TextDecoder'; +import { Terminal } from 'public/Terminal'; +import { SerializeAddon } from 'SerializeAddon'; + +class TestTerminal extends Terminal { + writeSync(data: string): void { + (this)._core.writeSync(data); + } +} + +perfContext('Terminal: sh -c "dd if=/dev/random count=40 bs=1k | hexdump | lolcat -f"', () => { + let content = ''; + let contentUtf8: Uint8Array; + + before(async () => { + const p = spawn('sh', ['-c', 'dd if=/dev/random count=40 bs=1k | hexdump | lolcat -f'], { + name: 'xterm-256color', + cols: 80, + rows: 25, + cwd: process.env.HOME, + env: process.env, + encoding: (null as unknown as string) // needs to be fixed in node-pty + }); + const chunks: Buffer[] = []; + let length = 0; + p.on('data', data => { + chunks.push(data as unknown as Buffer); + length += data.length; + }); + await new Promise(resolve => p.on('exit', () => resolve())); + contentUtf8 = Buffer.concat(chunks, length); + // translate to content string + const buffer = new Uint32Array(contentUtf8.length); + const decoder = new Utf8ToUtf32(); + const codepoints = decoder.decode(contentUtf8, buffer); + for (let i = 0; i < codepoints; ++i) { + content += stringFromCodePoint(buffer[i]); + // peek into content to force flat repr in v8 + if (!(i % 10000000)) { + content[i]; + } + } + }); + + perfContext('serialize', () => { + let terminal: TestTerminal; + const serializeAddon = new SerializeAddon(); + before(() => { + terminal = new TestTerminal({ cols: 80, rows: 25, scrollback: 5000 }); + serializeAddon.activate(terminal); + terminal.writeSync(content); + }); + new ThroughputRuntimeCase('', () => { + return { payloadSize: serializeAddon.serialize().length }; + }, { fork: false }).showAverageThroughput(); + }); +}); diff --git a/addons/xterm-addon-serialize/benchmark/benchmark.json b/addons/xterm-addon-serialize/benchmark/benchmark.json new file mode 100644 index 00000000..f8b99b55 --- /dev/null +++ b/addons/xterm-addon-serialize/benchmark/benchmark.json @@ -0,0 +1,19 @@ +{ + "APP_PATH": ".benchmark", + "evalConfig": { + "tolerance": { + "*": [0.75, 1.5], + "*.dev": [0.01, 1.5], + "*.cv": [0.01, 1.5], + "EscapeSequenceParser.benchmark.js.*.averageThroughput.mean": [0.9, 5] + }, + "skip": [ + "*.median", + "*.runs", + "*.dev", + "*.cv", + "EscapeSequenceParser.benchmark.js.*.averageRuntime", + "Terminal.benchmark.js.*.averageRuntime" + ] + } +} diff --git a/addons/xterm-addon-serialize/benchmark/tsconfig.json b/addons/xterm-addon-serialize/benchmark/tsconfig.json new file mode 100644 index 00000000..4e62ed59 --- /dev/null +++ b/addons/xterm-addon-serialize/benchmark/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["dom", "es6"], + "outDir": "../out-benchmark", + "types": ["../../../node_modules/@types/node"], + "moduleResolution": "node", + "strict": false, + "target": "es2015", + "module": "commonjs", + "baseUrl": ".", + "paths": { + "common/*": ["../../../src/common/*"], + "browser/*": ["../../../src/browser/*"], + "public/*": ["../../../src/public/*"], + "Terminal": ["../../../src/Terminal"], + "SerializeAddon": ["../src/SerializeAddon"] + } + }, + "include": ["../**/*", "../../../typings/xterm.d.ts", "../../../out/**/*"], + "exclude": ["../../../**/*test.ts", "../../**/*api.ts"], + "references": [ + { "path": "../../../src/common" }, + { "path": "../../../src/browser" } + ] +} diff --git a/addons/xterm-addon-serialize/package.json b/addons/xterm-addon-serialize/package.json index c8a66c4b..787462f9 100644 --- a/addons/xterm-addon-serialize/package.json +++ b/addons/xterm-addon-serialize/package.json @@ -12,7 +12,10 @@ "build": "../../node_modules/.bin/tsc -p src", "prepackage": "npm run build", "package": "../../node_modules/.bin/webpack", - "prepublishOnly": "npm run package" + "prepublishOnly": "npm run package", + "benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json", + "benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/addons/xterm-addon-serialize/benchmark/*benchmark.js", + "benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/addons/xterm-addon-serialize/benchmark/*benchmark.js" }, "peerDependencies": { "xterm": "^3.14.0" diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.api.ts b/addons/xterm-addon-serialize/src/SerializeAddon.api.ts index 5543696b..a34aa0bd 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.api.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.api.ts @@ -4,7 +4,6 @@ */ import * as puppeteer from 'puppeteer'; -import * as util from 'util'; import { assert } from 'chai'; import { ITerminalOptions } from 'xterm'; @@ -17,90 +16,260 @@ const height = 600; describe('SerializeAddon', () => { before(async function (): Promise { - this.timeout(20000); browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`] }); page = (await browser.pages())[0]; await page.setViewport({ width, height }); - }); - - after(async () => { - await browser.close(); - }); - - beforeEach(async function (): Promise { - this.timeout(20000); await page.goto(APP); - }); - - it('empty content', async function (): Promise { - this.timeout(20000); - const rows = 10; - const cols = 10; - const blankline = ' '.repeat(cols); - const lines = newArray(blankline, rows); - - await openTerminal({ rows: rows, cols: cols, rendererType: 'dom' }); + await openTerminal({ rows: 10, cols: 10, rendererType: 'dom' }); await page.evaluate(` window.serializeAddon = new SerializeAddon(); window.term.loadAddon(window.serializeAddon); `); + }); - assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + after(async () => await browser.close()); + beforeEach(async () => await page.evaluate(`window.term.reset()`)); + + it('empty content', async function (): Promise { + const rows = 10; + const cols = 10; + assert.equal(await page.evaluate(`serializeAddon.serialize();`), ''); + }); + + it('trim last empty lines', async function (): Promise { + const cols = 10; + const lines = [ + '', + '', + digitsString(cols), + digitsString(cols), + '', + '', + digitsString(cols), + digitsString(cols), + '', + '', + '' + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.slice(0, 8).join('\r\n')); }); it('digits content', async function (): Promise { - this.timeout(20000); const rows = 10; const cols = 10; const digitsLine = digitsString(cols); const lines = newArray(digitsLine, rows); - - await openTerminal({ rows: rows, cols: cols, rendererType: 'dom' }); - await page.evaluate(` - window.serializeAddon = new SerializeAddon(); - window.term.loadAddon(window.serializeAddon); - window.term.write(${util.inspect(lines.join('\r\n'))}); - `); - + await writeSync(page, lines.join('\\r\\n')); assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); }); - it('serialize n rows of content', async function (): Promise { - this.timeout(20000); + it('serialize half rows of content', async function (): Promise { const rows = 10; const halfRows = rows >> 1; const cols = 10; const lines = newArray((index: number) => digitsString(cols, index), rows); - - await openTerminal({ rows: rows, cols: cols, rendererType: 'dom' }); - await page.evaluate(` - window.serializeAddon = new SerializeAddon(); - window.term.loadAddon(window.serializeAddon); - window.term.write(${util.inspect(lines.join('\r\n'))}); - `); - + await writeSync(page, lines.join('\\r\\n')); assert.equal(await page.evaluate(`serializeAddon.serialize(${halfRows});`), lines.slice(halfRows, 2 * halfRows).join('\r\n')); }); it('serialize 0 rows of content', async function (): Promise { - this.timeout(20000); const rows = 10; const cols = 10; const lines = newArray((index: number) => digitsString(cols, index), rows); - - await openTerminal({ rows: rows, cols: cols, rendererType: 'dom' }); - await page.evaluate(` - window.serializeAddon = new SerializeAddon(); - window.term.loadAddon(window.serializeAddon); - window.term.write(${util.inspect(lines.join('\r\n'))}); - `); - + await writeSync(page, lines.join('\\r\\n')); assert.equal(await page.evaluate(`serializeAddon.serialize(0);`), ''); }); + + it('serialize all rows of content with color16', async function (): Promise { + const cols = 10; + const color16 = [ + 30, 31, 32, 33, 34, 35, 36, 37, // Set foreground color + 90, 91, 92, 93, 94, 95, 96, 97, + 40, 41, 42, 43, 44, 45, 46, 47, // Set background color + 100, 101, 103, 104, 105, 106, 107 + ]; + const rows = color16.length; + const lines = newArray( + (index: number) => digitsString(cols, index, `\x1b[${color16[index % color16.length]}m`), + rows + ); + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize all rows of content with fg/bg flags', async function (): Promise { + const cols = 10; + const line = '+'.repeat(cols); + const lines: string[] = [ + mkSGR(FG_P16_GREEN) + line, // Workaround: If we clear all flags a the end, serialize will use \x1b[0m to clear instead of the sepcific disable sequence + mkSGR(INVERSE) + line, + mkSGR(BOLD) + line, + mkSGR(UNDERLINED) + line, + mkSGR(BLINK) + line, + mkSGR(INVISIBLE) + line, + mkSGR(NO_INVERSE) + line, + mkSGR(NO_BOLD) + line, + mkSGR(NO_UNDERLINED) + line, + mkSGR(NO_BLINK) + line, + mkSGR(NO_INVISIBLE) + line + ]; + const rows = lines.length; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize all rows of content with color256', async function (): Promise { + const rows = 32; + const cols = 10; + const lines = newArray( + (index: number) => digitsString(cols, index, `\x1b[38;5;${16 + index}m`), + rows + ); + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize all rows of content with color16 and style separately', async function (): Promise { + const cols = 10; + const line = '+'.repeat(cols); + const lines: string[] = [ + mkSGR(FG_P16_RED) + line, // fg Red, + mkSGR(UNDERLINED) + line, // fg Red, Underlined + mkSGR(FG_P16_GREEN) + line, // fg Green, Underlined + mkSGR(INVERSE) + line, // fg Green, Underlined, Inverse + mkSGR(NO_INVERSE) + line, // fg Green, Underlined + mkSGR(INVERSE) + line, // fg Green, Underlined, Inverse + mkSGR(BG_P16_YELLOW) + line, // fg Green, bg Yellow, Underlined, Inverse + mkSGR(FG_RESET) + line, // bg Yellow, Underlined, Inverse + mkSGR(BG_RESET) + line, // Underlined, Inverse + mkSGR(NORMAL) + line // Back to normal + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize all rows of content with color16 and style together', async function (): Promise { + const cols = 10; + const line = '+'.repeat(cols); + const lines: string[] = [ + mkSGR(FG_P16_RED) + line, // fg Red + mkSGR(FG_P16_GREEN, BG_P16_YELLOW) + line, // fg Green, bg Yellow + mkSGR(UNDERLINED, ITALIC) + line, // fg Green, bg Yellow, Underlined, Italic + mkSGR(NO_UNDERLINED, NO_ITALIC) + line, // fg Green, bg Yellow + mkSGR(FG_RESET, ITALIC) + line, // bg Yellow, Italic + mkSGR(BG_RESET) + line, // Italic + mkSGR(NORMAL) + line, // Back to normal + mkSGR(FG_P16_RED) + line, // fg Red + mkSGR(FG_P16_GREEN, BG_P16_YELLOW) + line, // fg Green, bg Yellow + mkSGR(UNDERLINED, ITALIC) + line, // fg Green, bg Yellow, Underlined, Italic + mkSGR(NO_UNDERLINED, NO_ITALIC) + line, // fg Green, bg Yellow + mkSGR(FG_RESET, ITALIC) + line, // bg Yellow, Italic + mkSGR(BG_RESET) + line // Italic + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize all rows of content with color256 and style separately', async function (): Promise { + const cols = 10; + const line = '+'.repeat(cols); + const lines: string[] = [ + mkSGR(FG_P256_RED) + line, // fg Red 256, + mkSGR(UNDERLINED) + line, // fg Red 256, Underlined + mkSGR(FG_P256_GREEN) + line, // fg Green 256, Underlined + mkSGR(INVERSE) + line, // fg Green 256, Underlined, Inverse + mkSGR(NO_INVERSE) + line, // fg Green 256, Underlined + mkSGR(INVERSE) + line, // fg Green 256, Underlined, Inverse + mkSGR(BG_P256_YELLOW) + line, // fg Green 256, bg Yellow 256, Underlined, Inverse + mkSGR(FG_RESET) + line, // bg Yellow 256, Underlined, Inverse + mkSGR(BG_RESET) + line, // Underlined, Inverse + mkSGR(NORMAL) + line // Back to normal + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize all rows of content with color256 and style together', async function (): Promise { + const cols = 10; + const line = '+'.repeat(cols); + const lines: string[] = [ + mkSGR(FG_P256_RED) + line, // fg Red 256 + mkSGR(FG_P256_GREEN, BG_P256_YELLOW) + line, // fg Green 256, bg Yellow 256 + mkSGR(UNDERLINED, ITALIC) + line, // fg Green 256, bg Yellow 256, Underlined, Italic + mkSGR(NO_UNDERLINED, NO_ITALIC) + line, // fg Green 256, bg Yellow 256 + mkSGR(FG_RESET, ITALIC) + line, // bg Yellow 256, Italic + mkSGR(BG_RESET) + line, // Italic + mkSGR(NORMAL) + line, // Back to normal + mkSGR(FG_P256_RED) + line, // fg Red 256 + mkSGR(FG_P256_GREEN, BG_P256_YELLOW) + line, // fg Green 256, bg Yellow 256 + mkSGR(UNDERLINED, ITALIC) + line, // fg Green 256, bg Yellow 256, Underlined, Italic + mkSGR(NO_UNDERLINED, NO_ITALIC) + line, // fg Green 256, bg Yellow 256 + mkSGR(FG_RESET, ITALIC) + line, // bg Yellow 256, Italic + mkSGR(BG_RESET) + line // Italic + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize all rows of content with colorRGB and style separately', async function (): Promise { + const cols = 10; + const line = '+'.repeat(cols); + const lines: string[] = [ + mkSGR(FG_RGB_RED) + line, // fg Red RGB, + mkSGR(UNDERLINED) + line, // fg Red RGB, Underlined + mkSGR(FG_RGB_GREEN) + line, // fg Green RGB, Underlined + mkSGR(INVERSE) + line, // fg Green RGB, Underlined, Inverse + mkSGR(NO_INVERSE) + line, // fg Green RGB, Underlined + mkSGR(INVERSE) + line, // fg Green RGB, Underlined, Inverse + mkSGR(BG_RGB_YELLOW) + line, // fg Green RGB, bg Yellow RGB, Underlined, Inverse + mkSGR(FG_RESET) + line, // bg Yellow RGB, Underlined, Inverse + mkSGR(BG_RESET) + line, // Underlined, Inverse + mkSGR(NORMAL) + line // Back to normal + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize all rows of content with colorRGB and style together', async function (): Promise { + const cols = 10; + const line = '+'.repeat(cols); + const lines: string[] = [ + mkSGR(FG_RGB_RED) + line, // fg Red RGB + mkSGR(FG_RGB_GREEN, BG_RGB_YELLOW) + line, // fg Green RGB, bg Yellow RGB + mkSGR(UNDERLINED, ITALIC) + line, // fg Green RGB, bg Yellow RGB, Underlined, Italic + mkSGR(NO_UNDERLINED, NO_ITALIC) + line, // fg Green RGB, bg Yellow RGB + mkSGR(FG_RESET, ITALIC) + line, // bg Yellow RGB, Italic + mkSGR(BG_RESET) + line, // Italic + mkSGR(NORMAL) + line, // Back to normal + mkSGR(FG_RGB_RED) + line, // fg Red RGB + mkSGR(FG_RGB_GREEN, BG_RGB_YELLOW) + line, // fg Green RGB, bg Yellow RGB + mkSGR(UNDERLINED, ITALIC) + line, // fg Green RGB, bg Yellow RGB, Underlined, Italic + mkSGR(NO_UNDERLINED, NO_ITALIC) + line, // fg Green RGB, bg Yellow RGB + mkSGR(FG_RESET, ITALIC) + line, // bg Yellow RGB, Italic + mkSGR(BG_RESET) + line // Italic + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + it('serialize tabs correctly', async () => { + const lines = [ + 'a\tb', + 'aa\tc', + 'aaa\td' + ]; + const expected = [ + 'a\x1b[7Cb', + 'aa\x1b[6Cc', + 'aaa\x1b[5Cd' + ]; + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), expected.join('\r\n')); + }); }); async function openTerminal(options: ITerminalOptions = {}): Promise { @@ -125,10 +294,77 @@ function newArray(initial: T | ((index: number) => T), count: number): T[] { return array; } -function digitsString(length: number, from: number = 0): string { - let s = ''; +function digitsString(length: number, from: number = 0, sgr: string = ''): string { + let s = sgr; for (let i = 0; i < length; i++) { - s += (from++) % 10; + s += `${(from++) % 10}`; } return s; } + +function mkSGR(...seq: string[]): string { + return `\x1b[${seq.join(';')}m`; +} + +const NORMAL = '0'; + +const FG_P16_RED = '31'; +const FG_P16_GREEN = '32'; +const FG_P16_YELLOW = '33'; +const FG_P256_RED = '38;5;196'; +const FG_P256_GREEN = '38;5;46'; +const FG_P256_YELLOW = '38;5;226'; +const FG_RGB_RED = '38;2;255;0;0'; +const FG_RGB_GREEN = '38;2;0;255;0'; +const FG_RGB_YELLOW = '38;2;255;255;0'; +const FG_RESET = '39'; + + +const BG_P16_RED = '41'; +const BG_P16_GREEN = '42'; +const BG_P16_YELLOW = '43'; +const BG_P256_RED = '48;5;196'; +const BG_P256_GREEN = '48;5;46'; +const BG_P256_YELLOW = '48;5;226'; +const BG_RGB_RED = '48;2;255;0;0'; +const BG_RGB_GREEN = '48;2;0;255;0'; +const BG_RGB_YELLOW = '48;2;255;255;0'; +const BG_RESET = '49'; + +const INVERSE = '7'; +const BOLD = '1'; +const UNDERLINED = '4'; +const BLINK = '5'; +const INVISIBLE = '8'; + +const NO_INVERSE = '27'; +const NO_BOLD = '22'; +const NO_UNDERLINED = '24'; +const NO_BLINK = '25'; +const NO_INVISIBLE = '28'; + +const ITALIC = '3'; +const DIM = '2'; + +const NO_ITALIC = '23'; +const NO_DIM = '22'; + +async function writeSync(page: puppeteer.Page, data: string): Promise { + await page.evaluate(` + window.ready = false; + window.term.write('${data}', () => window.ready = true); + `); + await pollFor(page, 'window.ready', true); +} + +async function pollFor(page: puppeteer.Page, fn: string, val: any, preFn?: () => Promise): Promise { + if (preFn) { + await preFn(); + } + const result = await page.evaluate(fn); + if (result !== val) { + return new Promise(r => { + setTimeout(() => r(pollFor(page, fn, val, preFn)), 10); + }); + } +} diff --git a/addons/xterm-addon-serialize/src/SerializeAddon.ts b/addons/xterm-addon-serialize/src/SerializeAddon.ts index 9328e674..1d011726 100644 --- a/addons/xterm-addon-serialize/src/SerializeAddon.ts +++ b/addons/xterm-addon-serialize/src/SerializeAddon.ts @@ -1,12 +1,161 @@ /** * Copyright (c) 2019 The xterm.js authors. All rights reserved. * @license MIT + * + * (EXPERIMENTAL) This Addon is still under development */ -import { Terminal, ITerminalAddon } from 'xterm'; +import { Terminal, ITerminalAddon, IBuffer, IBufferCell } from 'xterm'; -function crop(value: number, from: number, to: number): number { - return Math.max(from, Math.min(value, to)); +function constrain(value: number, low: number, high: number): number { + return Math.max(low, Math.min(value, high)); +} + +// TODO: Refine this template class later +abstract class BaseSerializeHandler { + constructor(private _buffer: IBuffer) { } + + serialize(startRow: number, endRow: number): string { + // we need two of them to flip between old and new cell + const cell1 = this._buffer.getNullCell(); + const cell2 = this._buffer.getNullCell(); + let oldCell = cell1; + + this._beforeSerialize(endRow - startRow); + + for (let row = startRow; row < endRow; row++) { + const line = this._buffer.getLine(row); + if (line) { + for (let col = 0; col < line.length; col++) { + const c = line.getCell(col, oldCell === cell1 ? cell2 : cell1); + if (!c) { + console.warn(`Can't get cell at row=${row}, col=${col}`); + continue; + } + this._nextCell(c, oldCell, row, col); + oldCell = c; + } + } + this._rowEnd(row); + } + + this._afterSerialize(); + + return this._serializeString(); + } + + protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { } + protected _rowEnd(row: number): void { } + protected _beforeSerialize(rows: number): void { } + protected _afterSerialize(): void { } + protected _serializeString(): string { return ''; } +} + +function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean { + return cell1.getFgColorMode() === cell2.getFgColorMode() + && cell1.getFgColor() === cell2.getFgColor(); +} + +function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean { + return cell1.getBgColorMode() === cell2.getBgColorMode() + && cell1.getBgColor() === cell2.getBgColor(); +} + +function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean { + return cell1.isInverse() === cell2.isInverse() + && cell1.isBold() === cell2.isBold() + && cell1.isUnderline() === cell2.isUnderline() + && cell1.isBlink() === cell2.isBlink() + && cell1.isInvisible() === cell2.isInvisible() + && cell1.isItalic() === cell2.isItalic() + && cell1.isDim() === cell2.isDim(); +} + +class StringSerializeHandler extends BaseSerializeHandler { + private _rowIndex: number = 0; + private _allRows: string[] = new Array(); + private _currentRow: string = ''; + private _nullCellCount: number = 0; + + constructor(buffer: IBuffer) { + super(buffer); + } + + protected _beforeSerialize(rows: number): void { + this._allRows = new Array(rows); + } + + protected _rowEnd(row: number): void { + this._allRows[this._rowIndex++] = this._currentRow; + this._currentRow = ''; + this._nullCellCount = 0; + } + + protected _nextCell(cell: IBufferCell, oldCell: IBufferCell, row: number, col: number): void { + const sgrSeq: number[] = []; + const fgChanged = !equalFg(cell, oldCell); + const bgChanged = !equalBg(cell, oldCell); + const flagsChanged = !equalFlags(cell, oldCell); + + if (fgChanged || bgChanged || flagsChanged) { + if (cell.isAttributeDefault()) { + this._currentRow += '\x1b[0m'; + } else { + if (fgChanged) { + const color = cell.getFgColor(); + if (cell.isFgRGB()) { sgrSeq.push(38, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); } + else if (cell.isFgPalette()) { + if (color >= 16) { sgrSeq.push(38, 5, color); } + else { sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7)); } + } + else { sgrSeq.push(39); } + } + if (bgChanged) { + const color = cell.getBgColor(); + if (cell.isBgRGB()) { sgrSeq.push(48, 2, (color >>> 16) & 0xFF, (color >>> 8) & 0xFF, color & 0xFF); } + else if (cell.isBgPalette()) { + if (color >= 16) { sgrSeq.push(48, 5, color); } + else { sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7)); } + } + else { sgrSeq.push(49); } + } + if (flagsChanged) { + if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } + if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); } + if (cell.isUnderline() !== oldCell.isUnderline()) { sgrSeq.push(cell.isUnderline() ? 4 : 24); } + if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } + if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } + if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } + if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); } + } + } + } + + if (sgrSeq.length) { + this._currentRow += `\x1b[${sgrSeq.join(';')}m`; + } + + // Count number of null cells encountered after the last non-null cell and move the cursor + // if a non-null cell is found (eg. \t or cursor move) + if (cell.getChars() === '') { + this._nullCellCount++; + } else if (this._nullCellCount > 0) { + this._currentRow += `\x1b[${this._nullCellCount}C`; + this._nullCellCount = 0; + } + + this._currentRow += cell.getChars(); + } + + protected _serializeString(): string { + let rowEnd = this._allRows.length; + for (; rowEnd > 0; rowEnd--) { + if (this._allRows[rowEnd - 1]) { + break; + } + } + return this._allRows.slice(0, rowEnd).join('\r\n'); + } } export class SerializeAddon implements ITerminalAddon { @@ -19,25 +168,19 @@ export class SerializeAddon implements ITerminalAddon { } public serialize(rows?: number): string { - // TODO: Add frontground/background color support later + // TODO: Add re-position cursor support + // TODO: Add word wrap mode support + // TODO: Add combinedData support if (!this._terminal) { throw new Error('Cannot use addon until it has been loaded'); } - const terminalRows = this._terminal.rows; - if (rows === undefined) { - rows = terminalRows; - } - rows = crop(rows, 0, terminalRows); - const buffer = this._terminal.buffer; - const lines: string[] = new Array(rows); + const maxRows = this._terminal.buffer.length; + const handler = new StringSerializeHandler(this._terminal.buffer); - for (let i = terminalRows - rows; i < terminalRows; i++) { - const line = buffer.getLine(i); - lines[i - terminalRows + rows] = line ? line.translateToString() : ''; - } + rows = (rows === undefined) ? maxRows : constrain(rows, 0, maxRows); - return lines.join('\r\n'); + return handler.serialize(maxRows - rows, maxRows); } public dispose(): void { } diff --git a/addons/xterm-addon-serialize/src/tsconfig.json b/addons/xterm-addon-serialize/src/tsconfig.json index 5539aa56..57f3d6ed 100644 --- a/addons/xterm-addon-serialize/src/tsconfig.json +++ b/addons/xterm-addon-serialize/src/tsconfig.json @@ -10,10 +10,17 @@ "outDir": "../out", "sourceMap": true, "removeComments": true, + "baseUrl": ".", + "paths": { + "common/*": [ "../../../src/common/*" ] + }, "strict": true }, "include": [ "./**/*", "../../../typings/xterm.d.ts" + ], + "references": [ + { "path": "../../../src/common" } ] } diff --git a/addons/xterm-addon-webgl/src/GlyphRenderer.ts b/addons/xterm-addon-webgl/src/GlyphRenderer.ts index 332abeb8..5c5d5970 100644 --- a/addons/xterm-addon-webgl/src/GlyphRenderer.ts +++ b/addons/xterm-addon-webgl/src/GlyphRenderer.ts @@ -283,8 +283,8 @@ export class GlyphRenderer { if (!line) { line = terminal.buffer.getLine(row); } - const chars = line!.getCell(x)!.char; - this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], bg, fg, chars); + const chars = line!.getCell(x)!.getChars(); + this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], bg, model.cells[offset + RENDER_MODEL_FG_OFFSET], chars); } else { this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], bg, fg); } diff --git a/demo/client.ts b/demo/client.ts index a7375fac..1a14a0df 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -12,15 +12,16 @@ import { Terminal } from '../out/public/Terminal'; import { AttachAddon } from '../addons/xterm-addon-attach/out/AttachAddon'; import { FitAddon } from '../addons/xterm-addon-fit/out/FitAddon'; import { SearchAddon, ISearchOptions } from '../addons/xterm-addon-search/out/SearchAddon'; +import { SerializeAddon } from '../addons/xterm-addon-serialize/out/SerializeAddon'; import { WebLinksAddon } from '../addons/xterm-addon-web-links/out/WebLinksAddon'; import { WebglAddon } from '../addons/xterm-addon-webgl/out/WebglAddon'; -import { SerializeAddon } from '../addons/xterm-addon-serialize/out/SerializeAddon'; // Use webpacked version (yarn package) // import { Terminal } from '../lib/xterm'; // import { AttachAddon } from 'xterm-addon-attach'; // import { FitAddon } from 'xterm-addon-fit'; // import { SearchAddon, ISearchOptions } from 'xterm-addon-search'; +// import { SerializeAddon } from 'xterm-addon-serialize'; // import { WebLinksAddon } from 'xterm-addon-web-links'; // import { WebglAddon } from 'xterm-addon-webgl'; @@ -34,9 +35,9 @@ export interface IWindowWithTerminal extends Window { AttachAddon?: typeof AttachAddon; FitAddon?: typeof FitAddon; SearchAddon?: typeof SearchAddon; + SerializeAddon?: typeof SerializeAddon; WebLinksAddon?: typeof WebLinksAddon; WebglAddon?: typeof WebglAddon; - SerializeAddon?: typeof SerializeAddon; } declare let window: IWindowWithTerminal; @@ -46,7 +47,7 @@ let socketURL; let socket; let pid; -type AddonType = 'attach' | 'fit' | 'search' | 'web-links' | 'webgl'; +type AddonType = 'attach' | 'fit' | 'search' | 'serialize' | 'web-links' | 'webgl'; interface IDemoAddon { name: T; @@ -55,12 +56,14 @@ interface IDemoAddon { T extends 'attach' ? typeof AttachAddon : T extends 'fit' ? typeof FitAddon : T extends 'search' ? typeof SearchAddon : + T extends 'serialize' ? typeof SerializeAddon : T extends 'web-links' ? typeof WebLinksAddon : typeof WebglAddon; instance?: T extends 'attach' ? AttachAddon : T extends 'fit' ? FitAddon : T extends 'search' ? SearchAddon : + T extends 'serialize' ? SerializeAddon : T extends 'web-links' ? WebLinksAddon : T extends 'webgl' ? WebglAddon : never; @@ -70,6 +73,7 @@ const addons: { [T in AddonType]: IDemoAddon} = { attach: { name: 'attach', ctor: AttachAddon, canChange: false }, fit: { name: 'fit', ctor: FitAddon, canChange: false }, search: { name: 'search', ctor: SearchAddon, canChange: true }, + serialize: { name: 'serialize', ctor: SerializeAddon, canChange: true }, 'web-links': { name: 'web-links', ctor: WebLinksAddon, canChange: true }, webgl: { name: 'webgl', ctor: WebglAddon, canChange: true } }; @@ -121,6 +125,7 @@ if (document.location.pathname === '/test') { } else { createTerminal(); document.getElementById('dispose').addEventListener('click', disposeRecreateButtonHandler); + document.getElementById('serialize').addEventListener('click', serializeButtonHandler); } function createTerminal(): void { @@ -136,12 +141,14 @@ function createTerminal(): void { // Load addons const typedTerm = term as TerminalType; - addons['web-links'].instance = new WebLinksAddon(); addons.search.instance = new SearchAddon(); + addons.serialize.instance = new SerializeAddon(); addons.fit.instance = new FitAddon(); - typedTerm.loadAddon(addons['web-links'].instance); - typedTerm.loadAddon(addons.search.instance); + addons['web-links'].instance = new WebLinksAddon(); typedTerm.loadAddon(addons.fit.instance); + typedTerm.loadAddon(addons.search.instance); + typedTerm.loadAddon(addons.serialize.instance); + typedTerm.loadAddon(addons['web-links'].instance); window.term = term; // Expose `term` to window for debugging purposes term.onResize((size: { cols: number, rows: number }) => { @@ -388,3 +395,14 @@ function updateTerminalSize(): void { terminalContainer.style.height = height; addons.fit.instance.fit(); } + +function serializeButtonHandler(): void { + const output = addons.serialize.instance.serialize(); + const outputString = JSON.stringify(output); + + document.getElementById('serialize-output').innerText = outputString; + if ((document.getElementById('write-to-terminal') as HTMLInputElement).checked) { + term.reset(); + term.write(output); + } +} diff --git a/demo/index.html b/demo/index.html index 88c6bc4c..4d5149f3 100644 --- a/demo/index.html +++ b/demo/index.html @@ -11,16 +11,6 @@

xterm.js: A terminal for the web

-
-

Actions

-

- - - - - -

-

Options

These options can be set in the Terminal constructor or using the Terminal.setOption function.

@@ -30,6 +20,21 @@

Addons

Addons can be loaded and unloaded on a particular terminal to extend its functionality.

+

Addons Control

+

SearchAddon

+

+ + + + + +

+

SerializeAddon

+

+ + +

+

Style

diff --git a/demo/style.css b/demo/style.css index b061dfcb..9c5fd0bd 100644 --- a/demo/style.css +++ b/demo/style.css @@ -30,3 +30,14 @@ p { padding-left: 20px; vertical-align: top; } + +pre { + display: block; + padding: 9.5px; + font-size: 13px; + color: #c7254e; + background-color: #f9f2f4; + word-break: break-all; + word-wrap: break-word; + white-space: pre-wrap; +} diff --git a/demo/tsconfig.json b/demo/tsconfig.json index 7a53302d..4e1b5035 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -9,6 +9,7 @@ "xterm-addon-attach": ["../addons/xterm-addon-attach"], "xterm-addon-fit": ["../addons/xterm-addon-fit"], "xterm-addon-search": ["../addons/xterm-addon-search"], + "xterm-addon-serialize": ["../addons/xterm-addon-serialize"], "xterm-addon-web-links": ["../addons/xterm-addon-web-links"], "xterm-addon-webgl": ["../addons/xterm-addon-webgl"] } diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index fdb5eb73..5b3834c9 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -93,6 +93,7 @@ export interface IAttributeData { isBgPalette(): boolean; isFgDefault(): boolean; isBgDefault(): boolean; + isAttributeDefault(): boolean; // colors getFgColor(): number; diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index 0e7e2705..0b2679ee 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -47,6 +47,7 @@ export class AttributeData implements IAttributeData { public isBgPalette(): boolean { return (this.bg & Attributes.CM_MASK) === Attributes.CM_P16 || (this.bg & Attributes.CM_MASK) === Attributes.CM_P256; } public isFgDefault(): boolean { return (this.fg & Attributes.CM_MASK) === 0; } public isBgDefault(): boolean { return (this.bg & Attributes.CM_MASK) === 0; } + public isAttributeDefault(): boolean { return this.fg === 0 && this.bg === 0; } // colors public getFgColor(): number { diff --git a/src/public/Terminal.ts b/src/public/Terminal.ts index 96e13f63..40ba6293 100644 --- a/src/public/Terminal.ts +++ b/src/public/Terminal.ts @@ -5,8 +5,9 @@ import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBuffer as IBufferApi, IBufferLine as IBufferLineApi, IBufferCell as IBufferCellApi, IParser, IFunctionIdentifier } from 'xterm'; import { ITerminal } from '../Types'; -import { IBufferLine } from 'common/Types'; +import { IBufferLine, ICellData } from 'common/Types'; import { IBuffer } from 'common/buffer/Types'; +import { CellData } from 'common/buffer/CellData'; import { Terminal as TerminalCore } from '../Terminal'; import * as Strings from '../browser/LocalizableStrings'; import { IEvent } from 'common/EventEmitter'; @@ -190,7 +191,7 @@ export class Terminal implements ITerminalApi { } class BufferApiView implements IBufferApi { - constructor(private _buffer: IBuffer) {} + constructor(private _buffer: IBuffer) { } public get cursorY(): number { return this._buffer.y; } public get cursorX(): number { return this._buffer.x; } @@ -204,29 +205,30 @@ class BufferApiView implements IBufferApi { } return new BufferLineApiView(line); } + public getNullCell(): IBufferCellApi { return new CellData(); } } class BufferLineApiView implements IBufferLineApi { - constructor(private _line: IBufferLine) {} + constructor(private _line: IBufferLine) { } public get isWrapped(): boolean { return this._line.isWrapped; } - public getCell(x: number): IBufferCellApi | undefined { + public get length(): number { return this._line.length; } + public getCell(x: number, cell?: IBufferCellApi): IBufferCellApi | undefined { if (x < 0 || x >= this._line.length) { return undefined; } - return new BufferCellApiView(this._line, x); + + if (cell) { + this._line.loadCell(x, cell); + return cell; + } + return this._line.loadCell(x, new CellData()); } public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string { return this._line.translateToString(trimRight, startColumn, endColumn); } } -class BufferCellApiView implements IBufferCellApi { - constructor(private _line: IBufferLine, private _x: number) {} - public get char(): string { return this._line.getString(this._x); } - public get width(): number { return this._line.getWidth(this._x); } -} - class ParserApi implements IParser { constructor(private _core: ITerminal) {} diff --git a/test/benchmark/tsconfig.json b/test/benchmark/tsconfig.json index 8b93dcb1..51abeba5 100644 --- a/test/benchmark/tsconfig.json +++ b/test/benchmark/tsconfig.json @@ -16,7 +16,9 @@ "paths": { "common/*": [ "../../src/common/*" ], "browser/*": [ "../../src/browser/*" ], - "Terminal": [ "../../src/Terminal" ] + "addons/xterm-addon-serialize/src/*": ["../../addons/xterm-addon-serialize/src/*"], + "public/*": ["../../src/public/*"], + "Terminal": ["../../src/Terminal"] }, }, "include": [ @@ -31,4 +33,4 @@ { "path": "../../src/common" }, { "path": "../../src/browser" }, ] -} \ No newline at end of file +} diff --git a/tsconfig.all.json b/tsconfig.all.json index 5a7ae05b..60cb9164 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -10,6 +10,7 @@ { "path": "./addons/xterm-addon-search/src" }, { "path": "./addons/xterm-addon-web-links/src" }, { "path": "./addons/xterm-addon-webgl/src" }, - { "path": "./addons/xterm-addon-serialize/src" } + { "path": "./addons/xterm-addon-serialize/src" }, + { "path": "./addons/xterm-addon-serialize/benchmark" } ] } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index e848c547..3e6d6223 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -971,6 +971,13 @@ declare module 'xterm' { * @param y The line index to get. */ getLine(y: number): IBufferLine | undefined; + + /** + * Creates an empty cell object suitable as a cell reference in + * `line.getCell(x, cell)`. Use this to avoid costly recreation of + * cell objects when dealing with tons of cells. + */ + getNullCell(): IBufferCell; } /** @@ -981,6 +988,7 @@ declare module 'xterm' { * Whether the line is wrapped from the previous line. */ readonly isWrapped: boolean; + readonly length: number; /** * Gets a cell from the line, or undefined if the line index does not exist. @@ -990,8 +998,9 @@ declare module 'xterm' { * behavior. * * @param x The character index to get. + * @param cell Optional cell object to load data into. */ - getCell(x: number): IBufferCell | undefined; + getCell(x: number, cell?: IBufferCell): IBufferCell | undefined; /** * Gets the line as a string. Note that this is gets only the string for the @@ -1008,19 +1017,30 @@ declare module 'xterm' { * Represents a single cell in the terminal's buffer. */ interface IBufferCell { - /** - * The character within the cell. - */ - readonly char: string; + getWidth(): number; + getChars(): string; + getCode(): number; - /** - * The width of the character. Some examples: - * - * - This is `1` for most cells. - * - This is `2` for wide character like CJK glyphs. - * - This is `0` for cells immediately following cells with a width of `2`. - */ - readonly width: number; + getFgColorMode(): number; + getBgColorMode(): number; + getFgColor(): number; + getBgColor(): number; + + isInverse(): number; + isBold(): number; + isUnderline(): number; + isBlink(): number; + isInvisible(): number; + isItalic(): number; + isDim(): number; + + isFgRGB(): boolean; + isBgRGB(): boolean; + isFgPalette(): boolean; + isBgPalette(): boolean; + isAttributeDefault(): boolean; + isFgDefault(): boolean; + isBgDefault(): boolean; } /**