diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..dccd0c5df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question + url: https://stackoverflow.com/questions/tagged/xtermjs + about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index 1fe5c763a..000000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: Question -about: The issue tracker is not for questions. Please ask questions on https://stackoverflow.com/questions/tagged/xtermjs ---- - -🛑 The issue tracker is not for questions 🛑 - -If you have a question, please ask it on https://stackoverflow.com/questions/tagged/xtermjs. diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c8a2dc2e..20e4915ca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "typescript.preferences.quoteStyle": "single" + "typescript.preferences.quoteStyle": "single", + "typescript.preferences.importModuleSpecifier": "non-relative" } diff --git a/README.md b/README.md index 5cf2848f5..d78dcc725 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Xterm.js is a front-end component written in TypeScript that lets applications b - **Performant**: Xterm.js is *really* fast, it even includes a GPU-accelerated renderer. - **Rich unicode support**: Supports CJK, emojis and IMEs. - **Self-contained**: Requires zero dependencies to work. -- **Accessible**: Screen reader support can be turned on using the `screenReaderMode` option. +- **Accessible**: Screen reader and minimum contrast ratio support can be turned on - **And much more**: Links, theming, addons, well documented API, etc. ## What xterm.js is not @@ -110,7 +110,7 @@ Xterm.js is used in several world-class applications to provide great terminal e - [**WebSSH2**](https://github.com/billchurch/WebSSH2): A web based SSH2 client using xterm.js, socket.io, and ssh2. - [**Spyder Terminal**](https://github.com/spyder-ide/spyder-terminal): A full fledged system terminal embedded on Spyder IDE. - [**Cloud Commander**](https://cloudcmd.io "Cloud Commander"): Orthodox web file manager with console and editor. -- [**Codevolve**](https://www.codevolve.com "Codevolve"): Online platform for interactive coding and web development courses. Live container-backed terminal uses xterm.js. +- [**Next Tech**](https://next.tech "Next Tech"): Online platform for interactive coding and web development courses. Live container-backed terminal uses xterm.js. - [**RStudio**](https://www.rstudio.com/products/RStudio "RStudio"): RStudio is an integrated development environment (IDE) for R. - [**Terminal for Atom**](https://github.com/jsmecham/atom-terminal-tab): A simple terminal for the Atom text editor. - [**Eclipse Orion**](https://orionhub.org): A modern, open source software development environment that runs in the cloud. Code, deploy and run in the cloud. @@ -156,6 +156,9 @@ Xterm.js is used in several world-class applications to provide great terminal e - [**tty-share**](https://tty-share.com): Extremely simple terminal sharing over the Internet. - [**Ten Hands**](https://github.com/saisandeepvaddi/ten-hands): One place to run your command-line tasks. - [**WebAssembly.sh**](https://webassembly.sh): A WebAssembly WASI browser terminal +- [**Gus**](https://gus.jp): A shared coding pad where you can run Python with xterm.js +- [**Linode**](https://linode.com): Linode uses xterm.js to provide users a web console for their Linode instances. +- [**FluffOS**](https://www.fluffos.info): Active maintained LPMUD driver with websocket support. [And much more...](https://github.com/xtermjs/xterm.js/network/dependents) diff --git a/addons/xterm-addon-attach/package.json b/addons/xterm-addon-attach/package.json index 5718f356a..dd134308a 100644 --- a/addons/xterm-addon-attach/package.json +++ b/addons/xterm-addon-attach/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-attach", - "version": "0.3.0", + "version": "0.4.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-attach/src/AttachAddon.api.ts b/addons/xterm-addon-attach/src/AttachAddon.api.ts index 94d11139b..264b32ee1 100644 --- a/addons/xterm-addon-attach/src/AttachAddon.api.ts +++ b/addons/xterm-addon-attach/src/AttachAddon.api.ts @@ -20,7 +20,6 @@ describe('AttachAddon', () => { this.timeout(20000); browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; @@ -43,7 +42,7 @@ describe('AttachAddon', () => { const server = new WebSocket.Server({ port }); server.on('connection', socket => socket.send('foo')); await page.evaluate(`window.term.loadAddon(new window.AttachAddon(new WebSocket('ws://localhost:${port}')))`); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foo'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foo'); server.close(); }); @@ -55,7 +54,7 @@ describe('AttachAddon', () => { const data = new Uint8Array([102, 111, 111]); server.on('connection', socket => socket.send(data)); await page.evaluate(`window.term.loadAddon(new window.AttachAddon(new WebSocket('ws://localhost:${port}')))`); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foo'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foo'); server.close(); }); }); @@ -69,3 +68,12 @@ async function openTerminal(options: ITerminalOptions = {}): Promise { await page.waitForSelector('.xterm-text-layer'); } } + +async function pollFor(page: puppeteer.Page, fn: string, val: any): Promise { + const result = await page.evaluate(fn); + if (result !== val) { + return new Promise(r => { + setTimeout(() => r(pollFor(page, fn, val)), 10); + }); + } +} diff --git a/addons/xterm-addon-attach/src/AttachAddon.ts b/addons/xterm-addon-attach/src/AttachAddon.ts index 117b2b584..279d1b2ee 100644 --- a/addons/xterm-addon-attach/src/AttachAddon.ts +++ b/addons/xterm-addon-attach/src/AttachAddon.ts @@ -33,6 +33,7 @@ export class AttachAddon implements ITerminalAddon { if (this._bidirectional) { this._disposables.push(terminal.onData(data => this._sendData(data))); + this._disposables.push(terminal.onBinary(data => this._sendBinary(data))); } this._disposables.push(addSocketListener(this._socket, 'close', () => this.dispose())); @@ -51,6 +52,17 @@ export class AttachAddon implements ITerminalAddon { } this._socket.send(data); } + + private _sendBinary(data: string): void { + if (this._socket.readyState !== 1) { + return; + } + const buffer = new Uint8Array(data.length); + for (let i = 0; i < data.length; ++i) { + buffer[i] = data.charCodeAt(i) & 255; + } + this._socket.send(buffer); + } } function addSocketListener(socket: WebSocket, type: K, handler: (this: WebSocket, ev: WebSocketEventMap[K]) => any): IDisposable { diff --git a/addons/xterm-addon-attach/src/tsconfig.json b/addons/xterm-addon-attach/src/tsconfig.json index d875aa530..5539aa564 100644 --- a/addons/xterm-addon-attach/src/tsconfig.json +++ b/addons/xterm-addon-attach/src/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "commonjs", - "target": "es2015", + "target": "es5", "lib": [ "dom", "es2015" diff --git a/addons/xterm-addon-fit/src/FitAddon.api.ts b/addons/xterm-addon-fit/src/FitAddon.api.ts index 4f9558684..aa73a9b61 100644 --- a/addons/xterm-addon-fit/src/FitAddon.api.ts +++ b/addons/xterm-addon-fit/src/FitAddon.api.ts @@ -19,7 +19,6 @@ describe('FitAddon', () => { this.timeout(20000); browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; diff --git a/addons/xterm-addon-search/package.json b/addons/xterm-addon-search/package.json index c426ee3b6..d66e8a687 100644 --- a/addons/xterm-addon-search/package.json +++ b/addons/xterm-addon-search/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-search", - "version": "0.3.0", + "version": "0.4.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-search/src/SearchAddon.api.ts b/addons/xterm-addon-search/src/SearchAddon.api.ts index ff195f5e9..72a69b5fb 100644 --- a/addons/xterm-addon-search/src/SearchAddon.api.ts +++ b/addons/xterm-addon-search/src/SearchAddon.api.ts @@ -22,7 +22,6 @@ describe('Search Tests', function (): void { before(async function (): Promise { browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts index db2968e69..183687ac1 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 new file mode 100644 index 000000000..03c051b3c --- /dev/null +++ b/addons/xterm-addon-serialize/.gitignore @@ -0,0 +1,3 @@ +lib +node_modules +out-benchmark diff --git a/addons/xterm-addon-serialize/.npmignore b/addons/xterm-addon-serialize/.npmignore new file mode 100644 index 000000000..1c7944456 --- /dev/null +++ b/addons/xterm-addon-serialize/.npmignore @@ -0,0 +1,5 @@ +**/*.api.js +**/*.api.ts +tsconfig.json +.yarnrc +webpack.config.js diff --git a/addons/xterm-addon-serialize/README.md b/addons/xterm-addon-serialize/README.md new file mode 100644 index 000000000..12e0ac876 --- /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 000000000..025e7194d --- /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 000000000..f8b99b556 --- /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 000000000..4e62ed59a --- /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 new file mode 100644 index 000000000..787462f9e --- /dev/null +++ b/addons/xterm-addon-serialize/package.json @@ -0,0 +1,23 @@ +{ + "name": "xterm-addon-serialize", + "version": "0.1.0", + "author": { + "name": "The xterm.js authors", + "url": "https://xtermjs.org/" + }, + "main": "lib/xterm-addon-serialize.js", + "types": "typings/xterm-addon-serialize.d.ts", + "license": "MIT", + "scripts": { + "build": "../../node_modules/.bin/tsc -p src", + "prepackage": "npm run build", + "package": "../../node_modules/.bin/webpack", + "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 new file mode 100644 index 000000000..a34aa0bd3 --- /dev/null +++ b/addons/xterm-addon-serialize/src/SerializeAddon.api.ts @@ -0,0 +1,370 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import * as puppeteer from 'puppeteer'; +import { assert } from 'chai'; +import { ITerminalOptions } from 'xterm'; + +const APP = 'http://127.0.0.1:3000/test'; + +let browser: puppeteer.Browser; +let page: puppeteer.Page; +const width = 800; +const height = 600; + +describe('SerializeAddon', () => { + before(async function (): Promise { + browser = await puppeteer.launch({ + headless: process.argv.indexOf('--headless') !== -1, + args: [`--window-size=${width},${height}`] + }); + page = (await browser.pages())[0]; + await page.setViewport({ width, height }); + await page.goto(APP); + await openTerminal({ rows: 10, cols: 10, rendererType: 'dom' }); + await page.evaluate(` + window.serializeAddon = new SerializeAddon(); + window.term.loadAddon(window.serializeAddon); + `); + }); + + 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 { + const rows = 10; + const cols = 10; + const digitsLine = digitsString(cols); + const lines = newArray(digitsLine, rows); + await writeSync(page, lines.join('\\r\\n')); + assert.equal(await page.evaluate(`serializeAddon.serialize();`), lines.join('\r\n')); + }); + + 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 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 { + const rows = 10; + const cols = 10; + const lines = newArray((index: number) => digitsString(cols, index), rows); + 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 { + await page.evaluate(`window.term = new Terminal(${JSON.stringify(options)})`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + if (options.rendererType === 'dom') { + await page.waitForSelector('.xterm-rows'); + } else { + await page.waitForSelector('.xterm-text-layer'); + } +} + +function newArray(initial: T | ((index: number) => T), count: number): T[] { + const array: T[] = new Array(count); + for (let i = 0; i < array.length; i++) { + if (typeof initial === 'function') { + array[i] = (<(index: number) => T>initial)(i); + } else { + array[i] = initial; + } + } + return array; +} + +function digitsString(length: number, from: number = 0, sgr: string = ''): string { + let s = sgr; + for (let i = 0; i < length; i++) { + 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 new file mode 100644 index 000000000..1d011726c --- /dev/null +++ b/addons/xterm-addon-serialize/src/SerializeAddon.ts @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + * + * (EXPERIMENTAL) This Addon is still under development + */ + +import { Terminal, ITerminalAddon, IBuffer, IBufferCell } from 'xterm'; + +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 { + private _terminal: Terminal | undefined; + + constructor() { } + + public activate(terminal: Terminal): void { + this._terminal = terminal; + } + + public serialize(rows?: number): string { + // 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 maxRows = this._terminal.buffer.length; + const handler = new StringSerializeHandler(this._terminal.buffer); + + rows = (rows === undefined) ? maxRows : constrain(rows, 0, maxRows); + + 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 new file mode 100644 index 000000000..57f3d6edf --- /dev/null +++ b/addons/xterm-addon-serialize/src/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "lib": [ + "dom", + "es2015" + ], + "rootDir": ".", + "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-serialize/typings/xterm-addon-serialize.d.ts b/addons/xterm-addon-serialize/typings/xterm-addon-serialize.d.ts new file mode 100644 index 000000000..9e7b500aa --- /dev/null +++ b/addons/xterm-addon-serialize/typings/xterm-addon-serialize.d.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + + +import { Terminal, ITerminalAddon } from 'xterm'; + +declare module 'xterm-addon-serialize' { + /** + * An xterm.js addon that enables web links. + */ + export class SerializeAddon implements ITerminalAddon { + + constructor(); + + /** + * Activates the addon + * @param terminal The terminal the addon is being loaded in. + */ + public activate(terminal: Terminal): void; + + /** + * Serializes terminal rows into a string that can be written back to the terminal + * to restore the state. The cursor will also be positioned to the correct cell. + * When restoring a terminal it is best to do before `Terminal.open` is called + * to avoid wasting CPU cycles rendering incomplete frames. + * @param rows The number of rows to serialize, starting from the bottom of the + * terminal. This defaults to the number of rows in the viewport. + */ + public serialize(rows?: number): string; + + /** + * Disposes the addon. + */ + public dispose(): void; + } +} diff --git a/addons/xterm-addon-serialize/webpack.config.js b/addons/xterm-addon-serialize/webpack.config.js new file mode 100644 index 000000000..4cabbad91 --- /dev/null +++ b/addons/xterm-addon-serialize/webpack.config.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const path = require('path'); + +const addonName = 'SerializeAddon'; +const mainFile = 'xterm-addon-serialize.js'; + +module.exports = { + entry: `./out/${addonName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + output: { + filename: mainFile, + path: path.resolve('./lib'), + library: addonName, + libraryTarget: 'umd' + }, + mode: 'production' +}; diff --git a/addons/xterm-addon-unicode11/.gitignore b/addons/xterm-addon-unicode11/.gitignore new file mode 100644 index 000000000..3063f07d5 --- /dev/null +++ b/addons/xterm-addon-unicode11/.gitignore @@ -0,0 +1,2 @@ +lib +node_modules diff --git a/addons/xterm-addon-unicode11/.npmignore b/addons/xterm-addon-unicode11/.npmignore new file mode 100644 index 000000000..1c7944456 --- /dev/null +++ b/addons/xterm-addon-unicode11/.npmignore @@ -0,0 +1,5 @@ +**/*.api.js +**/*.api.ts +tsconfig.json +.yarnrc +webpack.config.js diff --git a/addons/xterm-addon-unicode11/LICENSE b/addons/xterm-addon-unicode11/LICENSE new file mode 100644 index 000000000..8f1789258 --- /dev/null +++ b/addons/xterm-addon-unicode11/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/addons/xterm-addon-unicode11/README.md b/addons/xterm-addon-unicode11/README.md new file mode 100644 index 000000000..ec01203e9 --- /dev/null +++ b/addons/xterm-addon-unicode11/README.md @@ -0,0 +1,23 @@ +## xterm-addon-unicode11 + +An addon providing Unicode version 11 rules for xterm.js. + +### Install + +```bash +npm install --save xterm-addon-unicode11 +``` + +### Usage + +```ts +import { Terminal } from 'xterm'; +import { Unicode11Addon } from 'xterm-addon-unicode11'; + +const terminal = new Terminal(); +const unicode11Addon = new Unicode11Addon(); +terminal.loadAddon(unicode11Addon); + +// activate the new version +terminal.unicode.activeVersion = '11'; +``` diff --git a/addons/xterm-addon-unicode11/package.json b/addons/xterm-addon-unicode11/package.json new file mode 100644 index 000000000..0aaa4a1de --- /dev/null +++ b/addons/xterm-addon-unicode11/package.json @@ -0,0 +1,21 @@ +{ + "name": "xterm-addon-unicode11", + "version": "0.1.0", + "author": { + "name": "The xterm.js authors", + "url": "https://xtermjs.org/" + }, + "main": "lib/xterm-addon-unicode11.js", + "types": "typings/xterm-addon-unicode11.d.ts", + "repository": "https://github.com/xtermjs/xterm.js", + "license": "MIT", + "scripts": { + "build": "../../node_modules/.bin/tsc -p src", + "prepackage": "npm run build", + "package": "../../node_modules/.bin/webpack", + "prepublishOnly": "npm run package" + }, + "peerDependencies": { + "xterm": "^4.0.0" + } +} diff --git a/addons/xterm-addon-unicode11/src/Unicode11Addon.api.ts b/addons/xterm-addon-unicode11/src/Unicode11Addon.api.ts new file mode 100644 index 000000000..fb721b47b --- /dev/null +++ b/addons/xterm-addon-unicode11/src/Unicode11Addon.api.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import * as puppeteer from 'puppeteer'; +import { ITerminalOptions } from 'xterm'; +import { assert } from 'chai'; + +const APP = 'http://127.0.0.1:3000/test'; + +let browser: puppeteer.Browser; +let page: puppeteer.Page; +const width = 800; +const height = 600; + +describe('Unicode11Addon', () => { + before(async function(): Promise { + this.timeout(20000); + browser = await puppeteer.launch({ + headless: process.argv.indexOf('--headless') !== -1, + args: [`--window-size=${width},${height}`, `--no-sandbox`] + }); + 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); + await openTerminal(); + }); + + it('wcwidth V11 emoji test', async () => { + await page.evaluate(` + window.unicode11 = new Unicode11Addon(); + window.term.loadAddon(window.unicode11); + `); + // should have loaded '11' + assert.deepEqual(await page.evaluate(`window.term.unicode.versions`), ['6', '11']); + // switch should not throw + await page.evaluate(`window.term.unicode.activeVersion = '11';`); + assert.deepEqual(await page.evaluate(`window.term.unicode.activeVersion`), '11'); + // v6: 10, V11: 20 + assert.deepEqual(await page.evaluate(`window.term._core.unicodeService.getStringCellWidth('🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣')`), 20); + }); +}); + +async function openTerminal(options: ITerminalOptions = {}): Promise { + await page.evaluate(`window.term = new Terminal(${JSON.stringify(options)})`); + await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); + if (options.rendererType === 'dom') { + await page.waitForSelector('.xterm-rows'); + } else { + await page.waitForSelector('.xterm-text-layer'); + } +} diff --git a/addons/xterm-addon-unicode11/src/Unicode11Addon.ts b/addons/xterm-addon-unicode11/src/Unicode11Addon.ts new file mode 100644 index 000000000..cb8c9c563 --- /dev/null +++ b/addons/xterm-addon-unicode11/src/Unicode11Addon.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + * + * UnicodeVersionProvider for V11. + */ + +import { Terminal, ITerminalAddon } from 'xterm'; +import { UnicodeV11 } from './UnicodeV11'; + +export class Unicode11Addon implements ITerminalAddon { + public activate(terminal: Terminal): void { + terminal.unicode.register(new UnicodeV11()); + } + public dispose(): void { } +} diff --git a/addons/xterm-addon-unicode11/src/UnicodeV11.ts b/addons/xterm-addon-unicode11/src/UnicodeV11.ts new file mode 100644 index 000000000..d9d548d6e --- /dev/null +++ b/addons/xterm-addon-unicode11/src/UnicodeV11.ts @@ -0,0 +1,222 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IUnicodeVersionProvider } from 'xterm'; +import { fill } from 'common/TypedArrayUtils'; + +type CharWidth = 0 | 1 | 2; + +const BMP_COMBINING = [ + [0x0300, 0x036F], [0x0483, 0x0489], [0x0591, 0x05BD], + [0x05BF, 0x05BF], [0x05C1, 0x05C2], [0x05C4, 0x05C5], + [0x05C7, 0x05C7], [0x0600, 0x0605], [0x0610, 0x061A], + [0x061C, 0x061C], [0x064B, 0x065F], [0x0670, 0x0670], + [0x06D6, 0x06DD], [0x06DF, 0x06E4], [0x06E7, 0x06E8], + [0x06EA, 0x06ED], [0x070F, 0x070F], [0x0711, 0x0711], + [0x0730, 0x074A], [0x07A6, 0x07B0], [0x07EB, 0x07F3], + [0x07FD, 0x07FD], [0x0816, 0x0819], [0x081B, 0x0823], + [0x0825, 0x0827], [0x0829, 0x082D], [0x0859, 0x085B], + [0x08D3, 0x0902], [0x093A, 0x093A], [0x093C, 0x093C], + [0x0941, 0x0948], [0x094D, 0x094D], [0x0951, 0x0957], + [0x0962, 0x0963], [0x0981, 0x0981], [0x09BC, 0x09BC], + [0x09C1, 0x09C4], [0x09CD, 0x09CD], [0x09E2, 0x09E3], + [0x09FE, 0x09FE], [0x0A01, 0x0A02], [0x0A3C, 0x0A3C], + [0x0A41, 0x0A42], [0x0A47, 0x0A48], [0x0A4B, 0x0A4D], + [0x0A51, 0x0A51], [0x0A70, 0x0A71], [0x0A75, 0x0A75], + [0x0A81, 0x0A82], [0x0ABC, 0x0ABC], [0x0AC1, 0x0AC5], + [0x0AC7, 0x0AC8], [0x0ACD, 0x0ACD], [0x0AE2, 0x0AE3], + [0x0AFA, 0x0AFF], [0x0B01, 0x0B01], [0x0B3C, 0x0B3C], + [0x0B3F, 0x0B3F], [0x0B41, 0x0B44], [0x0B4D, 0x0B4D], + [0x0B56, 0x0B56], [0x0B62, 0x0B63], [0x0B82, 0x0B82], + [0x0BC0, 0x0BC0], [0x0BCD, 0x0BCD], [0x0C00, 0x0C00], + [0x0C04, 0x0C04], [0x0C3E, 0x0C40], [0x0C46, 0x0C48], + [0x0C4A, 0x0C4D], [0x0C55, 0x0C56], [0x0C62, 0x0C63], + [0x0C81, 0x0C81], [0x0CBC, 0x0CBC], [0x0CBF, 0x0CBF], + [0x0CC6, 0x0CC6], [0x0CCC, 0x0CCD], [0x0CE2, 0x0CE3], + [0x0D00, 0x0D01], [0x0D3B, 0x0D3C], [0x0D41, 0x0D44], + [0x0D4D, 0x0D4D], [0x0D62, 0x0D63], [0x0DCA, 0x0DCA], + [0x0DD2, 0x0DD4], [0x0DD6, 0x0DD6], [0x0E31, 0x0E31], + [0x0E34, 0x0E3A], [0x0E47, 0x0E4E], [0x0EB1, 0x0EB1], + [0x0EB4, 0x0EBC], [0x0EC8, 0x0ECD], [0x0F18, 0x0F19], + [0x0F35, 0x0F35], [0x0F37, 0x0F37], [0x0F39, 0x0F39], + [0x0F71, 0x0F7E], [0x0F80, 0x0F84], [0x0F86, 0x0F87], + [0x0F8D, 0x0F97], [0x0F99, 0x0FBC], [0x0FC6, 0x0FC6], + [0x102D, 0x1030], [0x1032, 0x1037], [0x1039, 0x103A], + [0x103D, 0x103E], [0x1058, 0x1059], [0x105E, 0x1060], + [0x1071, 0x1074], [0x1082, 0x1082], [0x1085, 0x1086], + [0x108D, 0x108D], [0x109D, 0x109D], [0x1160, 0x11FF], + [0x135D, 0x135F], [0x1712, 0x1714], [0x1732, 0x1734], + [0x1752, 0x1753], [0x1772, 0x1773], [0x17B4, 0x17B5], + [0x17B7, 0x17BD], [0x17C6, 0x17C6], [0x17C9, 0x17D3], + [0x17DD, 0x17DD], [0x180B, 0x180E], [0x1885, 0x1886], + [0x18A9, 0x18A9], [0x1920, 0x1922], [0x1927, 0x1928], + [0x1932, 0x1932], [0x1939, 0x193B], [0x1A17, 0x1A18], + [0x1A1B, 0x1A1B], [0x1A56, 0x1A56], [0x1A58, 0x1A5E], + [0x1A60, 0x1A60], [0x1A62, 0x1A62], [0x1A65, 0x1A6C], + [0x1A73, 0x1A7C], [0x1A7F, 0x1A7F], [0x1AB0, 0x1ABE], + [0x1B00, 0x1B03], [0x1B34, 0x1B34], [0x1B36, 0x1B3A], + [0x1B3C, 0x1B3C], [0x1B42, 0x1B42], [0x1B6B, 0x1B73], + [0x1B80, 0x1B81], [0x1BA2, 0x1BA5], [0x1BA8, 0x1BA9], + [0x1BAB, 0x1BAD], [0x1BE6, 0x1BE6], [0x1BE8, 0x1BE9], + [0x1BED, 0x1BED], [0x1BEF, 0x1BF1], [0x1C2C, 0x1C33], + [0x1C36, 0x1C37], [0x1CD0, 0x1CD2], [0x1CD4, 0x1CE0], + [0x1CE2, 0x1CE8], [0x1CED, 0x1CED], [0x1CF4, 0x1CF4], + [0x1CF8, 0x1CF9], [0x1DC0, 0x1DF9], [0x1DFB, 0x1DFF], + [0x200B, 0x200F], [0x202A, 0x202E], [0x2060, 0x2064], + [0x2066, 0x206F], [0x20D0, 0x20F0], [0x2CEF, 0x2CF1], + [0x2D7F, 0x2D7F], [0x2DE0, 0x2DFF], [0x302A, 0x302D], + [0x3099, 0x309A], [0xA66F, 0xA672], [0xA674, 0xA67D], + [0xA69E, 0xA69F], [0xA6F0, 0xA6F1], [0xA802, 0xA802], + [0xA806, 0xA806], [0xA80B, 0xA80B], [0xA825, 0xA826], + [0xA8C4, 0xA8C5], [0xA8E0, 0xA8F1], [0xA8FF, 0xA8FF], + [0xA926, 0xA92D], [0xA947, 0xA951], [0xA980, 0xA982], + [0xA9B3, 0xA9B3], [0xA9B6, 0xA9B9], [0xA9BC, 0xA9BD], + [0xA9E5, 0xA9E5], [0xAA29, 0xAA2E], [0xAA31, 0xAA32], + [0xAA35, 0xAA36], [0xAA43, 0xAA43], [0xAA4C, 0xAA4C], + [0xAA7C, 0xAA7C], [0xAAB0, 0xAAB0], [0xAAB2, 0xAAB4], + [0xAAB7, 0xAAB8], [0xAABE, 0xAABF], [0xAAC1, 0xAAC1], + [0xAAEC, 0xAAED], [0xAAF6, 0xAAF6], [0xABE5, 0xABE5], + [0xABE8, 0xABE8], [0xABED, 0xABED], [0xFB1E, 0xFB1E], + [0xFE00, 0xFE0F], [0xFE20, 0xFE2F], [0xFEFF, 0xFEFF], + [0xFFF9, 0xFFFB] +]; +const HIGH_COMBINING = [ + [0x101FD, 0x101FD], [0x102E0, 0x102E0], + [0x10376, 0x1037A], [0x10A01, 0x10A03], [0x10A05, 0x10A06], + [0x10A0C, 0x10A0F], [0x10A38, 0x10A3A], [0x10A3F, 0x10A3F], + [0x10AE5, 0x10AE6], [0x10D24, 0x10D27], [0x10F46, 0x10F50], + [0x11001, 0x11001], [0x11038, 0x11046], [0x1107F, 0x11081], + [0x110B3, 0x110B6], [0x110B9, 0x110BA], [0x110BD, 0x110BD], + [0x110CD, 0x110CD], [0x11100, 0x11102], [0x11127, 0x1112B], + [0x1112D, 0x11134], [0x11173, 0x11173], [0x11180, 0x11181], + [0x111B6, 0x111BE], [0x111C9, 0x111CC], [0x1122F, 0x11231], + [0x11234, 0x11234], [0x11236, 0x11237], [0x1123E, 0x1123E], + [0x112DF, 0x112DF], [0x112E3, 0x112EA], [0x11300, 0x11301], + [0x1133B, 0x1133C], [0x11340, 0x11340], [0x11366, 0x1136C], + [0x11370, 0x11374], [0x11438, 0x1143F], [0x11442, 0x11444], + [0x11446, 0x11446], [0x1145E, 0x1145E], [0x114B3, 0x114B8], + [0x114BA, 0x114BA], [0x114BF, 0x114C0], [0x114C2, 0x114C3], + [0x115B2, 0x115B5], [0x115BC, 0x115BD], [0x115BF, 0x115C0], + [0x115DC, 0x115DD], [0x11633, 0x1163A], [0x1163D, 0x1163D], + [0x1163F, 0x11640], [0x116AB, 0x116AB], [0x116AD, 0x116AD], + [0x116B0, 0x116B5], [0x116B7, 0x116B7], [0x1171D, 0x1171F], + [0x11722, 0x11725], [0x11727, 0x1172B], [0x1182F, 0x11837], + [0x11839, 0x1183A], [0x119D4, 0x119D7], [0x119DA, 0x119DB], + [0x119E0, 0x119E0], [0x11A01, 0x11A0A], [0x11A33, 0x11A38], + [0x11A3B, 0x11A3E], [0x11A47, 0x11A47], [0x11A51, 0x11A56], + [0x11A59, 0x11A5B], [0x11A8A, 0x11A96], [0x11A98, 0x11A99], + [0x11C30, 0x11C36], [0x11C38, 0x11C3D], [0x11C3F, 0x11C3F], + [0x11C92, 0x11CA7], [0x11CAA, 0x11CB0], [0x11CB2, 0x11CB3], + [0x11CB5, 0x11CB6], [0x11D31, 0x11D36], [0x11D3A, 0x11D3A], + [0x11D3C, 0x11D3D], [0x11D3F, 0x11D45], [0x11D47, 0x11D47], + [0x11D90, 0x11D91], [0x11D95, 0x11D95], [0x11D97, 0x11D97], + [0x11EF3, 0x11EF4], [0x13430, 0x13438], [0x16AF0, 0x16AF4], + [0x16B30, 0x16B36], [0x16F4F, 0x16F4F], [0x16F8F, 0x16F92], + [0x1BC9D, 0x1BC9E], [0x1BCA0, 0x1BCA3], [0x1D167, 0x1D169], + [0x1D173, 0x1D182], [0x1D185, 0x1D18B], [0x1D1AA, 0x1D1AD], + [0x1D242, 0x1D244], [0x1DA00, 0x1DA36], [0x1DA3B, 0x1DA6C], + [0x1DA75, 0x1DA75], [0x1DA84, 0x1DA84], [0x1DA9B, 0x1DA9F], + [0x1DAA1, 0x1DAAF], [0x1E000, 0x1E006], [0x1E008, 0x1E018], + [0x1E01B, 0x1E021], [0x1E023, 0x1E024], [0x1E026, 0x1E02A], + [0x1E130, 0x1E136], [0x1E2EC, 0x1E2EF], [0x1E8D0, 0x1E8D6], + [0x1E944, 0x1E94A], [0xE0001, 0xE0001], [0xE0020, 0xE007F], + [0xE0100, 0xE01EF] +]; +const BMP_WIDE = [ + [0x1100, 0x115F], [0x231A, 0x231B], [0x2329, 0x232A], + [0x23E9, 0x23EC], [0x23F0, 0x23F0], [0x23F3, 0x23F3], + [0x25FD, 0x25FE], [0x2614, 0x2615], [0x2648, 0x2653], + [0x267F, 0x267F], [0x2693, 0x2693], [0x26A1, 0x26A1], + [0x26AA, 0x26AB], [0x26BD, 0x26BE], [0x26C4, 0x26C5], + [0x26CE, 0x26CE], [0x26D4, 0x26D4], [0x26EA, 0x26EA], + [0x26F2, 0x26F3], [0x26F5, 0x26F5], [0x26FA, 0x26FA], + [0x26FD, 0x26FD], [0x2705, 0x2705], [0x270A, 0x270B], + [0x2728, 0x2728], [0x274C, 0x274C], [0x274E, 0x274E], + [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], + [0x27B0, 0x27B0], [0x27BF, 0x27BF], [0x2B1B, 0x2B1C], + [0x2B50, 0x2B50], [0x2B55, 0x2B55], [0x2E80, 0x2E99], + [0x2E9B, 0x2EF3], [0x2F00, 0x2FD5], [0x2FF0, 0x2FFB], + [0x3000, 0x3029], [0x302E, 0x303E], [0x3041, 0x3096], + [0x309B, 0x30FF], [0x3105, 0x312F], [0x3131, 0x318E], + [0x3190, 0x31BA], [0x31C0, 0x31E3], [0x31F0, 0x321E], + [0x3220, 0x3247], [0x3250, 0x4DBF], [0x4E00, 0xA48C], + [0xA490, 0xA4C6], [0xA960, 0xA97C], [0xAC00, 0xD7A3], + [0xF900, 0xFAFF], [0xFE10, 0xFE19], [0xFE30, 0xFE52], + [0xFE54, 0xFE66], [0xFE68, 0xFE6B], [0xFF01, 0xFF60], + [0xFFE0, 0xFFE6] +]; +const HIGH_WIDE = [ + [0x16FE0, 0x16FE3], [0x17000, 0x187F7], + [0x18800, 0x18AF2], [0x1B000, 0x1B11E], [0x1B150, 0x1B152], + [0x1B164, 0x1B167], [0x1B170, 0x1B2FB], [0x1F004, 0x1F004], + [0x1F0CF, 0x1F0CF], [0x1F18E, 0x1F18E], [0x1F191, 0x1F19A], + [0x1F200, 0x1F202], [0x1F210, 0x1F23B], [0x1F240, 0x1F248], + [0x1F250, 0x1F251], [0x1F260, 0x1F265], [0x1F300, 0x1F320], + [0x1F32D, 0x1F335], [0x1F337, 0x1F37C], [0x1F37E, 0x1F393], + [0x1F3A0, 0x1F3CA], [0x1F3CF, 0x1F3D3], [0x1F3E0, 0x1F3F0], + [0x1F3F4, 0x1F3F4], [0x1F3F8, 0x1F43E], [0x1F440, 0x1F440], + [0x1F442, 0x1F4FC], [0x1F4FF, 0x1F53D], [0x1F54B, 0x1F54E], + [0x1F550, 0x1F567], [0x1F57A, 0x1F57A], [0x1F595, 0x1F596], + [0x1F5A4, 0x1F5A4], [0x1F5FB, 0x1F64F], [0x1F680, 0x1F6C5], + [0x1F6CC, 0x1F6CC], [0x1F6D0, 0x1F6D2], [0x1F6D5, 0x1F6D5], + [0x1F6EB, 0x1F6EC], [0x1F6F4, 0x1F6FA], [0x1F7E0, 0x1F7EB], + [0x1F90D, 0x1F971], [0x1F973, 0x1F976], [0x1F97A, 0x1F9A2], + [0x1F9A5, 0x1F9AA], [0x1F9AE, 0x1F9CA], [0x1F9CD, 0x1F9FF], + [0x1FA70, 0x1FA73], [0x1FA78, 0x1FA7A], [0x1FA80, 0x1FA82], + [0x1FA90, 0x1FA95], [0x20000, 0x2FFFD], [0x30000, 0x3FFFD] +]; + +// BMP lookup table, lazy initialized during first addon loading +let table: Uint8Array; + +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; +} + + +export class UnicodeV11 implements IUnicodeVersionProvider { + public readonly version = '11'; + + constructor() { + if (!table) { + table = new Uint8Array(65536); + fill(table, 1); + table[0] = 0; + fill(table, 0, 1, 32); + fill(table, 0, 0x7f, 0xa0); + for (let r = 0; r < BMP_COMBINING.length; ++r) { + fill(table, 0, BMP_COMBINING[r][0], BMP_COMBINING[r][1] + 1); + } + for (let r = 0; r < BMP_WIDE.length; ++r) { + fill(table, 2, BMP_WIDE[r][0], BMP_WIDE[r][1] + 1); + } + } + } + + public wcwidth(num: number): CharWidth { + if (num < 32) return 0; + if (num < 127) return 1; + if (num < 65536) return table[num] as CharWidth; + if (bisearch(num, HIGH_COMBINING)) return 0; + if (bisearch(num, HIGH_WIDE)) return 2; + return 1; + } +} diff --git a/addons/xterm-addon-unicode11/src/tsconfig.json b/addons/xterm-addon-unicode11/src/tsconfig.json new file mode 100644 index 000000000..1681e6680 --- /dev/null +++ b/addons/xterm-addon-unicode11/src/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "lib": [ + "dom", + "es2015" + ], + "rootDir": ".", + "outDir": "../out", + "sourceMap": true, + "removeComments": true, + "strict": true, + "baseUrl": ".", + "paths": { + "common/*": [ "../../../src/common/*" ] + }, + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { "path": "../../../src/common" } + ] +} diff --git a/addons/xterm-addon-unicode11/typings/xterm-addon-unicode11.d.ts b/addons/xterm-addon-unicode11/typings/xterm-addon-unicode11.d.ts new file mode 100644 index 000000000..1d0dce1b1 --- /dev/null +++ b/addons/xterm-addon-unicode11/typings/xterm-addon-unicode11.d.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, ITerminalAddon } from 'xterm'; + +declare module 'xterm-addon-unicode11' { + export class Unicode11Addon implements ITerminalAddon { + constructor(); + public activate(terminal: Terminal): void; + public dispose(): void; + } +} diff --git a/addons/xterm-addon-unicode11/webpack.config.js b/addons/xterm-addon-unicode11/webpack.config.js new file mode 100644 index 000000000..66a83a2fb --- /dev/null +++ b/addons/xterm-addon-unicode11/webpack.config.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const path = require('path'); + +const addonName = 'Unicode11Addon'; +const mainFile = 'xterm-addon-unicode11.js'; + +module.exports = { + entry: `./out/${addonName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + resolve: { + modules: ['./node_modules'], + extensions: [ '.js' ], + alias: { + common: path.resolve('../../out/common') + } + }, + output: { + filename: mainFile, + path: path.resolve('./lib'), + library: addonName, + libraryTarget: 'umd' + }, + mode: 'production' +}; diff --git a/addons/xterm-addon-web-links/src/WebLinksAddon.api.ts b/addons/xterm-addon-web-links/src/WebLinksAddon.api.ts index 977f51a62..d2644e830 100644 --- a/addons/xterm-addon-web-links/src/WebLinksAddon.api.ts +++ b/addons/xterm-addon-web-links/src/WebLinksAddon.api.ts @@ -19,7 +19,6 @@ describe('WebLinksAddon', () => { this.timeout(10000); browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; @@ -54,24 +53,21 @@ describe('WebLinksAddon', () => { async function testHostName(hostname: string): Promise { await openTerminal({ rendererType: 'dom', cols: 40 }); await page.evaluate(`window.term.loadAddon(new window.WebLinksAddon())`); - await page.evaluate(` - window.term.writeln(' http://${hostname} '); - window.term.writeln(' http://${hostname}/a~b#c~d?e~f '); - window.term.writeln(' http://${hostname}/colon:test '); - window.term.writeln(' http://${hostname}/colon:test: '); - window.term.writeln('"http://${hostname}/"'); - window.term.writeln('\\'http://${hostname}/\\''); - window.term.writeln('http://${hostname}/subpath/+/id'); - window.term.writeln('http://${hostname}/subpath/subpath2/subpath3/subpath4/subpath5/+/id'); - `); - assert.equal(await getLinkAtCell(3, 1), `http://${hostname}`); - assert.equal(await getLinkAtCell(3, 2), `http://${hostname}/a~b#c~d?e~f`); - assert.equal(await getLinkAtCell(3, 3), `http://${hostname}/colon:test`); - assert.equal(await getLinkAtCell(3, 4), `http://${hostname}/colon:test`); - assert.equal(await getLinkAtCell(2, 5), `http://${hostname}/`); - assert.equal(await getLinkAtCell(2, 6), `http://${hostname}/`); - assert.equal(await getLinkAtCell(1, 7), `http://${hostname}/subpath/+/id`); - assert.equal(await getLinkAtCell(1, 8) + await getLinkAtCell(1, 9), `http://${hostname}/subpath/subpath2/subpath3/subpath4/subpath5/+/id`); + const data = ` http://${hostname} \\r\\n` + + ` http://${hostname}/a~b#c~d?e~f \\r\\n` + + ` http://${hostname}/colon:test \\r\\n` + + ` http://${hostname}/colon:test: \\r\\n` + + `"http://${hostname}/"\\r\\n` + + `\\'http://${hostname}/\\'\\r\\n` + + `http://${hostname}/subpath/+/id`; + await writeSync(page, data); + await pollForLinkAtCell(3, 1, `http://${hostname}`); + await pollForLinkAtCell(3, 2, `http://${hostname}/a~b#c~d?e~f`); + await pollForLinkAtCell(3, 3, `http://${hostname}/colon:test`); + await pollForLinkAtCell(3, 4, `http://${hostname}/colon:test`); + await pollForLinkAtCell(2, 5, `http://${hostname}/`); + await pollForLinkAtCell(2, 6, `http://${hostname}/`); + await pollForLinkAtCell(1, 7, `http://${hostname}/subpath/+/id`); } async function openTerminal(options: ITerminalOptions = {}): Promise { @@ -84,8 +80,30 @@ async function openTerminal(options: ITerminalOptions = {}): Promise { } } -async function getLinkAtCell(col: number, row: number): Promise { +async function pollForLinkAtCell(col: number, row: number, value: string): Promise { const rowSelector = `.xterm-rows > :nth-child(${row})`; - await page.hover(`${rowSelector} > :nth-child(${col})`); - return await page.evaluate(`Array.prototype.reduce.call(document.querySelectorAll('${rowSelector} > span[style]'), (a, b) => a + b.textContent, '');`); + // Ensure the hover element exists before trying to hover it + await pollFor(page, `!!document.querySelector('${rowSelector} > :nth-child(${col})')`, true); + await pollFor(page, `document.querySelectorAll('${rowSelector} > span[style]').length >= ${value.length}`, true, async () => page.hover(`${rowSelector} > :nth-child(${col})`)); + assert.equal(await page.evaluate(`Array.prototype.reduce.call(document.querySelectorAll('${rowSelector} > span[style]'), (a, b) => a + b.textContent, '');`), value); +} + +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); + }); + } +} + +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); } diff --git a/addons/xterm-addon-web-links/src/WebLinksAddon.ts b/addons/xterm-addon-web-links/src/WebLinksAddon.ts index a37fba655..a1b77c306 100644 --- a/addons/xterm-addon-web-links/src/WebLinksAddon.ts +++ b/addons/xterm-addon-web-links/src/WebLinksAddon.ts @@ -27,7 +27,13 @@ const end = ')($|' + negatedPathCharacterSet + ')'; const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end); function handleLink(event: MouseEvent, uri: string): void { - window.open(uri, '_blank'); + const newWindow = window.open(); + if (newWindow) { + newWindow.opener = null; + newWindow.location.href = uri; + } else { + console.warn('Opening link blocked as opener could not be cleared'); + } } export class WebLinksAddon implements ITerminalAddon { diff --git a/addons/xterm-addon-webgl/package.json b/addons/xterm-addon-webgl/package.json index a6befe080..550635250 100644 --- a/addons/xterm-addon-webgl/package.json +++ b/addons/xterm-addon-webgl/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-webgl", - "version": "0.3.0", + "version": "0.4.1", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-webgl/src/CharDataCompat.ts b/addons/xterm-addon-webgl/src/CharDataCompat.ts deleted file mode 100644 index 13613c884..000000000 --- a/addons/xterm-addon-webgl/src/CharDataCompat.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) 2019 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { CellData } from 'common/buffer/CellData'; -import { FLAGS } from './Constants'; -import { IBufferLine } from 'common/Types'; - -export function getCompatAttr(bufferLine: IBufferLine, index: number): number { - // TODO: Need to move WebGL over to the new system and remove this block - const cell = new CellData(); - bufferLine.loadCell(index, cell); - const oldBg = cell.getBgColor() === -1 ? 256 : cell.getBgColor(); - const oldFg = cell.getFgColor() === -1 ? 256 : cell.getFgColor(); - const oldAttr = - (cell.isBold() ? FLAGS.BOLD : 0) | - (cell.isUnderline() ? FLAGS.UNDERLINE : 0) | - (cell.isBlink() ? FLAGS.BLINK : 0) | - (cell.isInverse() ? FLAGS.INVERSE : 0) | - (cell.isDim() ? FLAGS.DIM : 0) | - (cell.isItalic() ? FLAGS.ITALIC : 0); - const attrCompat = - oldBg | - (oldFg << 9) | - (oldAttr << 18); - return attrCompat; -} diff --git a/addons/xterm-addon-webgl/src/ColorUtils.ts b/addons/xterm-addon-webgl/src/ColorUtils.ts deleted file mode 100644 index 80372e650..000000000 --- a/addons/xterm-addon-webgl/src/ColorUtils.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @license MIT - * Copyright (c) 2018 The xterm.js authors. All rights reserved. - */ - -import { IColor } from 'browser/Types'; - -export function getLuminance(color: IColor): number { - // Coefficients taken from: https://www.w3.org/TR/AERT/#color-contrast - const r = color.rgba >> 24 & 0xff; - const g = color.rgba >> 16 & 0xff; - const b = color.rgba >> 8 & 0xff; - return (0.299 * r + 0.587 * g + 0.114 * b) / 255; -} diff --git a/addons/xterm-addon-webgl/src/Constants.ts b/addons/xterm-addon-webgl/src/Constants.ts deleted file mode 100644 index ab18b97ab..000000000 --- a/addons/xterm-addon-webgl/src/Constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) 2019 The xterm.js authors. All rights reserved. - * @license MIT - */ - -// TODO: Should be removed after chardata workaround is fixed -export const enum FLAGS { - BOLD = 1, - UNDERLINE = 2, - BLINK = 4, - INVERSE = 8, - INVISIBLE = 16, - DIM = 32, - ITALIC = 64 -} diff --git a/addons/xterm-addon-webgl/src/GlyphRenderer.ts b/addons/xterm-addon-webgl/src/GlyphRenderer.ts index bd4488dc1..6c67febb5 100644 --- a/addons/xterm-addon-webgl/src/GlyphRenderer.ts +++ b/addons/xterm-addon-webgl/src/GlyphRenderer.ts @@ -6,15 +6,14 @@ import { createProgram, PROJECTION_MATRIX, throwIfFalsy } from './WebglUtils'; import { WebglCharAtlas } from './atlas/WebglCharAtlas'; import { IWebGL2RenderingContext, IWebGLVertexArrayObject, IRenderModel, IRasterizedGlyph } from './Types'; -import { INDICIES_PER_CELL } from './WebglRenderer'; -import { COMBINED_CHAR_BIT_MASK } from './RenderModel'; +import { COMBINED_CHAR_BIT_MASK, RENDER_MODEL_INDICIES_PER_CELL, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_BG_OFFSET } from './RenderModel'; import { fill } from 'common/TypedArrayUtils'; import { slice } from './TypedArray'; -import { NULL_CELL_CODE, WHITESPACE_CELL_CODE } from 'common/buffer/Constants'; -import { getLuminance } from './ColorUtils'; +import { NULL_CELL_CODE, WHITESPACE_CELL_CODE, Attributes, FgFlags } from 'common/buffer/Constants'; import { Terminal, IBufferLine } from 'xterm'; -import { IColorSet } from 'browser/Types'; +import { IColorSet, IColor } from 'browser/Types'; import { IRenderDimensions } from 'browser/renderer/Types'; +import { AttributeData } from 'common/buffer/AttributeData'; interface IVertices { attributes: Float32Array; @@ -103,7 +102,6 @@ export class GlyphRenderer { private _dimensions: IRenderDimensions ) { const gl = this._gl; - const program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource)); this._program = program; @@ -169,11 +167,11 @@ export class GlyphRenderer { return this._atlas ? this._atlas.beginFrame() : true; } - public updateCell(x: number, y: number, code: number, attr: number, bg: number, fg: number, chars: string): void { - this._updateCell(this._vertices.attributes, x, y, code, attr, bg, fg, chars); + public updateCell(x: number, y: number, code: number, bg: number, fg: number, chars: string): void { + this._updateCell(this._vertices.attributes, x, y, code, bg, fg, chars); } - private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, attr: number, bg: number, fg: number, chars?: string): void { + private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, chars?: string): void { const terminal = this._terminal; const i = (y * terminal.cols + x) * INDICES_PER_CELL; @@ -186,12 +184,12 @@ export class GlyphRenderer { let rasterizedGlyph: IRasterizedGlyph; if (!this._atlas) { - throw new Error('atlas must be set before updating cell'); + return; } if (chars && chars.length > 1) { - rasterizedGlyph = this._atlas.getRasterizedGlyphCombinedChar(chars, attr, bg, fg); + rasterizedGlyph = this._atlas.getRasterizedGlyphCombinedChar(chars, bg, fg); } else { - rasterizedGlyph = this._atlas.getRasterizedGlyph(code, attr, bg, fg); + rasterizedGlyph = this._atlas.getRasterizedGlyph(code, bg, fg); } // Fill empty if no glyph was found @@ -220,62 +218,86 @@ export class GlyphRenderer { this._vertices.selectionAttributes = slice(this._vertices.attributes, 0); - // TODO: Make fg and bg configurable, currently since the buffer doesn't - // support truecolor the char atlas cannot store it. - const lumi = getLuminance(this._colors.background); - const fg = lumi > 0.5 ? 7 : 0; - const bg = lumi > 0.5 ? 0 : 7; + const bg = (this._colors.selectionOpaque.rgba >>> 8) | Attributes.CM_RGB; if (columnSelectMode) { const startCol = model.selection.startCol; const width = model.selection.endCol - startCol; const height = model.selection.viewportCappedEndRow - model.selection.viewportCappedStartRow + 1; for (let y = model.selection.viewportCappedStartRow; y < model.selection.viewportCappedStartRow + height; y++) { - this._updateSelectionRange(startCol, startCol + width, y, model, bg, fg); + this._updateSelectionRange(startCol, startCol + width, y, model, bg); } } else { // Draw first row const startCol = model.selection.viewportStartRow === model.selection.viewportCappedStartRow ? model.selection.startCol : 0; const startRowEndCol = model.selection.viewportCappedStartRow === model.selection.viewportCappedEndRow ? model.selection.endCol : terminal.cols; - this._updateSelectionRange(startCol, startRowEndCol, model.selection.viewportCappedStartRow, model, bg, fg); + this._updateSelectionRange(startCol, startRowEndCol, model.selection.viewportCappedStartRow, model, bg); // Draw middle rows const middleRowsCount = Math.max(model.selection.viewportCappedEndRow - model.selection.viewportCappedStartRow - 1, 0); for (let y = model.selection.viewportCappedStartRow + 1; y <= model.selection.viewportCappedStartRow + middleRowsCount; y++) { - this._updateSelectionRange(0, startRowEndCol, y, model, bg, fg); + this._updateSelectionRange(0, startRowEndCol, y, model, bg); } // Draw final row if (model.selection.viewportCappedStartRow !== model.selection.viewportCappedEndRow) { // Only draw viewportEndRow if it's not the same as viewportStartRow const endCol = model.selection.viewportEndRow === model.selection.viewportCappedEndRow ? model.selection.endCol : terminal.cols; - this._updateSelectionRange(0, endCol, model.selection.viewportCappedEndRow, model, bg, fg); + this._updateSelectionRange(0, endCol, model.selection.viewportCappedEndRow, model, bg); } } } - private _updateSelectionRange(startCol: number, endCol: number, y: number, model: IRenderModel, bg: number, fg: number): void { + private _updateSelectionRange(startCol: number, endCol: number, y: number, model: IRenderModel, bg: number): void { const terminal = this._terminal; const row = y + terminal.buffer.viewportY; let line: IBufferLine | undefined; for (let x = startCol; x < endCol; x++) { - const offset = (y * this._terminal.cols + x) * INDICIES_PER_CELL; - // Because the cache uses attr as a lookup key it needs to contain the selection colors as well - let attr = model.cells[offset + 1]; - attr = attr & ~0x3ffff | bg << 9 | fg; + const offset = (y * this._terminal.cols + x) * RENDER_MODEL_INDICIES_PER_CELL; const code = model.cells[offset]; + let fg = model.cells[offset + RENDER_MODEL_FG_OFFSET]; + if (fg & FgFlags.INVERSE) { + const workCell = new AttributeData(); + workCell.fg = fg; + workCell.bg = model.cells[offset + RENDER_MODEL_BG_OFFSET]; + // Get attributes from fg (excluding inverse) and resolve inverse by pullibng rgb colors + // from bg. This is needed since the inverse fg color should be based on the original bg + // color, not on the selection color + fg = (fg & ~(Attributes.CM_MASK | Attributes.RGB_MASK | FgFlags.INVERSE)); + switch (workCell.getBgColorMode()) { + case Attributes.CM_P16: + case Attributes.CM_P256: + const c = this._getColorFromAnsiIndex(workCell.getBgColor()).rgba; + fg |= (c >> 8) & Attributes.RED_MASK | (c >> 8) & Attributes.GREEN_MASK | (c >> 8) & Attributes.BLUE_MASK; + case Attributes.CM_RGB: + const arr = AttributeData.toColorRGB(workCell.getBgColor()); + fg |= arr[0] << Attributes.RED_SHIFT | arr[1] << Attributes.GREEN_SHIFT | arr[2] << Attributes.BLUE_SHIFT; + case Attributes.CM_DEFAULT: + default: + const c2 = this._colors.background.rgba; + fg |= (c2 >> 8) & Attributes.RED_MASK | (c2 >> 8) & Attributes.GREEN_MASK | (c2 >> 8) & Attributes.BLUE_MASK; + } + fg |= Attributes.CM_RGB; + } if (code & COMBINED_CHAR_BIT_MASK) { if (!line) { line = terminal.buffer.getLine(row); } - const chars = line!.getCell(x)!.char; - this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], attr, bg, fg, chars); + const chars = line!.getCell(x)!.getChars(); + this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], bg, fg, chars); } else { - this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], attr, bg, fg); + this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], bg, fg); } } } + private _getColorFromAnsiIndex(idx: number): IColor { + if (idx >= this._colors.ansi.length) { + throw new Error('No color found for idx ' + idx); + } + return this._colors.ansi[idx]; + } + public onResize(): void { const terminal = this._terminal; const gl = this._gl; diff --git a/addons/xterm-addon-webgl/src/RectangleRenderer.ts b/addons/xterm-addon-webgl/src/RectangleRenderer.ts index 2c464d2ae..2246cddd9 100644 --- a/addons/xterm-addon-webgl/src/RectangleRenderer.ts +++ b/addons/xterm-addon-webgl/src/RectangleRenderer.ts @@ -6,12 +6,11 @@ import { createProgram, expandFloat32Array, PROJECTION_MATRIX, throwIfFalsy } from './WebglUtils'; import { IRenderModel, IWebGLVertexArrayObject, IWebGL2RenderingContext, ISelectionRenderModel } from './Types'; import { fill } from 'common/TypedArrayUtils'; -import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; -import { is256Color } from './atlas/CharAtlasUtils'; -import { DEFAULT_COLOR } from 'common/buffer/Constants'; +import { Attributes, FgFlags } from 'common/buffer/Constants'; import { Terminal } from 'xterm'; import { IColorSet, IColor } from 'browser/Types'; import { IRenderDimensions } from 'browser/renderer/Types'; +import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel'; const enum VertexAttribLocations { POSITION = 0, @@ -23,13 +22,13 @@ const enum VertexAttribLocations { const vertexShaderSource = `#version 300 es layout (location = ${VertexAttribLocations.POSITION}) in vec2 a_position; layout (location = ${VertexAttribLocations.SIZE}) in vec2 a_size; -layout (location = ${VertexAttribLocations.COLOR}) in vec3 a_color; +layout (location = ${VertexAttribLocations.COLOR}) in vec4 a_color; layout (location = ${VertexAttribLocations.UNIT_QUAD}) in vec2 a_unitquad; uniform mat4 u_projection; uniform vec2 u_resolution; -out vec3 v_color; +out vec4 v_color; void main() { vec2 zeroToOne = (a_position + (a_unitquad * a_size)) / u_resolution; @@ -40,12 +39,12 @@ void main() { const fragmentShaderSource = `#version 300 es precision lowp float; -in vec3 v_color; +in vec4 v_color; out vec4 outColor; void main() { - outColor = vec4(v_color, 1); + outColor = v_color; }`; interface IVertices { @@ -156,7 +155,7 @@ export class RectangleRenderer { private _updateCachedColors(): void { this._bgFloat = this._colorToFloat32Array(this._colors.background); - this._selectionFloat = this._colorToFloat32Array(this._colors.selection); + this._selectionFloat = this._colorToFloat32Array(this._colors.selectionOpaque); } private _updateViewportRectangle(): void { @@ -247,47 +246,73 @@ export class RectangleRenderer { for (let y = 0; y < terminal.rows; y++) { let currentStartX = -1; - let currentBg = DEFAULT_COLOR; + let currentBg = 0; + let currentFg = 0; + let currentInverse = false; for (let x = 0; x < terminal.cols; x++) { - const modelIndex = ((y * terminal.cols) + x) * 4; - const bg = model.cells[modelIndex + 2]; - if (bg !== currentBg) { + const modelIndex = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; + const bg = model.cells[modelIndex + RENDER_MODEL_BG_OFFSET]; + const fg = model.cells[modelIndex + RENDER_MODEL_FG_OFFSET]; + const inverse = !!(fg & FgFlags.INVERSE); + if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) { // A rectangle needs to be drawn if going from non-default to another color - if (currentBg !== DEFAULT_COLOR) { + if (currentBg !== 0 || (currentInverse && currentFg !== 0)) { const offset = rectangleCount++ * INDICES_PER_RECTANGLE; - this._updateRectangle(vertices, offset, currentBg, currentStartX, x, y); + this._updateRectangle(vertices, offset, currentFg, currentBg, currentStartX, x, y); } currentStartX = x; currentBg = bg; + currentFg = fg; + currentInverse = inverse; } } // Finish rectangle if it's still going - if (currentBg !== DEFAULT_COLOR) { + if (currentBg !== 0 || (currentInverse && currentFg !== 0)) { const offset = rectangleCount++ * INDICES_PER_RECTANGLE; - this._updateRectangle(vertices, offset, currentBg, currentStartX, terminal.cols, y); + this._updateRectangle(vertices, offset, currentFg, currentBg, currentStartX, terminal.cols, y); } } vertices.count = rectangleCount; } - private _updateRectangle(vertices: IVertices, offset: number, bg: number, startX: number, endX: number, y: number): void { - let color: IColor | null = null; - if (bg === INVERTED_DEFAULT_COLOR) { - color = this._colors.foreground; - } else if (is256Color(bg)) { - color = this._colors.ansi[bg]; + private _updateRectangle(vertices: IVertices, offset: number, fg: number, bg: number, startX: number, endX: number, y: number): void { + let rgba: number | undefined; + if (fg & FgFlags.INVERSE) { + switch (fg & Attributes.CM_MASK) { + case Attributes.CM_P16: + case Attributes.CM_P256: + rgba = this._colors.ansi[fg & Attributes.PCOLOR_MASK].rgba; + break; + case Attributes.CM_RGB: + rgba = (fg & Attributes.RGB_MASK) << 8; + break; + case Attributes.CM_DEFAULT: + default: + rgba = this._colors.foreground.rgba; + } } else { - // TODO: Add support for true color - color = this._colors.foreground; + switch (bg & Attributes.CM_MASK) { + case Attributes.CM_P16: + case Attributes.CM_P256: + rgba = this._colors.ansi[bg & Attributes.PCOLOR_MASK].rgba; + break; + case Attributes.CM_RGB: + rgba = (bg & Attributes.RGB_MASK) << 8; + break; + case Attributes.CM_DEFAULT: + default: + rgba = this._colors.background.rgba; + } } + if (vertices.attributes.length < offset + 4) { vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE); } const x1 = startX * this._dimensions.scaledCellWidth; const y1 = y * this._dimensions.scaledCellHeight; - const r = ((color.rgba >> 24) & 0xFF) / 255; - const g = ((color.rgba >> 16) & 0xFF) / 255; - const b = ((color.rgba >> 8 ) & 0xFF) / 255; + const r = ((rgba >> 24) & 0xFF) / 255; + const g = ((rgba >> 16) & 0xFF) / 255; + const b = ((rgba >> 8 ) & 0xFF) / 255; this._addRectangle(vertices.attributes, offset, x1, y1, (endX - startX) * this._dimensions.scaledCellWidth, this._dimensions.scaledCellHeight, r, g, b, 1); } diff --git a/addons/xterm-addon-webgl/src/RenderModel.ts b/addons/xterm-addon-webgl/src/RenderModel.ts index a9ee24e9a..2b48047f8 100644 --- a/addons/xterm-addon-webgl/src/RenderModel.ts +++ b/addons/xterm-addon-webgl/src/RenderModel.ts @@ -6,7 +6,9 @@ import { IRenderModel, ISelectionRenderModel } from './Types'; import { fill } from 'common/TypedArrayUtils'; -export const RENDER_MODEL_INDICIES_PER_CELL = 4; +export const RENDER_MODEL_INDICIES_PER_CELL = 3; +export const RENDER_MODEL_BG_OFFSET = 1; +export const RENDER_MODEL_FG_OFFSET = 2; export const COMBINED_CHAR_BIT_MASK = 0x80000000; diff --git a/addons/xterm-addon-webgl/src/Types.d.ts b/addons/xterm-addon-webgl/src/Types.d.ts index 6ebc8d64d..f01092eec 100644 --- a/addons/xterm-addon-webgl/src/Types.d.ts +++ b/addons/xterm-addon-webgl/src/Types.d.ts @@ -4,7 +4,7 @@ */ export interface IRasterizedGlyphSet { - [flags: number]: IRasterizedGlyph; + [bg: number]: { [fg: number]: IRasterizedGlyph } | undefined; } /** diff --git a/addons/xterm-addon-webgl/src/WebglAddon.ts b/addons/xterm-addon-webgl/src/WebglAddon.ts index 0074f9b28..3cf45f352 100644 --- a/addons/xterm-addon-webgl/src/WebglAddon.ts +++ b/addons/xterm-addon-webgl/src/WebglAddon.ts @@ -9,20 +9,35 @@ import { IRenderService } from 'browser/services/Services'; import { IColorSet } from 'browser/Types'; export class WebglAddon implements ITerminalAddon { + private _terminal?: Terminal; + private _renderer?: WebglRenderer; + constructor( private _preserveDrawingBuffer?: boolean ) {} public activate(terminal: Terminal): void { if (!terminal.element) { - throw new Error('Cannot activate WebglRendererAddon before Terminal.open'); + throw new Error('Cannot activate WebglAddon before Terminal.open'); } + this._terminal = terminal; const renderService: IRenderService = (terminal)._core._renderService; const colors: IColorSet = (terminal)._core._colorManager.colors; - renderService.setRenderer(new WebglRenderer(terminal, colors, this._preserveDrawingBuffer)); + this._renderer = new WebglRenderer(terminal, colors, this._preserveDrawingBuffer); + renderService.setRenderer(this._renderer); } public dispose(): void { - throw new Error('WebglRendererAddon.dispose Not yet implemented'); + if (!this._terminal) { + throw new Error('Cannot dispose WebglAddon because it is activated'); + } + const renderService: IRenderService = (this._terminal)._core._renderService; + renderService.setRenderer((this._terminal)._core._createRenderer()); + renderService.onResize(this._terminal.cols, this._terminal.rows); + this._renderer = undefined; + } + + public get textureAtlas(): HTMLCanvasElement | undefined { + return this._renderer?.textureAtlas; } } diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.api.ts b/addons/xterm-addon-webgl/src/WebglRenderer.api.ts index 9303782d7..8b9c84bf7 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.api.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.api.ts @@ -4,9 +4,10 @@ */ import * as puppeteer from 'puppeteer'; -import { assert } from 'chai'; import { ITerminalOptions } from '../../../src/Types'; import { ITheme } from 'xterm'; +import { assert } from 'chai'; +import deepEqual = require('deep-equal'); const APP = 'http://127.0.0.1:3000/test'; @@ -16,31 +17,20 @@ const width = 800; const height = 600; describe('WebGL Renderer Integration Tests', function(): void { - this.timeout(20000); - - before(async function(): Promise { - browser = await puppeteer.launch({ - headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, - args: [`--window-size=${width},${height}`, `--no-sandbox`] - }); - page = (await browser.pages())[0]; - await page.setViewport({ width, height }); - await page.goto(APP); - await openTerminal(); - await page.evaluate(`window.term.loadAddon(new WebglAddon(true));`); + it('dispose removes renderer canvases', async () => { + await setupBrowser(); + assert.equal(await page.evaluate(`document.querySelectorAll('.xterm canvas').length`), 3); + await page.evaluate(`addon.dispose()`); + assert.equal(await page.evaluate(`document.querySelectorAll('.xterm canvas').length`), 0); + await browser.close(); }); - after(() => { - browser.close(); - }); + describe('colors', () => { + before(async () => setupBrowser()); + after(async () => browser.close()); + beforeEach(async () => page.evaluate(`window.term.reset()`)); - beforeEach(async () => { - await page.evaluate(`window.term.reset()`); - }); - - describe('WebGL Renderer', () => { - it('foreground colors normal', async function(): Promise { + it('foreground 0-15', async () => { const theme: ITheme = { black: '#010203', red: '#040506', @@ -53,17 +43,17 @@ describe('WebGL Renderer Integration Tests', function(): void { }; await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); await writeSync(`\\x1b[30m█\\x1b[31m█\\x1b[32m█\\x1b[33m█\\x1b[34m█\\x1b[35m█\\x1b[36m█\\x1b[37m█`); - assert.deepEqual(await getCellColor(1, 1), [1, 2, 3, 255]); - assert.deepEqual(await getCellColor(2, 1), [4, 5, 6, 255]); - assert.deepEqual(await getCellColor(3, 1), [7, 8, 9, 255]); - assert.deepEqual(await getCellColor(4, 1), [10, 11, 12, 255]); - assert.deepEqual(await getCellColor(5, 1), [13, 14, 15, 255]); - assert.deepEqual(await getCellColor(6, 1), [16, 17, 18, 255]); - assert.deepEqual(await getCellColor(7, 1), [19, 20, 21, 255]); - assert.deepEqual(await getCellColor(8, 1), [22, 23, 24, 255]); + await pollFor(page, () => getCellColor(1, 1), [1, 2, 3, 255]); + await pollFor(page, () => getCellColor(2, 1), [4, 5, 6, 255]); + await pollFor(page, () => getCellColor(3, 1), [7, 8, 9, 255]); + await pollFor(page, () => getCellColor(4, 1), [10, 11, 12, 255]); + await pollFor(page, () => getCellColor(5, 1), [13, 14, 15, 255]); + await pollFor(page, () => getCellColor(6, 1), [16, 17, 18, 255]); + await pollFor(page, () => getCellColor(7, 1), [19, 20, 21, 255]); + await pollFor(page, () => getCellColor(8, 1), [22, 23, 24, 255]); }); - it('foreground colors bright', async function(): Promise { + it('foreground 0-7 drawBoldTextInBrightColors', async () => { const theme: ITheme = { brightBlack: '#010203', brightRed: '#040506', @@ -74,19 +64,22 @@ describe('WebGL Renderer Integration Tests', function(): void { brightCyan: '#131415', brightWhite: '#161718' }; - await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); - await writeSync(`\\x1b[90m█\\x1b[91m█\\x1b[92m█\\x1b[93m█\\x1b[94m█\\x1b[95m█\\x1b[96m█\\x1b[97m█`); - assert.deepEqual(await getCellColor(1, 1), [1, 2, 3, 255]); - assert.deepEqual(await getCellColor(2, 1), [4, 5, 6, 255]); - assert.deepEqual(await getCellColor(3, 1), [7, 8, 9, 255]); - assert.deepEqual(await getCellColor(4, 1), [10, 11, 12, 255]); - assert.deepEqual(await getCellColor(5, 1), [13, 14, 15, 255]); - assert.deepEqual(await getCellColor(6, 1), [16, 17, 18, 255]); - assert.deepEqual(await getCellColor(7, 1), [19, 20, 21, 255]); - assert.deepEqual(await getCellColor(8, 1), [22, 23, 24, 255]); + await page.evaluate(` + window.term.setOption('theme', ${JSON.stringify(theme)}); + window.term.setOption('drawBoldTextInBrightColors', true); + `); + await writeSync(`\\x1b[1;30m█\\x1b[1;31m█\\x1b[1;32m█\\x1b[1;33m█\\x1b[1;34m█\\x1b[1;35m█\\x1b[1;36m█\\x1b[1;37m█`); + await pollFor(page, () => getCellColor(1, 1), [1, 2, 3, 255]); + await pollFor(page, () => getCellColor(2, 1), [4, 5, 6, 255]); + await pollFor(page, () => getCellColor(3, 1), [7, 8, 9, 255]); + await pollFor(page, () => getCellColor(4, 1), [10, 11, 12, 255]); + await pollFor(page, () => getCellColor(5, 1), [13, 14, 15, 255]); + await pollFor(page, () => getCellColor(6, 1), [16, 17, 18, 255]); + await pollFor(page, () => getCellColor(7, 1), [19, 20, 21, 255]); + await pollFor(page, () => getCellColor(8, 1), [22, 23, 24, 255]); }); - it('background colors normal', async function(): Promise { + it('background 0-15', async () => { const theme: ITheme = { black: '#010203', red: '#040506', @@ -99,17 +92,132 @@ describe('WebGL Renderer Integration Tests', function(): void { }; await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); await writeSync(`\\x1b[40m \\x1b[41m \\x1b[42m \\x1b[43m \\x1b[44m \\x1b[45m \\x1b[46m \\x1b[47m `); - assert.deepEqual(await getCellColor(1, 1), [1, 2, 3, 255]); - assert.deepEqual(await getCellColor(2, 1), [4, 5, 6, 255]); - assert.deepEqual(await getCellColor(3, 1), [7, 8, 9, 255]); - assert.deepEqual(await getCellColor(4, 1), [10, 11, 12, 255]); - assert.deepEqual(await getCellColor(5, 1), [13, 14, 15, 255]); - assert.deepEqual(await getCellColor(6, 1), [16, 17, 18, 255]); - assert.deepEqual(await getCellColor(7, 1), [19, 20, 21, 255]); - assert.deepEqual(await getCellColor(8, 1), [22, 23, 24, 255]); + await pollFor(page, () => getCellColor(1, 1), [1, 2, 3, 255]); + await pollFor(page, () => getCellColor(2, 1), [4, 5, 6, 255]); + await pollFor(page, () => getCellColor(3, 1), [7, 8, 9, 255]); + await pollFor(page, () => getCellColor(4, 1), [10, 11, 12, 255]); + await pollFor(page, () => getCellColor(5, 1), [13, 14, 15, 255]); + await pollFor(page, () => getCellColor(6, 1), [16, 17, 18, 255]); + await pollFor(page, () => getCellColor(7, 1), [19, 20, 21, 255]); + await pollFor(page, () => getCellColor(8, 1), [22, 23, 24, 255]); }); - it('background colors bright', async function(): Promise { + it('foreground 0-15 inverse', async () => { + const theme: ITheme = { + black: '#010203', + red: '#040506', + green: '#070809', + yellow: '#0a0b0c', + blue: '#0d0e0f', + magenta: '#101112', + cyan: '#131415', + white: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[7;30m \\x1b[7;31m \\x1b[7;32m \\x1b[7;33m \\x1b[7;34m \\x1b[7;35m \\x1b[7;36m \\x1b[7;37m `); + await pollFor(page, () => getCellColor(1, 1), [1, 2, 3, 255]); + await pollFor(page, () => getCellColor(2, 1), [4, 5, 6, 255]); + await pollFor(page, () => getCellColor(3, 1), [7, 8, 9, 255]); + await pollFor(page, () => getCellColor(4, 1), [10, 11, 12, 255]); + await pollFor(page, () => getCellColor(5, 1), [13, 14, 15, 255]); + await pollFor(page, () => getCellColor(6, 1), [16, 17, 18, 255]); + await pollFor(page, () => getCellColor(7, 1), [19, 20, 21, 255]); + await pollFor(page, () => getCellColor(8, 1), [22, 23, 24, 255]); + }); + + it('background 0-15 inverse', async () => { + const theme: ITheme = { + black: '#010203', + red: '#040506', + green: '#070809', + yellow: '#0a0b0c', + blue: '#0d0e0f', + magenta: '#101112', + cyan: '#131415', + white: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[7;40m█\\x1b[7;41m█\\x1b[7;42m█\\x1b[7;43m█\\x1b[7;44m█\\x1b[7;45m█\\x1b[7;46m█\\x1b[7;47m█`); + await pollFor(page, () => getCellColor(1, 1), [1, 2, 3, 255]); + await pollFor(page, () => getCellColor(2, 1), [4, 5, 6, 255]); + await pollFor(page, () => getCellColor(3, 1), [7, 8, 9, 255]); + await pollFor(page, () => getCellColor(4, 1), [10, 11, 12, 255]); + await pollFor(page, () => getCellColor(5, 1), [13, 14, 15, 255]); + await pollFor(page, () => getCellColor(6, 1), [16, 17, 18, 255]); + await pollFor(page, () => getCellColor(7, 1), [19, 20, 21, 255]); + await pollFor(page, () => getCellColor(8, 1), [22, 23, 24, 255]); + }); + + it('foreground 0-15 inivisible', async () => { + const theme: ITheme = { + black: '#010203', + red: '#040506', + green: '#070809', + yellow: '#0a0b0c', + blue: '#0d0e0f', + magenta: '#101112', + cyan: '#131415', + white: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[8;30m \\x1b[8;31m \\x1b[8;32m \\x1b[8;33m \\x1b[8;34m \\x1b[8;35m \\x1b[8;36m \\x1b[8;37m `); + await pollFor(page, () => getCellColor(1, 1), [0, 0, 0, 255]); + await pollFor(page, () => getCellColor(2, 1), [0, 0, 0, 255]); + await pollFor(page, () => getCellColor(3, 1), [0, 0, 0, 255]); + await pollFor(page, () => getCellColor(4, 1), [0, 0, 0, 255]); + await pollFor(page, () => getCellColor(5, 1), [0, 0, 0, 255]); + await pollFor(page, () => getCellColor(6, 1), [0, 0, 0, 255]); + await pollFor(page, () => getCellColor(7, 1), [0, 0, 0, 255]); + await pollFor(page, () => getCellColor(8, 1), [0, 0, 0, 255]); + }); + + it('background 0-15 inivisible', async () => { + const theme: ITheme = { + black: '#010203', + red: '#040506', + green: '#070809', + yellow: '#0a0b0c', + blue: '#0d0e0f', + magenta: '#101112', + cyan: '#131415', + white: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[8;40m█\\x1b[8;41m█\\x1b[8;42m█\\x1b[8;43m█\\x1b[8;44m█\\x1b[8;45m█\\x1b[8;46m█\\x1b[8;47m█`); + await pollFor(page, () => getCellColor(1, 1), [1, 2, 3, 255]); + await pollFor(page, () => getCellColor(2, 1), [4, 5, 6, 255]); + await pollFor(page, () => getCellColor(3, 1), [7, 8, 9, 255]); + await pollFor(page, () => getCellColor(4, 1), [10, 11, 12, 255]); + await pollFor(page, () => getCellColor(5, 1), [13, 14, 15, 255]); + await pollFor(page, () => getCellColor(6, 1), [16, 17, 18, 255]); + await pollFor(page, () => getCellColor(7, 1), [19, 20, 21, 255]); + await pollFor(page, () => getCellColor(8, 1), [22, 23, 24, 255]); + }); + + it('foreground 0-15 bright', async () => { + const theme: ITheme = { + brightBlack: '#010203', + brightRed: '#040506', + brightGreen: '#070809', + brightYellow: '#0a0b0c', + brightBlue: '#0d0e0f', + brightMagenta: '#101112', + brightCyan: '#131415', + brightWhite: '#161718' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(`\\x1b[90m█\\x1b[91m█\\x1b[92m█\\x1b[93m█\\x1b[94m█\\x1b[95m█\\x1b[96m█\\x1b[97m█`); + await pollFor(page, () => getCellColor(1, 1), [1, 2, 3, 255]); + await pollFor(page, () => getCellColor(2, 1), [4, 5, 6, 255]); + await pollFor(page, () => getCellColor(3, 1), [7, 8, 9, 255]); + await pollFor(page, () => getCellColor(4, 1), [10, 11, 12, 255]); + await pollFor(page, () => getCellColor(5, 1), [13, 14, 15, 255]); + await pollFor(page, () => getCellColor(6, 1), [16, 17, 18, 255]); + await pollFor(page, () => getCellColor(7, 1), [19, 20, 21, 255]); + await pollFor(page, () => getCellColor(8, 1), [22, 23, 24, 255]); + }); + + it('background 0-15 bright', async () => { const theme: ITheme = { brightBlack: '#010203', brightRed: '#040506', @@ -122,14 +230,638 @@ describe('WebGL Renderer Integration Tests', function(): void { }; await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); await writeSync(`\\x1b[100m \\x1b[101m \\x1b[102m \\x1b[103m \\x1b[104m \\x1b[105m \\x1b[106m \\x1b[107m `); - assert.deepEqual(await getCellColor(1, 1), [1, 2, 3, 255]); - assert.deepEqual(await getCellColor(2, 1), [4, 5, 6, 255]); - assert.deepEqual(await getCellColor(3, 1), [7, 8, 9, 255]); - assert.deepEqual(await getCellColor(4, 1), [10, 11, 12, 255]); - assert.deepEqual(await getCellColor(5, 1), [13, 14, 15, 255]); - assert.deepEqual(await getCellColor(6, 1), [16, 17, 18, 255]); - assert.deepEqual(await getCellColor(7, 1), [19, 20, 21, 255]); - assert.deepEqual(await getCellColor(8, 1), [22, 23, 24, 255]); + await pollFor(page, () => getCellColor(1, 1), [1, 2, 3, 255]); + await pollFor(page, () => getCellColor(2, 1), [4, 5, 6, 255]); + await pollFor(page, () => getCellColor(3, 1), [7, 8, 9, 255]); + await pollFor(page, () => getCellColor(4, 1), [10, 11, 12, 255]); + await pollFor(page, () => getCellColor(5, 1), [13, 14, 15, 255]); + await pollFor(page, () => getCellColor(6, 1), [16, 17, 18, 255]); + await pollFor(page, () => getCellColor(7, 1), [19, 20, 21, 255]); + await pollFor(page, () => getCellColor(8, 1), [22, 23, 24, 255]); + }); + + it('foreground 16-255', async () => { + let data = ''; + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + data += `\\x1b[38;5;${16 + y * 16 + x}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + const cssColor = COLORS_16_TO_255[y * 16 + x]; + const r = parseInt(cssColor.substr(1, 2), 16); + const g = parseInt(cssColor.substr(3, 2), 16); + const b = parseInt(cssColor.substr(5, 2), 16); + await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); + } + } + }); + + it('background 16-255', async () => { + let data = ''; + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + data += `\\x1b[48;5;${16 + y * 16 + x}m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + const cssColor = COLORS_16_TO_255[y * 16 + x]; + const r = parseInt(cssColor.substr(1, 2), 16); + const g = parseInt(cssColor.substr(3, 2), 16); + const b = parseInt(cssColor.substr(5, 2), 16); + await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); + } + } + }); + + it('foreground 16-255 inverse', async () => { + let data = ''; + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + data += `\\x1b[7;38;5;${16 + y * 16 + x}m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + const cssColor = COLORS_16_TO_255[y * 16 + x]; + const r = parseInt(cssColor.substr(1, 2), 16); + const g = parseInt(cssColor.substr(3, 2), 16); + const b = parseInt(cssColor.substr(5, 2), 16); + await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); + } + } + }); + + it('background 16-255 inverse', async () => { + let data = ''; + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + data += `\\x1b[7;48;5;${16 + y * 16 + x}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + const cssColor = COLORS_16_TO_255[y * 16 + x]; + const r = parseInt(cssColor.substr(1, 2), 16); + const g = parseInt(cssColor.substr(3, 2), 16); + const b = parseInt(cssColor.substr(5, 2), 16); + await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); + } + } + }); + + it('foreground 16-255 invisible', async () => { + let data = ''; + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + data += `\\x1b[8;38;5;${16 + y * 16 + x}m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + const cssColor = COLORS_16_TO_255[y * 16 + x]; + const r = parseInt(cssColor.substr(1, 2), 16); + const g = parseInt(cssColor.substr(3, 2), 16); + const b = parseInt(cssColor.substr(5, 2), 16); + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, 0, 0, 255]); + } + } + }); + + it('background 16-255 invisible', async () => { + let data = ''; + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + data += `\\x1b[8;48;5;${16 + y * 16 + x}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 240 / 16; y++) { + for (let x = 0; x < 16; x++) { + const cssColor = COLORS_16_TO_255[y * 16 + x]; + const r = parseInt(cssColor.substr(1, 2), 16); + const g = parseInt(cssColor.substr(3, 2), 16); + const b = parseInt(cssColor.substr(5, 2), 16); + await pollFor(page, () => getCellColor(x + 1, y + 1), [r, g, b, 255]); + } + } + }); + + it('foreground true color red', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[38;2;${i};0;0m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, 0, 0, 255]); + } + } + }); + + it('background true color red', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[48;2;${i};0;0m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, 0, 0, 255]); + } + } + }); + + it('foreground true color green', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[38;2;0;${i};0m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, i, 0, 255]); + } + } + }); + + it('background true color green', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[48;2;0;${i};0m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, i, 0, 255]); + } + } + }); + + it('foreground true color blue', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[38;2;0;0;${i}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, 0, i, 255]); + } + } + }); + + it('background true color blue', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[48;2;0;0;${i}m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, 0, i, 255]); + } + } + }); + + it('foreground true color grey', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[38;2;${i};${i};${i}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, i, i, 255]); + } + } + }); + + it('background true color grey', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[48;2;${i};${i};${i}m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, i, i, 255]); + } + } + }); + + it('foreground true color red inverse', async function(): Promise { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\x1b[7;38;2;${i};0;0m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, 0, 0, 255]); + } + } + }); + + it('background true color red inverse', async function(): Promise { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[7;48;2;${i};0;0m█\\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, 0, 0, 255]); + } + } + }); + + it('foreground true color green inverse', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[7;38;2;0;${i};0m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, i, 0, 255]); + } + } + }); + + it('background true color green inverse', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[7;48;2;0;${i};0m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, i, 0, 255]); + } + } + }); + + it('foreground true color blue inverse', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[7;38;2;0;0;${i}m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, 0, i, 255]); + } + } + }); + + it('background true color blue inverse', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[7;48;2;0;0;${i}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, 0, i, 255]); + } + } + }); + + it('foreground true color grey inverse', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[7;38;2;${i};${i};${i}m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, i, i, 255]); + } + } + }); + + it('background true color grey inverse', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[7;48;2;${i};${i};${i}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, i, i, 255]); + } + } + }); + + it('foreground true color grey invisible', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[8;38;2;${i};${i};${i}m \x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [0, 0, 0, 255]); + } + } + }); + + it('background true color grey invisible', async () => { + let data = ''; + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + data += `\\x1b[8;48;2;${i};${i};${i}m█\x1b[0m`; + } + data += '\\r\\n'; + } + await writeSync(data); + for (let y = 0; y < 16; y++) { + for (let x = 0; x < 16; x++) { + const i = y * 16 + x; + await pollFor(page, () => getCellColor(x + 1, y + 1), [i, i, i, 255]); + } + } + }); + }); + + describe('minimumContrastRatio', async () => { + before(async () => setupBrowser()); + after(async () => browser.close()); + beforeEach(async () => page.evaluate(`window.term.reset()`)); + + it('should adjust 0-15 colors on black background', async () => { + const theme: ITheme = { + black: '#2e3436', + red: '#cc0000', + green: '#4e9a06', + yellow: '#c4a000', + blue: '#3465a4', + magenta: '#75507b', + cyan: '#06989a', + white: '#d3d7cf', + brightBlack: '#555753', + brightRed: '#ef2929', + brightGreen: '#8ae234', + brightYellow: '#fce94f', + brightBlue: '#729fcf', + brightMagenta: '#ad7fa8', + brightCyan: '#34e2e2', + brightWhite: '#eeeeec' + }; + await page.evaluate(` + window.term.setOption('theme', ${JSON.stringify(theme)}); + window.term.setOption('minimumContrastRatio', 1); + `); + await writeSync( + `\\x1b[30m█\\x1b[31m█\\x1b[32m█\\x1b[33m█\\x1b[34m█\\x1b[35m█\\x1b[36m█\\x1b[37m█\\r\\n` + + `\\x1b[90m█\\x1b[91m█\\x1b[92m█\\x1b[93m█\\x1b[94m█\\x1b[95m█\\x1b[96m█\\x1b[97m█` + ); + // Validate before minimumContrastRatio is applied + await pollFor(page, () => getCellColor(1, 1), [0x2e, 0x34, 0x36, 255]); + await pollFor(page, () => getCellColor(2, 1), [0xcc, 0x00, 0x00, 255]); + await pollFor(page, () => getCellColor(3, 1), [0x4e, 0x9a, 0x06, 255]); + await pollFor(page, () => getCellColor(4, 1), [0xc4, 0xa0, 0x00, 255]); + await pollFor(page, () => getCellColor(5, 1), [0x34, 0x65, 0xa4, 255]); + await pollFor(page, () => getCellColor(6, 1), [0x75, 0x50, 0x7b, 255]); + await pollFor(page, () => getCellColor(7, 1), [0x06, 0x98, 0x9a, 255]); + await pollFor(page, () => getCellColor(8, 1), [0xd3, 0xd7, 0xcf, 255]); + await pollFor(page, () => getCellColor(1, 2), [0x55, 0x57, 0x53, 255]); + await pollFor(page, () => getCellColor(2, 2), [0xef, 0x29, 0x29, 255]); + await pollFor(page, () => getCellColor(3, 2), [0x8a, 0xe2, 0x34, 255]); + await pollFor(page, () => getCellColor(4, 2), [0xfc, 0xe9, 0x4f, 255]); + await pollFor(page, () => getCellColor(5, 2), [0x72, 0x9f, 0xcf, 255]); + await pollFor(page, () => getCellColor(6, 2), [0xad, 0x7f, 0xa8, 255]); + await pollFor(page, () => getCellColor(7, 2), [0x34, 0xe2, 0xe2, 255]); + await pollFor(page, () => getCellColor(8, 2), [0xee, 0xee, 0xec, 255]); + // Setting and check for minimum contrast values, note that these are note + // exact to the contrast ratio, if the increase luminance algorithm + // changes then these will probably fail + await page.evaluate(`window.term.setOption('minimumContrastRatio', 10);`); + await pollFor(page, () => getCellColor(1, 1), [176, 180, 180, 255]); + await pollFor(page, () => getCellColor(2, 1), [238, 158, 158, 255]); + await pollFor(page, () => getCellColor(3, 1), [197, 223, 171, 255]); + await pollFor(page, () => getCellColor(4, 1), [235, 221, 158, 255]); + await pollFor(page, () => getCellColor(5, 1), [124, 156, 198, 255]); + await pollFor(page, () => getCellColor(6, 1), [183, 165, 187, 255]); + await pollFor(page, () => getCellColor(7, 1), [110, 197, 198, 255]); + await pollFor(page, () => getCellColor(8, 1), [211, 215, 207, 255]); + await pollFor(page, () => getCellColor(1, 2), [183, 185, 183, 255]); + await pollFor(page, () => getCellColor(2, 2), [249, 156, 156, 255]); + await pollFor(page, () => getCellColor(3, 2), [138, 226, 52, 255]); + await pollFor(page, () => getCellColor(4, 2), [252, 233, 79, 255]); + await pollFor(page, () => getCellColor(5, 2), [114, 159, 207, 255]); + await pollFor(page, () => getCellColor(6, 2), [190, 152, 185, 255]); + // Unchanged + await pollFor(page, () => getCellColor(7, 2), [0x34, 0xe2, 0xe2, 255]); + await pollFor(page, () => getCellColor(8, 2), [0xee, 0xee, 0xec, 255]); + }); + + it('should adjust 0-15 colors on white background', async () => { + const theme: ITheme = { + background: '#ffffff', + black: '#2e3436', + red: '#cc0000', + green: '#4e9a06', + yellow: '#c4a000', + blue: '#3465a4', + magenta: '#75507b', + cyan: '#06989a', + white: '#d3d7cf', + brightBlack: '#555753', + brightRed: '#ef2929', + brightGreen: '#8ae234', + brightYellow: '#fce94f', + brightBlue: '#729fcf', + brightMagenta: '#ad7fa8', + brightCyan: '#34e2e2', + brightWhite: '#eeeeec' + }; + await page.evaluate(` + window.term.setOption('theme', ${JSON.stringify(theme)}); + window.term.setOption('minimumContrastRatio', 1); + `); + await writeSync( + `\\x1b[30m█\\x1b[31m█\\x1b[32m█\\x1b[33m█\\x1b[34m█\\x1b[35m█\\x1b[36m█\\x1b[37m█\\r\\n` + + `\\x1b[90m█\\x1b[91m█\\x1b[92m█\\x1b[93m█\\x1b[94m█\\x1b[95m█\\x1b[96m█\\x1b[97m█` + ); + // Validate before minimumContrastRatio is applied + await pollFor(page, () => getCellColor(1, 1), [0x2e, 0x34, 0x36, 255]); + await pollFor(page, () => getCellColor(2, 1), [0xcc, 0x00, 0x00, 255]); + await pollFor(page, () => getCellColor(3, 1), [0x4e, 0x9a, 0x06, 255]); + await pollFor(page, () => getCellColor(4, 1), [0xc4, 0xa0, 0x00, 255]); + await pollFor(page, () => getCellColor(5, 1), [0x34, 0x65, 0xa4, 255]); + await pollFor(page, () => getCellColor(6, 1), [0x75, 0x50, 0x7b, 255]); + await pollFor(page, () => getCellColor(7, 1), [0x06, 0x98, 0x9a, 255]); + await pollFor(page, () => getCellColor(8, 1), [0xd3, 0xd7, 0xcf, 255]); + await pollFor(page, () => getCellColor(1, 2), [0x55, 0x57, 0x53, 255]); + await pollFor(page, () => getCellColor(2, 2), [0xef, 0x29, 0x29, 255]); + await pollFor(page, () => getCellColor(3, 2), [0x8a, 0xe2, 0x34, 255]); + await pollFor(page, () => getCellColor(4, 2), [0xfc, 0xe9, 0x4f, 255]); + await pollFor(page, () => getCellColor(5, 2), [0x72, 0x9f, 0xcf, 255]); + await pollFor(page, () => getCellColor(6, 2), [0xad, 0x7f, 0xa8, 255]); + await pollFor(page, () => getCellColor(7, 2), [0x34, 0xe2, 0xe2, 255]); + await pollFor(page, () => getCellColor(8, 2), [0xee, 0xee, 0xec, 255]); + // Setting and check for minimum contrast values, note that these are note + // exact to the contrast ratio, if the increase luminance algorithm + // changes then these will probably fail + await page.evaluate(`window.term.setOption('minimumContrastRatio', 10);`); + await pollFor(page, () => getCellColor(1, 1), [46, 52, 54, 255]); + await pollFor(page, () => getCellColor(2, 1), [132, 0, 0, 255]); + await pollFor(page, () => getCellColor(3, 1), [78, 154, 6, 255]); + await pollFor(page, () => getCellColor(4, 1), [114, 93, 0, 255]); + await pollFor(page, () => getCellColor(5, 1), [19, 40, 68, 255]); + await pollFor(page, () => getCellColor(6, 1), [60, 40, 64, 255]); + await pollFor(page, () => getCellColor(7, 1), [0, 71, 72, 255]); + await pollFor(page, () => getCellColor(8, 1), [64, 64, 63, 255]); + await pollFor(page, () => getCellColor(1, 2), [61, 63, 59, 255]); + await pollFor(page, () => getCellColor(2, 2), [125, 19, 19, 255]); + await pollFor(page, () => getCellColor(3, 2), [89, 146, 32, 255]); + await pollFor(page, () => getCellColor(4, 2), [105, 98, 32, 255]); + await pollFor(page, () => getCellColor(5, 2), [36, 52, 70, 255]); + await pollFor(page, () => getCellColor(6, 2), [64, 45, 63, 255]); + await pollFor(page, () => getCellColor(7, 2), [13, 67, 67, 255]); + await pollFor(page, () => getCellColor(8, 2), [64, 64, 64, 255]); + }); + }); + + describe('selection', async () => { + before(async () => setupBrowser()); + after(async () => browser.close()); + beforeEach(async () => page.evaluate(`window.term.reset()`)); + + it('should resolve the inverse foreground color based on the original background color, not the selection', async () => { + const theme: ITheme = { + foreground: '#FF0000', + background: '#00FF00', + selection: '#0000FF' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + await writeSync(` █\\x1b[7m█\\x1b[0m`); + await pollFor(page, () => getCellColor(1, 1), [0, 255, 0, 255]); + await pollFor(page, () => getCellColor(2, 1), [255, 0, 0, 255]); + await pollFor(page, () => getCellColor(3, 1), [0, 255, 0, 255]); + await page.evaluate(`window.term.selectAll()`); + // Selection only cell needs to be first to ensure renderer has kicked in + await pollFor(page, () => getCellColor(1, 1), [0, 0, 255, 255]); + await pollFor(page, () => getCellColor(2, 1), [255, 0, 0, 255]); + await pollFor(page, () => getCellColor(3, 1), [0, 255, 0, 255]); + }); + }); + + describe('allowTransparency', async () => { + before(async () => setupBrowser({ rendererType: 'dom', allowTransparency: true})); + after(async () => browser.close()); + beforeEach(async () => page.evaluate(`window.term.reset()`)); + it('transparent background inverse', async () => { + const theme: ITheme = { + background: '#ff000080' + }; + await page.evaluate(`window.term.setOption('theme', ${JSON.stringify(theme)});`); + const data = `\\x1b[7m█\x1b[0m`; + await writeSync(data); + // Inverse background should be opaque + await pollFor(page, () => getCellColor(1, 1), [255, 0, 0, 255]); }); }); }); @@ -161,3 +893,48 @@ async function getCellColor(col: number, row: number): Promise { `); return await page.evaluate(`Array.from(window.result)`); } + +async function setupBrowser(options: ITerminalOptions = { rendererType: 'dom' }): Promise { + browser = await puppeteer.launch({ + headless: process.argv.indexOf('--headless') !== -1, + args: [`--window-size=${width},${height}`, `--no-sandbox`] + }); + page = (await browser.pages())[0]; + await page.setViewport({ width, height }); + await page.goto(APP); + await openTerminal(options); + await page.evaluate(` + window.addon = new WebglAddon(true); + window.term.loadAddon(window.addon); + `); +} + +export async function pollFor(page: puppeteer.Page, evalOrFn: string | (() => Promise), val: T, preFn?: () => Promise): Promise { + if (preFn) { + await preFn(); + } + const result = typeof evalOrFn === 'string' ? await page.evaluate(evalOrFn) : await evalOrFn(); + if (!deepEqual(result, val)) { + return new Promise(r => { + setTimeout(() => r(pollFor(page, evalOrFn, val, preFn)), 1); + }); + } +} + +const COLORS_16_TO_255 = [ + '#000000', '#00005f', '#000087', '#0000af', '#0000d7', '#0000ff', '#005f00', '#005f5f', '#005f87', '#005faf', '#005fd7', '#005fff', '#008700', '#00875f', '#008787', '#0087af', + '#0087d7', '#0087ff', '#00af00', '#00af5f', '#00af87', '#00afaf', '#00afd7', '#00afff', '#00d700', '#00d75f', '#00d787', '#00d7af', '#00d7d7', '#00d7ff', '#00ff00', '#00ff5f', + '#00ff87', '#00ffaf', '#00ffd7', '#00ffff', '#5f0000', '#5f005f', '#5f0087', '#5f00af', '#5f00d7', '#5f00ff', '#5f5f00', '#5f5f5f', '#5f5f87', '#5f5faf', '#5f5fd7', '#5f5fff', + '#5f8700', '#5f875f', '#5f8787', '#5f87af', '#5f87d7', '#5f87ff', '#5faf00', '#5faf5f', '#5faf87', '#5fafaf', '#5fafd7', '#5fafff', '#5fd700', '#5fd75f', '#5fd787', '#5fd7af', + '#5fd7d7', '#5fd7ff', '#5fff00', '#5fff5f', '#5fff87', '#5fffaf', '#5fffd7', '#5fffff', '#870000', '#87005f', '#870087', '#8700af', '#8700d7', '#8700ff', '#875f00', '#875f5f', + '#875f87', '#875faf', '#875fd7', '#875fff', '#878700', '#87875f', '#878787', '#8787af', '#8787d7', '#8787ff', '#87af00', '#87af5f', '#87af87', '#87afaf', '#87afd7', '#87afff', + '#87d700', '#87d75f', '#87d787', '#87d7af', '#87d7d7', '#87d7ff', '#87ff00', '#87ff5f', '#87ff87', '#87ffaf', '#87ffd7', '#87ffff', '#af0000', '#af005f', '#af0087', '#af00af', + '#af00d7', '#af00ff', '#af5f00', '#af5f5f', '#af5f87', '#af5faf', '#af5fd7', '#af5fff', '#af8700', '#af875f', '#af8787', '#af87af', '#af87d7', '#af87ff', '#afaf00', '#afaf5f', + '#afaf87', '#afafaf', '#afafd7', '#afafff', '#afd700', '#afd75f', '#afd787', '#afd7af', '#afd7d7', '#afd7ff', '#afff00', '#afff5f', '#afff87', '#afffaf', '#afffd7', '#afffff', + '#d70000', '#d7005f', '#d70087', '#d700af', '#d700d7', '#d700ff', '#d75f00', '#d75f5f', '#d75f87', '#d75faf', '#d75fd7', '#d75fff', '#d78700', '#d7875f', '#d78787', '#d787af', + '#d787d7', '#d787ff', '#d7af00', '#d7af5f', '#d7af87', '#d7afaf', '#d7afd7', '#d7afff', '#d7d700', '#d7d75f', '#d7d787', '#d7d7af', '#d7d7d7', '#d7d7ff', '#d7ff00', '#d7ff5f', + '#d7ff87', '#d7ffaf', '#d7ffd7', '#d7ffff', '#ff0000', '#ff005f', '#ff0087', '#ff00af', '#ff00d7', '#ff00ff', '#ff5f00', '#ff5f5f', '#ff5f87', '#ff5faf', '#ff5fd7', '#ff5fff', + '#ff8700', '#ff875f', '#ff8787', '#ff87af', '#ff87d7', '#ff87ff', '#ffaf00', '#ffaf5f', '#ffaf87', '#ffafaf', '#ffafd7', '#ffafff', '#ffd700', '#ffd75f', '#ffd787', '#ffd7af', + '#ffd7d7', '#ffd7ff', '#ffff00', '#ffff5f', '#ffff87', '#ffffaf', '#ffffd7', '#ffffff', '#080808', '#121212', '#1c1c1c', '#262626', '#303030', '#3a3a3a', '#444444', '#4e4e4e', + '#585858', '#626262', '#6c6c6c', '#767676', '#808080', '#8a8a8a', '#949494', '#9e9e9e', '#a8a8a8', '#b2b2b2', '#bcbcbc', '#c6c6c6', '#d0d0d0', '#dadada', '#e4e4e4', '#eeeeee' +]; diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index 1f28d5893..ae85d9a01 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -11,20 +11,15 @@ import { acquireCharAtlas } from './atlas/CharAtlasCache'; import { WebglCharAtlas } from './atlas/WebglCharAtlas'; import { RectangleRenderer } from './RectangleRenderer'; import { IWebGL2RenderingContext } from './Types'; -import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; -import { RenderModel, COMBINED_CHAR_BIT_MASK } from './RenderModel'; +import { RenderModel, COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel'; import { Disposable } from 'common/Lifecycle'; -import { DEFAULT_COLOR, CHAR_DATA_CHAR_INDEX, CHAR_DATA_CODE_INDEX, NULL_CELL_CODE } from 'common/buffer/Constants'; +import { NULL_CELL_CODE } from 'common/buffer/Constants'; import { Terminal, IEvent } from 'xterm'; -import { getLuminance } from './ColorUtils'; import { IRenderLayer } from './renderLayer/Types'; import { IRenderDimensions, IRenderer, IRequestRefreshRowsEvent } from 'browser/renderer/Types'; import { IColorSet } from 'browser/Types'; -import { FLAGS } from './Constants'; -import { getCompatAttr } from './CharDataCompat'; import { EventEmitter } from 'common/EventEmitter'; - -export const INDICIES_PER_CELL = 4; +import { CellData } from 'common/buffer/CellData'; export class WebglRenderer extends Disposable implements IRenderer { private _renderLayers: IRenderLayer[]; @@ -32,6 +27,7 @@ export class WebglRenderer extends Disposable implements IRenderer { private _devicePixelRatio: number; private _model: RenderModel = new RenderModel(); + private _workCell: CellData = new CellData(); private _canvas: HTMLCanvasElement; private _gl: IWebGL2RenderingContext; @@ -41,6 +37,7 @@ export class WebglRenderer extends Disposable implements IRenderer { public dimensions: IRenderDimensions; private _core: ITerminal; + private _isAttached: boolean; private _onRequestRefreshRows = new EventEmitter(); public get onRequestRefreshRows(): IEvent { return this._onRequestRefreshRows.event; } @@ -54,8 +51,6 @@ export class WebglRenderer extends Disposable implements IRenderer { this._core = (this._terminal)._core; - this._applyBgLuminanceBasedSelection(); - this._renderLayers = [ new LinkRenderLayer(this._core.screenElement, 2, this._colors, this._core), new CursorRenderLayer(this._core.screenElement, 3, this._colors, this._onRequestRefreshRows) @@ -95,6 +90,8 @@ export class WebglRenderer extends Disposable implements IRenderer { // Update dimensions and acquire char atlas this.onCharSizeChanged(); + + this._isAttached = document.body.contains(this._core.screenElement); } public dispose(): void { @@ -103,20 +100,12 @@ export class WebglRenderer extends Disposable implements IRenderer { super.dispose(); } - private _applyBgLuminanceBasedSelection(): void { - // HACK: This is needed until webgl renderer adds support for selection colors - if (getLuminance(this._colors.background) > 0.5) { - this._colors.selection = { css: '#000', rgba: 255 }; - } else { - this._colors.selection = { css: '#fff', rgba: 4294967295 }; - } + public get textureAtlas(): HTMLCanvasElement | undefined { + return this._charAtlas?.cacheCanvas; } public setColors(colors: IColorSet): void { this._colors = colors; - - this._applyBgLuminanceBasedSelection(); - // Clear layers and force a full render this._renderLayers.forEach(l => { l.setColors(this._terminal, this._colors); @@ -189,7 +178,6 @@ export class WebglRenderer extends Disposable implements IRenderer { this._rectangleRenderer.updateSelection(this._model.selection, columnSelectMode); this._glyphRenderer.updateSelection(this._model, columnSelectMode); - // TODO: #2102 Should this move to RenderCoordinator? this._onRequestRefreshRows.fire({ start: 0, end: this._terminal.rows - 1 }); } @@ -210,6 +198,8 @@ export class WebglRenderer extends Disposable implements IRenderer { */ private _refreshCharAtlas(): void { if (this.dimensions.scaledCharWidth <= 0 && this.dimensions.scaledCharHeight <= 0) { + // Mark as not attached so char atlas gets refreshed on next render + this._isAttached = false; return; } @@ -235,6 +225,16 @@ export class WebglRenderer extends Disposable implements IRenderer { } public renderRows(start: number, end: number): void { + if (!this._isAttached) { + if (document.body.contains(this._core.screenElement) && (this._core)._charSizeService.width && (this._core)._charSizeService.height) { + this._updateDimensions(); + this._refreshCharAtlas(); + this._isAttached = true; + } else { + return; + } + } + // Update render layers this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); @@ -259,52 +259,34 @@ export class WebglRenderer extends Disposable implements IRenderer { const line = terminal.buffer.lines.get(row)!; this._model.lineLengths[y] = 0; for (let x = 0; x < terminal.cols; x++) { - const charData = line.get(x); - const chars = charData[CHAR_DATA_CHAR_INDEX]; - let code = charData[CHAR_DATA_CODE_INDEX]; - const attr = getCompatAttr(line, x); // charData[CHAR_DATA_ATTR_INDEX]; - const i = ((y * terminal.cols) + x) * INDICIES_PER_CELL; + line.loadCell(x, this._workCell); + + const chars = this._workCell.getChars(); + let code = this._workCell.getCode(); + const i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; if (code !== NULL_CELL_CODE) { this._model.lineLengths[y] = x + 1; } // Nothing has changed, no updates needed - if (this._model.cells[i] === code && this._model.cells[i + 1] === attr) { + if (this._model.cells[i] === code && + this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._workCell.bg && + this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._workCell.fg) { continue; } - // Resolve bg and fg and cache in the model - const flags = attr >> 18; - let bg = attr & 0x1ff; - let fg = (attr >> 9) & 0x1ff; - - // If inverse flag is on, the foreground should become the background. - if (flags & FLAGS.INVERSE) { - const temp = bg; - bg = fg; - fg = temp; - if (fg === DEFAULT_COLOR) { - fg = INVERTED_DEFAULT_COLOR; - } - if (bg === DEFAULT_COLOR) { - bg = INVERTED_DEFAULT_COLOR; - } - } - const drawInBrightColor = terminal.options.drawBoldTextInBrightColors && !!(flags & FLAGS.BOLD) && fg < 8 && fg !== INVERTED_DEFAULT_COLOR; - fg += drawInBrightColor ? 8 : 0; - // Flag combined chars with a bit mask so they're easily identifiable if (chars.length > 1) { code = code | COMBINED_CHAR_BIT_MASK; } - this._model.cells[i ] = code; - this._model.cells[i + 1] = attr; - this._model.cells[i + 2] = bg; - this._model.cells[i + 3] = fg; + // Cache the results in the model + this._model.cells[i] = code; + this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._workCell.bg; + this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._workCell.fg; - this._glyphRenderer.updateCell(x, y, code, attr, bg, fg, chars); + this._glyphRenderer.updateCell(x, y, code, this._workCell.bg, this._workCell.fg, chars); } } this._rectangleRenderer.updateBackgrounds(this._model); diff --git a/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts b/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts index 857fa6287..8e26b1d6a 100644 --- a/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts +++ b/addons/xterm-addon-webgl/src/atlas/CharAtlasUtils.ts @@ -4,7 +4,7 @@ */ import { ICharAtlasConfig } from './Types'; -import { DEFAULT_COLOR } from 'common/buffer/Constants'; +import { Attributes } from 'common/buffer/Constants'; import { Terminal, FontWeight } from 'xterm'; import { IColorSet, IColor } from 'browser/Types'; @@ -21,9 +21,11 @@ export function generateConfig(scaledCharWidth: number, scaledCharHeight: number cursor: NULL_COLOR, cursorAccent: NULL_COLOR, selection: NULL_COLOR, + selectionOpaque: NULL_COLOR, // For the static char atlas, we only use the first 16 colors, but we need all 256 for the // dynamic character atlas. - ansi: colors.ansi.slice() + ansi: colors.ansi.slice(), + contrastCache: colors.contrastCache }; return { devicePixelRatio: window.devicePixelRatio, @@ -34,6 +36,8 @@ export function generateConfig(scaledCharWidth: number, scaledCharHeight: number fontWeight: terminal.getOption('fontWeight') as FontWeight, fontWeightBold: terminal.getOption('fontWeightBold') as FontWeight, allowTransparency: terminal.getOption('allowTransparency'), + drawBoldTextInBrightColors: terminal.getOption('drawBoldTextInBrightColors'), + minimumContrastRatio: terminal.getOption('minimumContrastRatio'), colors: clonedColors }; } @@ -52,10 +56,12 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean a.allowTransparency === b.allowTransparency && a.scaledCharWidth === b.scaledCharWidth && a.scaledCharHeight === b.scaledCharHeight && + a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors && + a.minimumContrastRatio === b.minimumContrastRatio && a.colors.foreground === b.colors.foreground && a.colors.background === b.colors.background; } export function is256Color(colorCode: number): boolean { - return colorCode < DEFAULT_COLOR; + return (colorCode & Attributes.CM_MASK) === Attributes.CM_P16 || (colorCode & Attributes.CM_MASK) === Attributes.CM_P256; } diff --git a/addons/xterm-addon-webgl/src/atlas/Types.d.ts b/addons/xterm-addon-webgl/src/atlas/Types.d.ts index 1de843e08..cd73393ce 100644 --- a/addons/xterm-addon-webgl/src/atlas/Types.d.ts +++ b/addons/xterm-addon-webgl/src/atlas/Types.d.ts @@ -25,5 +25,7 @@ export interface ICharAtlasConfig { scaledCharWidth: number; scaledCharHeight: number; allowTransparency: boolean; + drawBoldTextInBrightColors: boolean; + minimumContrastRatio: number; colors: IColorSet; } diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index 8d8e995bf..6c3369f0d 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -4,14 +4,14 @@ */ import { ICharAtlasConfig } from './Types'; -import { DIM_OPACITY, INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; +import { DIM_OPACITY } from 'browser/renderer/atlas/Constants'; import { IRasterizedGlyph, IBoundingBox, IRasterizedGlyphSet } from '../Types'; -import { DEFAULT_COLOR, DEFAULT_ATTR } from 'common/buffer/Constants'; -import { is256Color } from './CharAtlasUtils'; +import { DEFAULT_COLOR, Attributes } from 'common/buffer/Constants'; import { throwIfFalsy } from '../WebglUtils'; import { IColor } from 'browser/Types'; -import { FLAGS } from '../Constants'; import { IDisposable } from 'xterm'; +import { AttributeData } from 'common/buffer/AttributeData'; +import { channels, rgba } from 'browser/Color'; // In practice we're probably never going to exhaust a texture this large. For debugging purposes, // however, it can be useful to set this to a really tiny value, to verify that LRU eviction works. @@ -68,8 +68,12 @@ export class WebglCharAtlas implements IDisposable { public hasCanvasChanged = false; private _workBoundingBox: IBoundingBox = { top: 0, left: 0, bottom: 0, right: 0 }; + private _workAttributeData: AttributeData = new AttributeData(); - constructor(document: Document, private _config: ICharAtlasConfig) { + constructor( + document: Document, + private _config: ICharAtlasConfig + ) { this.cacheCanvas = document.createElement('canvas'); this.cacheCanvas.width = TEXTURE_WIDTH; this.cacheCanvas.height = TEXTURE_HEIGHT; @@ -82,9 +86,6 @@ export class WebglCharAtlas implements IDisposable { this._tmpCanvas.width = this._config.scaledCharWidth * 2 + TMP_CANVAS_GLYPH_PADDING * 2; this._tmpCanvas.height = this._config.scaledCharHeight + TMP_CANVAS_GLYPH_PADDING * 2; this._tmpCtx = throwIfFalsy(this._tmpCanvas.getContext('2d', {alpha: this._config.allowTransparency})); - - // This is useful for debugging - document.body.appendChild(this.cacheCanvas); } public dispose(): void { @@ -103,9 +104,11 @@ export class WebglCharAtlas implements IDisposable { protected _doWarmUp(): void { // Pre-fill with ASCII 33-126 for (let i = 33; i < 126; i++) { - const rasterizedGlyph = this._drawToCache(i, DEFAULT_ATTR, DEFAULT_COLOR, DEFAULT_COLOR); + const rasterizedGlyph = this._drawToCache(i, DEFAULT_COLOR, DEFAULT_COLOR); this._cacheMap[i] = { - [DEFAULT_ATTR]: rasterizedGlyph + [DEFAULT_COLOR]: { + [DEFAULT_COLOR]: rasterizedGlyph + } }; } } @@ -123,16 +126,23 @@ export class WebglCharAtlas implements IDisposable { return false; } - public getRasterizedGlyphCombinedChar(chars: string, attr: number, bg: number, fg: number): IRasterizedGlyph { + public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number): IRasterizedGlyph { let rasterizedGlyphSet = this._cacheMapCombined[chars]; if (!rasterizedGlyphSet) { rasterizedGlyphSet = {}; this._cacheMapCombined[chars] = rasterizedGlyphSet; } - let rasterizedGlyph = rasterizedGlyphSet[attr]; + let rasterizedGlyph: IRasterizedGlyph | undefined; + const rasterizedGlyphSetBg = rasterizedGlyphSet[bg]; + if (rasterizedGlyphSetBg) { + rasterizedGlyph = rasterizedGlyphSetBg[fg]; + } if (!rasterizedGlyph) { - rasterizedGlyph = this._drawToCache(chars, attr, bg, fg); - rasterizedGlyphSet[attr] = rasterizedGlyph; + rasterizedGlyph = this._drawToCache(chars, bg, fg); + if (!rasterizedGlyphSet[bg]) { + rasterizedGlyphSet[bg] = {}; + } + rasterizedGlyphSet[bg]![fg] = rasterizedGlyph; } return rasterizedGlyph; } @@ -140,16 +150,23 @@ export class WebglCharAtlas implements IDisposable { /** * Gets the glyphs texture coords, drawing the texture if it's not already */ - public getRasterizedGlyph(code: number, attr: number, bg: number, fg: number): IRasterizedGlyph { + public getRasterizedGlyph(code: number, bg: number, fg: number): IRasterizedGlyph { let rasterizedGlyphSet = this._cacheMap[code]; if (!rasterizedGlyphSet) { rasterizedGlyphSet = {}; this._cacheMap[code] = rasterizedGlyphSet; } - let rasterizedGlyph = rasterizedGlyphSet[attr]; + let rasterizedGlyph: IRasterizedGlyph | undefined; + const rasterizedGlyphSetBg = rasterizedGlyphSet[bg]; + if (rasterizedGlyphSetBg) { + rasterizedGlyph = rasterizedGlyphSetBg[fg]; + } if (!rasterizedGlyph) { - rasterizedGlyph = this._drawToCache(code, attr, bg, fg); - rasterizedGlyphSet[attr] = rasterizedGlyph; + rasterizedGlyph = this._drawToCache(code, bg, fg); + if (!rasterizedGlyphSet[bg]) { + rasterizedGlyphSet[bg] = {}; + } + rasterizedGlyphSet[bg]![fg] = rasterizedGlyph; } return rasterizedGlyph; } @@ -161,48 +178,165 @@ export class WebglCharAtlas implements IDisposable { return this._config.colors.ansi[idx]; } - private _getBackgroundColor(bg: number): IColor { + private _getBackgroundColor(bgColorMode: number, bgColor: number, inverse: boolean): IColor { if (this._config.allowTransparency) { // The background color might have some transparency, so we need to render it as fully // transparent in the atlas. Otherwise we'd end up drawing the transparent background twice // around the anti-aliased edges of the glyph, and it would look too dark. return TRANSPARENT_COLOR; - } else if (bg === INVERTED_DEFAULT_COLOR) { - return this._config.colors.foreground; - } else if (is256Color(bg)) { - return this._getColorFromAnsiIndex(bg); } - // TODO: Support true color - return this._config.colors.background; + + switch (bgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + return this._getColorFromAnsiIndex(bgColor); + case Attributes.CM_RGB: + const arr = AttributeData.toColorRGB(bgColor); + // TODO: This object creation is slow + return { + rgba: bgColor << 8, + css: `#${toPaddedHex(arr[0])}${toPaddedHex(arr[1])}${toPaddedHex(arr[2])}` + }; + case Attributes.CM_DEFAULT: + default: + if (inverse) { + return this._config.colors.foreground; + } + return this._config.colors.background; + } } - private _getForegroundColor(fg: number): IColor { - if (fg === INVERTED_DEFAULT_COLOR) { - return this._config.colors.background; - } else if (is256Color(fg)) { - return this._getColorFromAnsiIndex(fg); + private _getForegroundCss(bg: number, bgColorMode: number, bgColor: number, fg: number, fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean): string { + const minimumContrastCss = this._getMinimumContrastCss(bg, bgColorMode, bgColor, fg, fgColorMode, fgColor, inverse, bold); + if (minimumContrastCss) { + return minimumContrastCss; + } + + switch (fgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + if (this._config.drawBoldTextInBrightColors && bold && fgColor < 8) { + fgColor += 8; + } + return this._getColorFromAnsiIndex(fgColor).css; + case Attributes.CM_RGB: + const arr = AttributeData.toColorRGB(fgColor); + return channels.toCss(arr[0], arr[1], arr[2]); + case Attributes.CM_DEFAULT: + default: + if (inverse) { + const bg = this._config.colors.background.css; + if (bg.length === 9) { + // Remove bg alpha channel if present + return bg.substr(0, 7); + } + return bg; + } + return this._config.colors.foreground.css; } - // TODO: Support true color - return this._config.colors.foreground; } - private _drawToCache(code: number, attr: number, bg: number, fg: number): IRasterizedGlyph; - private _drawToCache(chars: string, attr: number, bg: number, fg: number): IRasterizedGlyph; - private _drawToCache(codeOrChars: number | string, attr: number, bg: number, fg: number): IRasterizedGlyph { + private _resolveBackgroundRgba(bgColorMode: number, bgColor: number, inverse: boolean): number { + switch (bgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + return this._getColorFromAnsiIndex(bgColor).rgba; + case Attributes.CM_RGB: + return bgColor << 8; + case Attributes.CM_DEFAULT: + default: + if (inverse) { + return this._config.colors.foreground.rgba; + } + return this._config.colors.background.rgba; + } + } + + private _resolveForegroundRgba(fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean): number { + switch (fgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + if (this._config.drawBoldTextInBrightColors && bold && fgColor < 8) { + fgColor += 8; + } + return this._getColorFromAnsiIndex(fgColor).rgba; + case Attributes.CM_RGB: + return fgColor << 8; + case Attributes.CM_DEFAULT: + default: + if (inverse) { + return this._config.colors.background.rgba; + } + return this._config.colors.foreground.rgba; + } + } + + private _getMinimumContrastCss(bg: number, bgColorMode: number, bgColor: number, fg: number, fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean): string | undefined { + if (this._config.minimumContrastRatio === 1) { + return undefined; + } + + // Try get from cache first + const adjustedColor = this._config.colors.contrastCache.getCss(bg, fg); + if (adjustedColor !== undefined) { + return adjustedColor || undefined; + } + + const bgRgba = this._resolveBackgroundRgba(bgColorMode, bgColor, inverse); + const fgRgba = this._resolveForegroundRgba(fgColorMode, fgColor, inverse, bold); + const result = rgba.ensureContrastRatio(bgRgba, fgRgba, this._config.minimumContrastRatio); + + if (!result) { + this._config.colors.contrastCache.setCss(bg, fg, null); + return undefined; + } + + const css = channels.toCss( + (result >> 24) & 0xFF, + (result >> 16) & 0xFF, + (result >> 8) & 0xFF + ); + this._config.colors.contrastCache.setCss(bg, fg, css); + + return css; + } + + private _drawToCache(code: number, bg: number, fg: number): IRasterizedGlyph; + private _drawToCache(chars: string, bg: number, fg: number): IRasterizedGlyph; + private _drawToCache(codeOrChars: number | string, bg: number, fg: number): IRasterizedGlyph { const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars; this.hasCanvasChanged = true; - const flags = attr >> 18; - - const bold = !!(flags & FLAGS.BOLD); - const dim = !!(flags & FLAGS.DIM); - const italic = !!(flags & FLAGS.ITALIC); - this._tmpCtx.save(); + this._workAttributeData.fg = fg; + this._workAttributeData.bg = bg; + + const invisible = !!this._workAttributeData.isInvisible(); + if (invisible) { + return NULL_RASTERIZED_GLYPH; + } + + const bold = !!this._workAttributeData.isBold(); + const inverse = !!this._workAttributeData.isInverse(); + const dim = !!this._workAttributeData.isDim(); + const italic = !!this._workAttributeData.isItalic(); + let fgColor = this._workAttributeData.getFgColor(); + let fgColorMode = this._workAttributeData.getFgColorMode(); + let bgColor = this._workAttributeData.getBgColor(); + let bgColorMode = this._workAttributeData.getBgColorMode(); + if (inverse) { + const temp = fgColor; + fgColor = bgColor; + bgColor = temp; + const temp2 = fgColorMode; + fgColorMode = bgColorMode; + bgColorMode = temp2; + } + // draw the background - const backgroundColor = this._getBackgroundColor(bg); + const backgroundColor = this._getBackgroundColor(bgColorMode, bgColor, inverse); // Use a 'copy' composite operation to clear any existing glyph out of _tmpCtxWithAlpha, regardless of // transparency in backgroundColor this._tmpCtx.globalCompositeOperation = 'copy'; @@ -217,7 +351,7 @@ export class WebglCharAtlas implements IDisposable { `${fontStyle} ${fontWeight} ${this._config.fontSize * this._config.devicePixelRatio}px ${this._config.fontFamily}`; this._tmpCtx.textBaseline = 'top'; - this._tmpCtx.fillStyle = this._getForegroundColor(fg).css; + this._tmpCtx.fillStyle = this._getForegroundCss(bg, bgColorMode, bgColor, fg, fgColorMode, fgColor, inverse, bold); // Apply alpha to dim the character if (dim) { @@ -398,3 +532,8 @@ function clearColor(imageData: ImageData, color: IColor): boolean { } return isEmpty; } + +function toPaddedHex(c: number): string { + const s = c.toString(16); + return s.length < 2 ? '0' + s : s; +} diff --git a/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts index 0e2d645e5..b897ed44e 100644 --- a/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts +++ b/addons/xterm-addon-webgl/src/renderLayer/BaseRenderLayer.ts @@ -4,7 +4,6 @@ */ import { IRenderLayer } from './Types'; -import { IGlyphIdentifier } from '../atlas/Types'; import { acquireCharAtlas } from '../atlas/CharAtlasCache'; import { Terminal } from 'xterm'; import { IColorSet } from 'browser/Types'; @@ -25,19 +24,6 @@ export abstract class BaseRenderLayer implements IRenderLayer { protected _charAtlas: WebglCharAtlas | undefined; - /** - * An object that's reused when drawing glyphs in order to reduce GC. - */ - private _currentGlyphIdentifier: IGlyphIdentifier = { - chars: '', - code: 0, - bg: 0, - fg: 0, - bold: false, - dim: false, - italic: false - }; - constructor( private _container: HTMLElement, id: string, @@ -167,11 +153,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param x The column to fill. * @param y The row to fill. */ - protected _fillLeftLineAtCell(x: number, y: number): void { + protected _fillLeftLineAtCell(x: number, y: number, width: number): void { this._ctx.fillRect( x * this._scaledCellWidth, y * this._scaledCellHeight, - window.devicePixelRatio, + window.devicePixelRatio * width, this._scaledCellHeight); } diff --git a/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts index 413f4fdd4..1072a1719 100644 --- a/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts +++ b/addons/xterm-addon-webgl/src/renderLayer/CursorRenderLayer.ts @@ -204,7 +204,7 @@ export class CursorRenderLayer extends BaseRenderLayer { private _renderBarCursor(terminal: Terminal, x: number, y: number, cell: ICellData): void { this._ctx.save(); this._ctx.fillStyle = this._colors.cursor.css; - this._fillLeftLineAtCell(x, y); + this._fillLeftLineAtCell(x, y, terminal.getOption('cursorWidth')); this._ctx.restore(); } diff --git a/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts b/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts index 5199a2609..9586433a9 100644 --- a/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts +++ b/addons/xterm-addon-webgl/typings/xterm-addon-webgl.d.ts @@ -10,6 +10,8 @@ declare module 'xterm-addon-webgl' { * An xterm.js addon that provides search functionality. */ export class WebglAddon implements ITerminalAddon { + public textureAtlas?: HTMLCanvasElement; + constructor(preserveDrawingBuffer?: boolean); /** diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 4daa09618..e641e716b 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -25,7 +25,11 @@ jobs: displayName: Cache node modules - script: yarn --frozen-lockfile displayName: 'Install dependencies and build' - - script: yarn test-unit-coverage --forbid-only + - script: | + yarn test-unit-coverage --forbid-only + EXIT_CODE=$? + ./node_modules/.bin/nyc report --reporter=cobertura + exit $EXIT_CODE displayName: 'Unit tests' - script: yarn lint displayName: 'Lint' @@ -125,6 +129,8 @@ jobs: condition: and(succeeded(), or(eq(variables['Build.SourceBranch'], 'refs/heads/master'), eq(variables['FORCE_RELEASE'], 'true'))) pool: vmImage: 'ubuntu-16.04' + variables: + PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1 steps: - task: NodeTool@0 inputs: @@ -138,6 +144,7 @@ jobs: inputs: key: yarn2 | $(Agent.OS) | yarn.lock path: node_modules + displayName: Cache node modules - script: yarn --frozen-lockfile displayName: 'Install dependencies and build' - script: NPM_AUTH_TOKEN="$(NPM_AUTH_TOKEN)" node ./bin/publish.js diff --git a/bin/extract_vtfeatures.js b/bin/extract_vtfeatures.js new file mode 100644 index 000000000..7b77639d9 --- /dev/null +++ b/bin/extract_vtfeatures.js @@ -0,0 +1,503 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + * + * Script to extract vt features documented in docstrings. + */ +const fs = require('fs'); +const Mustache = require('mustache'); + +/** + * regexp to fetch all comments + * Fetches all multiline comments and single lines containing '// @vt:'. + */ +const REX_COMMENTS = /^\s*?[/][*][*]([\s\S]*?)[*][/]|^\s*?\/\/ ([@]vt[:].*?)$/mug; + +/** + * regexp to parse the @vt line + * expected data - "@vt: "" "" "" + */ +const REX_VT_LINE = /^[@]vt\:\s*(\w+|#\w+|#\w+\[.*?\])\s*(\w+)\s*(\w+)\s*"(.*?)"\s*"(.*?)"\s*"(.*?)".*$/; + +// known vt command types +const TYPES = [ + 'C0', + 'C1', + 'ESC', + 'CSI', + 'DCS', + 'OSC', + 'APC', + 'PM', + 'SOS' +]; + +const MARKDOWN_TMPL = ` +{::options parse_block_html="true" /} + +xterm.js version: {{version}} + +## Table of Contents + + + + +## General notes + +This document lists xterm.js' support of terminal sequences. The sequences are grouped by their sequence type: + +- C0: single byte command (7bit control codes, byte range \`\\x00\` .. \`\\x1F\`, \`\\x7F\`) +- C1: single byte command (8bit control codes, byte range \`\\x80\` .. \`\\x9F\`) +- ESC: sequence starting with \`ESC\` (\`\\x1B\`) +- CSI - Control Sequence Introducer: sequence starting with \`ESC [\` (7bit) or CSI (\`\\x9B\`, 8bit) +- DCS - Device Control String: sequence starting with \`ESC P\` (7bit) or DCS (\`\\x90\`, 8bit) +- OSC - Operating System Command: sequence starting with \`ESC ]\` (7bit) or OSC (\`\\x9D\`, 8bit) + +Application Program Command (APC), Privacy Message (PM) and Start of String (SOS) are recognized but not supported, +any sequence of these types will be ignored. They are also not hookable by the API. + +Note that the list only contains sequences implemented in xterm.js' core codebase. Missing sequences are either +not supported or unstable/experimental. Furthermore addons or integrations can provide additional custom sequences. + +To denote the sequences the tables use the same abbreviations as xterm does: +- \`Ps\`: A single (usually optional) numeric parameter, composed of one or more decimal digits. +- \`Pm\`: A multiple numeric parameter composed of any number of single numeric parameters, separated by ; character(s), + e.g. \` Ps ; Ps ; ... \`. +- \`Pt\`: A text parameter composed of printable characters. Note that for most commands with \`Pt\` only + ASCII printables are specified to work. Additionally the parser will let pass any codepoint greater than C1 as printable. + + + +{{#C0.length}} +## C0 + +| Mnemonic | Name | Sequence | Short Description | Support | +| -------- | ---- | -------- | ----------------- | ------- | +{{#C0}} +| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} | +{{/C0}} + +{{#C0.hasLongDescriptions}} +{{#C0}} +{{#longDescription.length}} +
+ +### {{name}} +{{#longDescription}} +{{{.}}} +{{/longDescription}} + +
+{{/longDescription.length}} +{{/C0}} +{{/C0.hasLongDescriptions}} + +{{/C0.length}} + + +{{#C1.length}} +## C1 + +| Mnemonic | Name | Sequence | Short Description | Support | +| -------- | ---- | -------- | ----------------- | ------- | +{{#C1}} +| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} | +{{/C1}} + +{{#C1.hasLongDescriptions}} +{{#C1}} +{{#longDescription.length}} +
+ +### {{name}} +{{#longDescription}} +{{{.}}} +{{/longDescription}} + +
+{{/longDescription.length}} +{{/C1}} +{{/C1.hasLongDescriptions}} + +{{/C1.length}} + + +{{#CSI.length}} +## CSI + +| Mnemonic | Name | Sequence | Short Description | Support | +| -------- | ---- | -------- | ----------------- | ------- | +{{#CSI}} +| {{mnemonic}} | {{name}} | \`\`{{{sequence}}}\`\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} | +{{/CSI}} + +{{#CSI.hasLongDescriptions}} +{{#CSI}} +{{#longDescription.length}} +
+ +### {{name}} +{{#longDescription}} +{{{.}}} +{{/longDescription}} + +
+{{/longDescription.length}} +{{/CSI}} +{{/CSI.hasLongDescriptions}} + +{{/CSI.length}} + + +{{#DCS.length}} +## DCS + +| Mnemonic | Name | Sequence | Short Description | Support | +| -------- | ---- | -------- | ----------------- | ------- | +{{#DCS}} +| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} | +{{/DCS}} + +{{#DCS.hasLongDescriptions}} +{{#DCS}} +{{#longDescription.length}} +
+ +### {{name}} +{{#longDescription}} +{{{.}}} +{{/longDescription}} + +
+{{/longDescription.length}} +{{/DCS}} +{{/DCS.hasLongDescriptions}} + +{{/DCS.length}} + + +{{#ESC.length}} +## ESC + +| Mnemonic | Name | Sequence | Short Description | Support | +| -------- | ---- | -------- | ----------------- | ------- | +{{#ESC}} +| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} | +{{/ESC}} + +{{#ESC.hasLongDescriptions}} +{{#ESC}} +{{#longDescription.length}} +
+ +### {{name}} +{{#longDescription}} +{{{.}}} +{{/longDescription}} + +
+{{/longDescription.length}} +{{/ESC}} +{{/ESC.hasLongDescriptions}} + +{{/ESC.length}} + + +{{#OSC.length}} +## OSC + +**Note**: Other than listed in the table, the parser recognizes both ST (ECMA-48) and BEL (xterm) as OSC sequence finalizer. + +| Identifier | Sequence | Short Description | Support | +| ---------- | -------- | ----------------- | ------- | +{{#OSC}} +| {{mnemonic}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} | +{{/OSC}} + +{{#OSC.hasLongDescriptions}} +{{#OSC}} +{{#longDescription.length}} +
+ +### {{name}} +{{#longDescription}} +{{{.}}} +{{/longDescription}} + +
+{{/longDescription.length}} +{{/OSC}} +{{/OSC.hasLongDescriptions}} + +{{/OSC.length}} + + +` + +// support status marcos +// applied for: +// - status field of single @vt line +// - all lines in long description +const MACRO = [ + // #Y - supported + [/#Y/g, s => '✓'], + // #N - unsupported + [/#N/g, s => '✗'], + // #P[reason] - partial support with a reason as title + [/#P\[(.*?)\]/g, (s, p1) => `Partial`], + // #B[reason] - supported but broken in a certain way, reason in title + [/#B\[(.*?)\]/g, (s, p1) => `Broken`] +]; + +function applyMacros(s) { + for (let i = 0; i < MACRO.length; ++i) { + s = s.replace(MACRO[i][0], MACRO[i][1]); + } + return s; +} + +function replaceStatus(s) { + if (s === 'supported') return '✓'; + if (s === 'unsupported') return '✗'; + return s; +} + +function createAnchorSlug(s) { + return s.toLowerCase().split(' ').join('-'); +} + +function empty(ar) { + return !ar.filter(Boolean, ar).length; +} + +function* parseMultiLineGen(filename, s) { + if (!~s.indexOf('@vt:')) { + return; + } + const lines = s.split('\n').map(el => el.trim().replace(/[*]/, '').replace(/\s/, '')); + let grabLine = false; + let longDescription = []; + let feature = undefined; + let noLineCount = 0; + for (const line of lines) { + if (grabLine) { + if (!line) noLineCount++; + if (noLineCount >= 2) { + if (feature) { + feature.longDescription = empty(longDescription) ? [] : longDescription; + feature.longTarget = createAnchorSlug(feature.name); + yield feature; + } + grabLine = false; + longDescription = []; + feature = undefined; + noLineCount = 0; + } + else if (line.indexOf('@vt:') === 0) { + if (feature) { + feature.longDescription = empty(longDescription) ? [] : longDescription; + feature.longTarget = createAnchorSlug(feature.name); + yield feature; + } + grabLine = true; + longDescription = []; + feature = undefined; + noLineCount = 0; + } else { + //longDescription.push(line); + longDescription.push(applyMacros(line)); + if (line) noLineCount = 0; + } + } + if (line.indexOf('@vt:') === 0) { + feature = parseSingleLine(filename, line); + grabLine = true; + } + } + if (grabLine && feature) { + feature.longDescription = empty(longDescription) ? [] : longDescription; + feature.longTarget = createAnchorSlug(feature.name); + yield feature; + } +} + +function parseSingleLine(filename, s) { + const line = s.trim(); + const match = line.match(REX_VT_LINE); + if (match !== null) { + if (!~TYPES.indexOf(match[2])) { + throw new Error(`unkown vt-command type "${match[2]}" specified in "${filename}"`); + } + return { + status: match[1], + type: match[2], + mnemonic: match[3], + name: match[4], + sequence: match[5], + shortDescription: match[6], + longDescription: [], + longTarget: '', + source: filename + }; + } +} + +function getSorter(entry) { + switch (entry) { + case 'C0': + case 'C1': + // NOTE: expects hex value notation at last position in sequence + return (a, b) => parseInt(a.sequence.slice(-2), 16) - parseInt(b.sequence.slice(-2), 16); + case 'OSC': + // NOTE: expects the decimal function identifier in mnemonic + return (a, b) => parseInt(a.mnemonic) - parseInt(b.mnemonic); + case 'DCS': + return (a, b) => a.mnemonic > b.mnemonic; + case 'CSI': + case 'ESC': + // default sort order by final byte + return (a, b) => { + // ugly hack to fix sorting of workaround in HPA sequence "CSI Ps ` " + // for HPA compare with length - 2 instead + const HPA = 'CSI Ps ` '; + const aa = a.sequence === HPA ? a.sequence.slice(0, -1) : a.sequence; + const bb = b.sequence === HPA ? b.sequence.slice(0, -1) : b.sequence; + return aa.charCodeAt(aa.length - 1) - bb.charCodeAt(bb.length - 1); + }; + default: + return (a, b) => a.sequence > b.sequence; + } +}; + +function postProcessData(features) { + const featureTable = {}; + for (const feature of features) { + // feature.status = replaceStatus(feature.status); + feature.status = applyMacros(feature.status); + if (featureTable[feature.type] === undefined) { + featureTable[feature.type] = []; + } + featureTable[feature.type].push(feature); + if (feature.longDescription.length) { + featureTable[feature.type].hasLongDescriptions = true; + } + } + for (const entry in featureTable) { + featureTable[entry].sort(getSorter(entry)); + } + // console.error(featureTable); + featureTable.version = require('../package.json').version; + console.log(Mustache.render(MARKDOWN_TMPL, featureTable)); +} + +function main(filenames) { + // console.error(filenames); + let leftToProcess = filenames.length; + const features = []; + for (const filename of filenames) { + fs.readFile(filename, 'utf-8', (err, data) => { + let match; + while ((match = REX_COMMENTS.exec(data)) !== null) { + if (match.index === REX_COMMENTS.lastIndex) { + REX_COMMENTS.lastIndex++; + } + if (match[1]) { + for (let feature of parseMultiLineGen(filename, match[1])) { + if (feature) features.push(feature); + } + } else { + const feature = parseSingleLine(filename, match[2]); + if (feature) features.push(feature); + } + } + leftToProcess--; + if (!leftToProcess) { + postProcessData(features); + } + }); + } +} + +main(process.argv.slice(2)) diff --git a/bin/publish.js b/bin/publish.js index 9d58ed159..9050c344e 100644 --- a/bin/publish.js +++ b/bin/publish.js @@ -97,7 +97,7 @@ function getNextBetaVersion(packageJson) { const nextStableVersion = `${stableVersion[0]}.${parseInt(stableVersion[1]) + 1}.0`; const publishedVersions = getPublishedVersions(packageJson, nextStableVersion, tag); if (publishedVersions.length === 0) { - return `${nextStableVersion}-${tag}1`; + return `${nextStableVersion}-${tag}.1`; } const latestPublishedVersion = publishedVersions.sort((a, b) => { const aVersion = parseInt(a.substr(a.search(/[0-9]+$/))); @@ -105,14 +105,14 @@ function getNextBetaVersion(packageJson) { return aVersion > bVersion ? -1 : 1; })[0]; const latestTagVersion = parseInt(latestPublishedVersion.substr(latestPublishedVersion.search(/[0-9]+$/)), 10); - return `${nextStableVersion}-${tag}${latestTagVersion + 1}`; + return `${nextStableVersion}-${tag}.${latestTagVersion + 1}`; } function getPublishedVersions(packageJson, version, tag) { const versionsProcess = cp.spawnSync('npm', ['view', packageJson.name, 'versions', '--json']); const versionsJson = JSON.parse(versionsProcess.stdout); if (tag) { - return versionsJson.filter(v => !v.search(new RegExp(`${version}-${tag}[0-9]+`))); + return versionsJson.filter(v => !v.search(new RegExp(`${version}-${tag}.[0-9]+`))); } return versionsJson; } diff --git a/bin/test.js b/bin/test.js index bd1784fbb..1380a5122 100644 --- a/bin/test.js +++ b/bin/test.js @@ -14,7 +14,8 @@ env.NODE_PATH = path.resolve(__dirname, '../out'); let testFiles = [ './out/*test.js', - './out/**/*test.js' + './out/**/*test.js', + './addons/**/out/*test.js', ]; let flagArgs = []; diff --git a/demo/client.ts b/demo/client.ts index 5b76700ea..6174216c0 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -12,16 +12,20 @@ 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 { Unicode11Addon } from '../addons/xterm-addon-unicode11/out/Unicode11Addon'; // 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'; +// import { Unicode11Addon } from 'xterm-addon-unicode11'; // Pulling in the module's types relies on the above, it's looks a // little weird here as we're importing "this" module @@ -33,8 +37,10 @@ export interface IWindowWithTerminal extends Window { AttachAddon?: typeof AttachAddon; FitAddon?: typeof FitAddon; SearchAddon?: typeof SearchAddon; + SerializeAddon?: typeof SerializeAddon; WebLinksAddon?: typeof WebLinksAddon; WebglAddon?: typeof WebglAddon; + Unicode11Addon?: typeof Unicode11Addon; } declare let window: IWindowWithTerminal; @@ -44,7 +50,7 @@ let socketURL; let socket; let pid; -type AddonType = 'attach' | 'fit' | 'search' | 'web-links' | 'webgl'; +type AddonType = 'attach' | 'fit' | 'search' | 'serialize' | 'unicode11' | 'web-links' | 'webgl'; interface IDemoAddon { name: T; @@ -53,14 +59,18 @@ 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 : + T extends 'unicode11' ? typeof Unicode11Addon : 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 : + T extends 'unicode11' ? typeof Unicode11Addon : never; } @@ -68,8 +78,10 @@ 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 } + webgl: { name: 'webgl', ctor: WebglAddon, canChange: true }, + unicode11: { name: 'unicode11', ctor: Unicode11Addon, canChange: true } }; const terminalContainer = document.getElementById('terminal-container'); @@ -113,11 +125,14 @@ if (document.location.pathname === '/test') { window.AttachAddon = AttachAddon; window.FitAddon = FitAddon; window.SearchAddon = SearchAddon; + window.SerializeAddon = SerializeAddon; + window.Unicode11Addon = Unicode11Addon; window.WebLinksAddon = WebLinksAddon; window.WebglAddon = WebglAddon; } else { createTerminal(); document.getElementById('dispose').addEventListener('click', disposeRecreateButtonHandler); + document.getElementById('serialize').addEventListener('click', serializeButtonHandler); } function createTerminal(): void { @@ -133,12 +148,16 @@ 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.unicode11.instance = new Unicode11Addon(); + addons['web-links'].instance = new WebLinksAddon(); typedTerm.loadAddon(addons.fit.instance); + typedTerm.loadAddon(addons.search.instance); + typedTerm.loadAddon(addons.serialize.instance); + typedTerm.loadAddon(addons.unicode11.instance); + typedTerm.loadAddon(addons['web-links'].instance); window.term = term; // Expose `term` to window for debugging purposes term.onResize((size: { cols: number, rows: number }) => { @@ -239,12 +258,10 @@ function initOptions(term: TerminalType): void { // Internal only options 'cancelEvents', 'convertEol', - 'handler', - 'screenKeys', 'termName', - 'useFlowControl', // Complex option - 'theme' + 'theme', + 'windowOptions' ]; const stringOptions = { bellSound: null, @@ -343,7 +360,15 @@ function initAddons(term: TerminalType): void { if (checkbox.checked) { addon.instance = new addon.ctor(); term.loadAddon(addon.instance); + if (name === 'webgl') { + setTimeout(() => { + document.body.appendChild((addon.instance as WebglAddon).textureAtlas); + }, 0); + } } else { + if (name === 'webgl') { + document.body.removeChild((addon.instance as WebglAddon).textureAtlas); + } addon.instance!.dispose(); addon.instance = undefined; } @@ -377,3 +402,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 88c6bc4c0..4d5149f35 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 b061dfcbb..9c5fd0bd6 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 7a53302d7..4e1b50359 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/package.json b/package.json index 7ed717419..9f3c4a786 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xterm", "description": "Full xterm terminal, in your browser", - "version": "4.2.0", + "version": "4.3.0", "main": "lib/xterm.js", "style": "css/xterm.css", "types": "typings/xterm.d.ts", @@ -26,10 +26,12 @@ "benchmark": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json", "benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/test/benchmark/*benchmark.js", "benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/test/benchmark/*benchmark.js", - "clean": "rm -rf lib out addons/*/lib addons/*/out" + "clean": "rm -rf lib out addons/*/lib addons/*/out", + "vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts" }, "devDependencies": { "@types/chai": "^3.4.34", + "@types/deep-equal": "^1.0.1", "@types/glob": "^5.0.35", "@types/jsdom": "11.0.1", "@types/mocha": "^2.2.33", @@ -39,12 +41,14 @@ "@types/webpack": "^4.4.11", "@types/ws": "^6.0.1", "chai": "3.5.0", + "deep-equal": "^1.1.0", "express": "^4.17.1", "express-ws": "^4.0.0", "glob": "^7.0.5", "jsdom": "^11.11.0", "mocha": "^6.1.4", - "node-pty": "0.7.6", + "mustache": "^3.0.1", + "node-pty": "^0.9.0", "nyc": "13", "puppeteer": "^1.15.0", "source-map-loader": "^0.2.4", diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index d8defa1cc..de43022b1 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -7,13 +7,13 @@ import { assert, expect } from 'chai'; import { InputHandler } from './InputHandler'; import { MockInputHandlingTerminal, TestTerminal } from './TestUtils.test'; import { Terminal } from './Terminal'; -import { IBufferLine } from 'common/Types'; +import { IBufferLine, IAttributeData } from 'common/Types'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; import { Attributes } from 'common/buffer/Constants'; import { AttributeData } from 'common/buffer/AttributeData'; import { Params } from 'common/parser/Params'; -import { MockCoreService, MockBufferService, MockDirtyRowService, MockOptionsService, MockLogService, MockCoreMouseService } from 'common/TestUtils.test'; +import { MockCoreService, MockBufferService, MockDirtyRowService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService } from 'common/TestUtils.test'; import { IBufferService } from 'common/services/Services'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { clone } from 'common/Clone'; @@ -33,34 +33,38 @@ function getLines(term: TestTerminal, limit: number = term.rows): string[] { return res; } +class TestInputHandler extends InputHandler { + get curAttrData(): IAttributeData { return (this as any)._curAttrData; } +} + describe('InputHandler', () => { describe('save and restore cursor', () => { const terminal = new MockInputHandlingTerminal(); - terminal.curAttrData.fg = 3; const bufferService = new MockBufferService(80, 30); bufferService.buffer.x = 1; bufferService.buffer.y = 2; bufferService.buffer.ybase = 0; - const inputHandler = new InputHandler(terminal, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); + const inputHandler = new TestInputHandler(terminal, bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService(), {} as any); + inputHandler.curAttrData.fg = 3; // Save cursor position inputHandler.saveCursor(); assert.equal(bufferService.buffer.x, 1); assert.equal(bufferService.buffer.y, 2); - assert.equal(terminal.curAttrData.fg, 3); + assert.equal(inputHandler.curAttrData.fg, 3); // Change cursor position bufferService.buffer.x = 10; bufferService.buffer.y = 20; - terminal.curAttrData.fg = 30; + inputHandler.curAttrData.fg = 30; // Restore cursor position inputHandler.restoreCursor(); assert.equal(bufferService.buffer.x, 1); assert.equal(bufferService.buffer.y, 2); - assert.equal(terminal.curAttrData.fg, 3); + assert.equal(inputHandler.curAttrData.fg, 3); }); describe('setCursorStyle', () => { it('should call Terminal.setOption with correct params', () => { const optionsService = new MockOptionsService(); - const inputHandler = new InputHandler(new MockInputHandlingTerminal(), new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService()); + const inputHandler = new InputHandler(new MockInputHandlingTerminal(), new MockBufferService(80, 30), new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService(), {} as any); inputHandler.setCursorStyle(Params.fromArray([0])); assert.equal(optionsService.options['cursorStyle'], 'block'); @@ -101,7 +105,7 @@ describe('InputHandler', () => { it('should toggle Terminal.bracketedPasteMode', () => { const terminal = new MockInputHandlingTerminal(); terminal.bracketedPasteMode = false; - const inputHandler = new InputHandler(terminal, new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); + const inputHandler = new InputHandler(terminal, new MockBufferService(80, 30), new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService(), {} as any); // Set bracketed paste mode inputHandler.setModePrivate(Params.fromArray([2004])); assert.equal(terminal.bracketedPasteMode, true); @@ -120,7 +124,7 @@ describe('InputHandler', () => { it('insertChars', function(): void { const term = new Terminal(); const bufferService = new MockBufferService(80, 30); - const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); + const inputHandler = new InputHandler(term, bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService(), {} as any); // insert some data in first and second line inputHandler.parse(Array(bufferService.cols - 9).join('a')); @@ -158,7 +162,7 @@ describe('InputHandler', () => { it('deleteChars', function(): void { const term = new Terminal(); const bufferService = new MockBufferService(80, 30); - const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); + const inputHandler = new InputHandler(term, bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService(), {} as any); // insert some data in first and second line inputHandler.parse(Array(bufferService.cols - 9).join('a')); @@ -199,7 +203,7 @@ describe('InputHandler', () => { it('eraseInLine', function(): void { const term = new Terminal(); const bufferService = new MockBufferService(80, 30); - const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); + const inputHandler = new InputHandler(term, bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService(), {} as any); // fill 6 lines to test 3 different states inputHandler.parse(Array(bufferService.cols + 1).join('a')); @@ -228,7 +232,7 @@ describe('InputHandler', () => { it('eraseInDisplay', function(): void { const term = new Terminal({cols: 80, rows: 7}); const bufferService = new MockBufferService(80, 7); - const inputHandler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); + const inputHandler = new InputHandler(term, bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService(), {} as any); // fill display with a's for (let i = 0; i < bufferService.rows; ++i) inputHandler.parse(Array(bufferService.cols + 1).join('a')); @@ -363,7 +367,7 @@ describe('InputHandler', () => { describe('print', () => { it('should not cause an infinite loop (regression test)', () => { const term = new Terminal(); - const inputHandler = new InputHandler(term, new MockBufferService(80, 30), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); + const inputHandler = new InputHandler(term, new MockBufferService(80, 30), new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService(), {} as any); const container = new Uint32Array(10); container[0] = 0x200B; inputHandler.print(container, 0, 1); @@ -378,7 +382,7 @@ describe('InputHandler', () => { beforeEach(() => { term = new Terminal(); bufferService = new MockBufferService(80, 30); - handler = new InputHandler(term, bufferService, new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService()); + handler = new InputHandler(term, bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService(), {} as any); }); it('should handle DECSET/DECRST 47 (alt screen buffer)', () => { handler.parse('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST'); @@ -1266,4 +1270,179 @@ describe('InputHandler', () => { [131072, 131072], [131072, 131072], [131072, 300000 - 131072 - 131072] ]); }); + describe('windowOptions', () => { + it('all should be disabled by default and not report', () => { + const term = new TestTerminal({cols: 10, rows: 10}); + const stack: string[] = []; + term.onData(data => stack.push(data)); + term.writeSync('\x1b[14t'); + term.writeSync('\x1b[16t'); + term.writeSync('\x1b[18t'); + term.writeSync('\x1b[20t'); + term.writeSync('\x1b[21t'); + assert.deepEqual(stack, []); + }); + it('14 - GetWinSizePixels', () => { + const term = new TestTerminal({cols: 10, rows: 10, windowOptions: {getWinSizePixels: true}}); + const stack: string[] = []; + term.onData(data => stack.push(data)); + term.writeSync('\x1b[14t'); + // does not report in test terminal due to missing renderer + assert.deepEqual(stack, []); + }); + it('16 - GetCellSizePixels', () => { + const term = new TestTerminal({cols: 10, rows: 10, windowOptions: {getCellSizePixels: true}}); + const stack: string[] = []; + term.onData(data => stack.push(data)); + term.writeSync('\x1b[16t'); + // does not report in test terminal due to missing renderer + assert.deepEqual(stack, []); + }); + it('18 - GetWinSizeChars', () => { + const term = new TestTerminal({cols: 10, rows: 10, windowOptions: {getWinSizeChars: true}}); + const stack: string[] = []; + term.onData(data => stack.push(data)); + term.writeSync('\x1b[18t'); + assert.deepEqual(stack, ['\x1b[8;10;10t']); + term.resize(50, 20); + term.writeSync('\x1b[18t'); + assert.deepEqual(stack, ['\x1b[8;10;10t', '\x1b[8;20;50t']); + }); + it('22/23 - PushTitle/PopTitle', () => { + const term = new TestTerminal({cols: 10, rows: 10, windowOptions: {pushTitle: true, popTitle: true}}); + const stack: string[] = []; + term.onTitleChange(data => stack.push(data)); + term.writeSync('\x1b]0;1\x07'); + term.writeSync('\x1b[22t'); + term.writeSync('\x1b]0;2\x07'); + term.writeSync('\x1b[22t'); + term.writeSync('\x1b]0;3\x07'); + term.writeSync('\x1b[22t'); + assert.deepEqual((term as any)._inputHandler._windowTitleStack, ['1', '2', '3']); + assert.deepEqual((term as any)._inputHandler._iconNameStack, ['1', '2', '3']); + assert.deepEqual(stack, ['1', '2', '3']); + term.writeSync('\x1b[23t'); + term.writeSync('\x1b[23t'); + term.writeSync('\x1b[23t'); + term.writeSync('\x1b[23t'); // one more to test "overflow" + assert.deepEqual((term as any)._inputHandler._windowTitleStack, []); + assert.deepEqual((term as any)._inputHandler._iconNameStack, []); + assert.deepEqual(stack, ['1', '2', '3', '3', '2', '1']); + }); + it('22/23 - PushTitle/PopTitle with ;1', () => { + const term = new TestTerminal({cols: 10, rows: 10, windowOptions: {pushTitle: true, popTitle: true}}); + const stack: string[] = []; + term.onTitleChange(data => stack.push(data)); + term.writeSync('\x1b]0;1\x07'); + term.writeSync('\x1b[22;1t'); + term.writeSync('\x1b]0;2\x07'); + term.writeSync('\x1b[22;1t'); + term.writeSync('\x1b]0;3\x07'); + term.writeSync('\x1b[22;1t'); + assert.deepEqual((term as any)._inputHandler._windowTitleStack, []); + assert.deepEqual((term as any)._inputHandler._iconNameStack, ['1', '2', '3']); + assert.deepEqual(stack, ['1', '2', '3']); + term.writeSync('\x1b[23;1t'); + term.writeSync('\x1b[23;1t'); + term.writeSync('\x1b[23;1t'); + term.writeSync('\x1b[23;1t'); // one more to test "overflow" + assert.deepEqual((term as any)._inputHandler._windowTitleStack, []); + assert.deepEqual((term as any)._inputHandler._iconNameStack, []); + assert.deepEqual(stack, ['1', '2', '3']); + }); + it('22/23 - PushTitle/PopTitle with ;2', () => { + const term = new TestTerminal({cols: 10, rows: 10, windowOptions: {pushTitle: true, popTitle: true}}); + const stack: string[] = []; + term.onTitleChange(data => stack.push(data)); + term.writeSync('\x1b]0;1\x07'); + term.writeSync('\x1b[22;2t'); + term.writeSync('\x1b]0;2\x07'); + term.writeSync('\x1b[22;2t'); + term.writeSync('\x1b]0;3\x07'); + term.writeSync('\x1b[22;2t'); + assert.deepEqual((term as any)._inputHandler._windowTitleStack, ['1', '2', '3']); + assert.deepEqual((term as any)._inputHandler._iconNameStack, []); + assert.deepEqual(stack, ['1', '2', '3']); + term.writeSync('\x1b[23;2t'); + term.writeSync('\x1b[23;2t'); + term.writeSync('\x1b[23;2t'); + term.writeSync('\x1b[23;2t'); // one more to test "overflow" + assert.deepEqual((term as any)._inputHandler._windowTitleStack, []); + assert.deepEqual((term as any)._inputHandler._iconNameStack, []); + assert.deepEqual(stack, ['1', '2', '3', '3', '2', '1']); + }); + it('DECCOLM - should only work with "SetWinLines" (24) enabled', () => { + // disabled + const term = new TestTerminal({cols: 10, rows: 10}); + term.writeSync('\x1b[?3l'); + assert.equal((term as any)._bufferService.cols, 10); + term.writeSync('\x1b[?3h'); + assert.equal((term as any)._bufferService.cols, 10); + // enabled + const term2 = new TestTerminal({cols: 10, rows: 10, windowOptions: {setWinLines: true}}); + term2.writeSync('\x1b[?3l'); + assert.equal((term2 as any)._bufferService.cols, 80); + term2.writeSync('\x1b[?3h'); + assert.equal((term2 as any)._bufferService.cols, 132); + }); + }); + describe('should correctly reset cells taken by wide chars', () => { + let term: TestTerminal; + beforeEach(() => { + term = new TestTerminal({cols: 10, rows: 5, scrollback: 1}); + term.writeSync('¥¥¥¥¥¥¥¥¥¥¥¥¥¥¥¥¥¥¥¥'); + }); + it('print', () => { + term.writeSync('\x1b[H#'); + assert.deepEqual(getLines(term), ['# ¥¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[1;6H######'); + assert.deepEqual(getLines(term), ['# ¥ #####', '# ¥¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('#'); + assert.deepEqual(getLines(term), ['# ¥ #####', '##¥¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('#'); + assert.deepEqual(getLines(term), ['# ¥ #####', '### ¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[3;9H#'); + assert.deepEqual(getLines(term), ['# ¥ #####', '### ¥¥¥', '¥¥¥¥#', '¥¥¥¥¥', '']); + term.writeSync('#'); + assert.deepEqual(getLines(term), ['# ¥ #####', '### ¥¥¥', '¥¥¥¥##', '¥¥¥¥¥', '']); + term.writeSync('#'); + assert.deepEqual(getLines(term), ['# ¥ #####', '### ¥¥¥', '¥¥¥¥##', '# ¥¥¥¥', '']); + term.writeSync('\x1b[4;10H#'); + assert.deepEqual(getLines(term), ['# ¥ #####', '### ¥¥¥', '¥¥¥¥##', '# ¥¥¥ #', '']); + }); + it('EL', () => { + term.writeSync('\x1b[1;6H\x1b[K#'); + assert.deepEqual(getLines(term), ['¥¥ #', '¥¥¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[2;5H\x1b[1K'); + assert.deepEqual(getLines(term), ['¥¥ #', ' ¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[3;6H\x1b[1K'); + assert.deepEqual(getLines(term), ['¥¥ #', ' ¥¥', ' ¥¥', '¥¥¥¥¥', '']); + }); + it('ICH', () => { + term.writeSync('\x1b[1;6H\x1b[@'); + assert.deepEqual(getLines(term), ['¥¥ ¥', '¥¥¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[2;4H\x1b[2@'); + assert.deepEqual(getLines(term), ['¥¥ ¥', '¥ ¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[3;4H\x1b[3@'); + assert.deepEqual(getLines(term), ['¥¥ ¥', '¥ ¥¥', '¥ ¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[4;4H\x1b[4@'); + assert.deepEqual(getLines(term), ['¥¥ ¥', '¥ ¥¥', '¥ ¥', '¥ ¥', '']); + }); + it('DCH', () => { + term.writeSync('\x1b[1;6H\x1b[P'); + assert.deepEqual(getLines(term), ['¥¥ ¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[2;6H\x1b[2P'); + assert.deepEqual(getLines(term), ['¥¥ ¥¥', '¥¥ ¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[3;6H\x1b[3P'); + assert.deepEqual(getLines(term), ['¥¥ ¥¥', '¥¥ ¥', '¥¥ ¥', '¥¥¥¥¥', '']); + }); + it('ECH', () => { + term.writeSync('\x1b[1;6H\x1b[X'); + assert.deepEqual(getLines(term), ['¥¥ ¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[2;6H\x1b[2X'); + assert.deepEqual(getLines(term), ['¥¥ ¥¥', '¥¥ ¥', '¥¥¥¥¥', '¥¥¥¥¥', '']); + term.writeSync('\x1b[3;6H\x1b[3X'); + assert.deepEqual(getLines(term), ['¥¥ ¥¥', '¥¥ ¥', '¥¥ ¥', '¥¥¥¥¥', '']); + }); + }); }); diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 3994d532c..fb37f4ab1 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -7,7 +7,6 @@ import { IInputHandler, IInputHandlingTerminal } from './Types'; import { C0, C1 } from 'common/data/EscapeSequences'; import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets'; -import { wcwidth } from 'common/CharWidth'; import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; import { Disposable } from 'common/Lifecycle'; import { concat } from 'common/TypedArrayUtils'; @@ -18,21 +17,84 @@ import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IFunctionId import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; import { AttributeData } from 'common/buffer/AttributeData'; -import { IAttributeData, IDisposable } from 'common/Types'; -import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService, ICoreMouseService } from 'common/services/Services'; +import { IAttributeData, IDisposable, IWindowOptions } from 'common/Types'; +import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, IInstantiationService } from 'common/services/Services'; import { OscHandler } from 'common/parser/OscParser'; import { DcsHandler } from 'common/parser/DcsParser'; +import { IRenderService } from 'browser/services/Services'; /** * Map collect to glevel. Used in `selectCharset`. */ const GLEVEL: {[key: string]: number} = {'(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2}; +/** + * VT commands done by the parser - FIXME: move this to the parser? + */ +// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence." +// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence." +// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence." +// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences." +// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message." +// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence." +// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence." +// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence." +// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence." +// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences." +// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message." +// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence." +// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored." +// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence." + +/** + * Document common VT features here that are currently unsupported + */ +// @vt: #N DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image starting at cursor position." +// @vt: #N OSC 1 "Set Icon Name" "OSC 1 ; Pt BEL" "Set icon name." + /** * Max length of the UTF32 input buffer. Real memory consumption is 4 times higher. */ const MAX_PARSEBUFFER_LENGTH = 131072; +/** + * Limit length of title and icon name stacks. + */ +const STACK_LIMIT = 10; + +// map params to window option +function paramToWindowOption(n: number, opts: IWindowOptions): boolean { + if (n > 24) { + return opts.setWinLines || false; + } + switch (n) { + case 1: return !!opts.restoreWin; + case 2: return !!opts.minimizeWin; + case 3: return !!opts.setWinPosition; + case 4: return !!opts.setWinSizePixels; + case 5: return !!opts.raiseWin; + case 6: return !!opts.lowerWin; + case 7: return !!opts.refreshWin; + case 8: return !!opts.setWinSizeChars; + case 9: return !!opts.maximizeWin; + case 10: return !!opts.fullscreenWin; + case 11: return !!opts.getWinState; + case 13: return !!opts.getWinPosition; + case 14: return !!opts.getWinSizePixels; + case 15: return !!opts.getScreenSizePixels; + case 16: return !!opts.getCellSizePixels; + case 18: return !!opts.getWinSizeChars; + case 19: return !!opts.getScreenSizeChars; + case 20: return !!opts.getIconTitle; + case 21: return !!opts.getWinTitle; + case 22: return !!opts.pushTitle; + case 23: return !!opts.popTitle; + case 24: return !!opts.setWinLines; + } + return false; +} + + /** * DCS subparser implementations @@ -43,6 +105,27 @@ const MAX_PARSEBUFFER_LENGTH = 131072; * DECRQSS (https://vt100.net/docs/vt510-rm/DECRQSS.html) * Request Status String (DECRQSS), VT420 and up. * Response: DECRPSS (https://vt100.net/docs/vt510-rm/DECRPSS.html) + * + * @vt: #P[See limited support below.] DCS DECRQSS "Request Selection or Setting" "DCS $ q Pt ST" "Request several terminal settings." + * Response is in the form `ESC P 1 $ r Pt ST` for valid requests, where `Pt` contains the corresponding CSI string, + * `ESC P 0 ST` for invalid requests. + * + * Supported requests and responses: + * + * | Type | Request | Response (`Pt`) | + * | -------------------------------- | ----------------- | ----------------------------------------------------- | + * | Graphic Rendition (SGR) | `DCS $ q m ST` | always reporting `0m` (currently broken) | + * | Top and Bottom Margins (DECSTBM) | `DCS $ q r ST` | `Ps ; Ps r` | + * | Cursor Style (DECSCUSR) | `DCS $ q SP q ST` | `Ps SP q` | + * | Protection Attribute (DECSCA) | `DCS $ q " q ST` | always reporting `0 " q` (DECSCA is unsupported) | + * | Conformance Level (DECSCL) | `DCS $ q " p ST` | always reporting `61 ; 1 " p` (DECSCL is unsupported) | + * + * + * TODO: + * - fix SGR report + * - either implement DECSCA or remove the report + * - either check which conformance is better suited or remove the report completely + * --> we are currently a mixture of all up to VT400 but dont follow anyone strictly */ class DECRQSS implements IDcsHandler { private _data: Uint32Array = new Uint32Array(0); @@ -74,7 +157,7 @@ class DECRQSS implements IDcsHandler { case '"q': // DECSCA return this._coreService.triggerDataEvent(`${C0.ESC}P1$r0"q${C0.ESC}\\`); case '"p': // DECSCL - return this._coreService.triggerDataEvent(`${C0.ESC}P1$r61"p${C0.ESC}\\`); + return this._coreService.triggerDataEvent(`${C0.ESC}P1$r61;1"p${C0.ESC}\\`); case 'r': // DECSTBM const pt = '' + (this._bufferService.buffer.scrollTop + 1) + ';' + (this._bufferService.buffer.scrollBottom + 1) + 'r'; @@ -99,18 +182,24 @@ class DECRQSS implements IDcsHandler { * DCS Ps; Ps| Pt ST * DECUDK (https://vt100.net/docs/vt510-rm/DECUDK.html) * not supported + * + * @vt: #N DCS DECUDK "User Defined Keys" "DCS Ps ; Ps | Pt ST" "Definitions for user-defined keys." */ /** * DCS + q Pt ST (xterm) * Request Terminfo String * not implemented + * + * @vt: #N DCS XTGETTCAP "Request Terminfo String" "DCS + q Pt ST" "Request Terminfo String." */ /** * DCS + p Pt ST (xterm) * Set Terminfo Data * not supported + * + * @vt: #N DCS XTSETTCAP "Set Terminfo Data" "DCS + p Pt ST" "Set Terminfo Data." */ @@ -127,7 +216,20 @@ export class InputHandler extends Disposable implements IInputHandler { private _stringDecoder: StringToUtf32 = new StringToUtf32(); private _utf8Decoder: Utf8ToUtf32 = new Utf8ToUtf32(); private _workCell: CellData = new CellData(); + private _windowTitle = ''; + private _iconName = ''; + private _windowTitleStack: string[] = []; + private _iconNameStack: string[] = []; + private _curAttrData: IAttributeData = DEFAULT_ATTR_DATA.clone(); + private _eraseAttrDataInternal: IAttributeData = DEFAULT_ATTR_DATA.clone(); + + private _onRequestRefreshRows = new EventEmitter(); + public get onRequestRefreshRows(): IEvent { return this._onRequestRefreshRows.event; } + private _onRequestReset = new EventEmitter(); + public get onRequestReset(): IEvent { return this._onRequestReset.event; } + private _onRequestBell = new EventEmitter(); + public get onRequestBell(): IEvent { return this._onRequestBell.event; } private _onCursorMove = new EventEmitter(); public get onCursorMove(): IEvent { return this._onCursorMove.event; } private _onLineFeed = new EventEmitter(); @@ -136,17 +238,19 @@ export class InputHandler extends Disposable implements IInputHandler { public get onScroll(): IEvent { return this._onScroll.event; } constructor( - protected _terminal: IInputHandlingTerminal, + private _terminal: IInputHandlingTerminal, private readonly _bufferService: IBufferService, + private readonly _charsetService: ICharsetService, private readonly _coreService: ICoreService, private readonly _dirtyRowService: IDirtyRowService, private readonly _logService: ILogService, private readonly _optionsService: IOptionsService, private readonly _coreMouseService: ICoreMouseService, + private readonly _unicodeService: IUnicodeService, + private readonly _instantiationService: IInstantiationService, private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()) { super(); - this.register(this._parser); /** @@ -222,6 +326,7 @@ export class InputHandler extends Disposable implements IInputHandler { this._parser.setCsiHandler({intermediates: ' ', final: 'q'}, params => this.setCursorStyle(params)); this._parser.setCsiHandler({final: 'r'}, params => this.setScrollRegion(params)); this._parser.setCsiHandler({final: 's'}, params => this.saveCursor(params)); + this._parser.setCsiHandler({final: 't'}, params => this.windowOptions(params)); this._parser.setCsiHandler({final: 'u'}, params => this.restoreCursor(params)); this._parser.setCsiHandler({intermediates: '\'', final: '}'}, params => this.insertColumns(params)); this._parser.setCsiHandler({intermediates: '\'', final: '~'}, params => this.deleteColumns(params)); @@ -248,8 +353,9 @@ export class InputHandler extends Disposable implements IInputHandler { * OSC handler */ // 0 - icon name + title - this._parser.setOscHandler(0, new OscHandler((data: string) => this.setTitle(data))); + this._parser.setOscHandler(0, new OscHandler((data: string) => { this.setTitle(data); this.setIconName(data); })); // 1 - icon name + this._parser.setOscHandler(1, new OscHandler((data: string) => this.setIconName(data))); // 2 - title this._parser.setOscHandler(2, new OscHandler((data: string) => this.setTitle(data))); // 3 - set property X in the form "prop=value" @@ -296,7 +402,7 @@ export class InputHandler extends Disposable implements IInputHandler { this._parser.setEscHandler({final: 'M'}, () => this.reverseIndex()); this._parser.setEscHandler({final: '='}, () => this.keypadApplicationMode()); this._parser.setEscHandler({final: '>'}, () => this.keypadNumericMode()); - this._parser.setEscHandler({final: 'c'}, () => this.reset()); + this._parser.setEscHandler({final: 'c'}, () => this.fullReset()); this._parser.setEscHandler({final: 'n'}, () => this.setgLevel(2)); this._parser.setEscHandler({final: 'o'}, () => this.setgLevel(3)); this._parser.setEscHandler({final: '|'}, () => this.setgLevel(3)); @@ -372,28 +478,34 @@ export class InputHandler extends Disposable implements IInputHandler { } // Refresh any dirty rows accumulated as part of parsing - this._terminal.refresh(this._dirtyRowService.start, this._dirtyRowService.end); + this._onRequestRefreshRows.fire(this._dirtyRowService.start, this._dirtyRowService.end); } public print(data: Uint32Array, start: number, end: number): void { let code: number; let chWidth: number; const buffer = this._bufferService.buffer; - const charset = this._terminal.charset; + const charset = this._charsetService.charset; const screenReaderMode = this._optionsService.options.screenReaderMode; const cols = this._bufferService.cols; - const wraparoundMode = this._terminal.wraparoundMode; + const wraparoundMode = this._coreService.decPrivateModes.wraparound; const insertMode = this._terminal.insertMode; - const curAttr = this._terminal.curAttrData; + const curAttr = this._curAttrData; let bufferRow = buffer.lines.get(buffer.y + buffer.ybase); this._dirtyRowService.markDirty(buffer.y); + + // handle wide chars: reset start_cell-1 if we would overwrite the second cell of a wide char + if (buffer.x && end - start > 0 && bufferRow.getWidth(buffer.x - 1) === 2) { + bufferRow.setCellFromCodePoint(buffer.x - 1, 0, 1, curAttr.fg, curAttr.bg); + } + for (let pos = start; pos < end; ++pos) { code = data[pos]; // calculate print space // expensive call, therefore we save width in line buffer - chWidth = wcwidth(code); + chWidth = this._unicodeService.wcwidth(code); // get charset replacement character // charset is only defined for ASCII, therefore we only @@ -410,7 +522,6 @@ export class InputHandler extends Disposable implements IInputHandler { } // insert combining char at last cursor position - // FIXME: needs handling after cursor jumps // buffer.x should never be 0 for a combining char // since they always follow a cell consuming char // therefore we can test for buffer.x to avoid overflow left @@ -427,10 +538,8 @@ export class InputHandler extends Disposable implements IInputHandler { } // goto next line if ch would overflow - // TODO: needs a global min terminal width of 2 - // FIXME: additionally ensure chWidth fits into a line - // --> maybe forbid cols= cols) { // autowrap - DECAWM // automatically wraps to the beginning of the next line @@ -439,7 +548,7 @@ export class InputHandler extends Disposable implements IInputHandler { buffer.y++; if (buffer.y === buffer.scrollBottom + 1) { buffer.y--; - this._terminal.scroll(true); + this._terminal.scroll(this._eraseAttrData(), true); } else { if (buffer.y >= this._bufferService.rows) { buffer.y = this._bufferService.rows - 1; @@ -463,7 +572,7 @@ export class InputHandler extends Disposable implements IInputHandler { // insert mode: move characters to right if (insertMode) { // right shift cells according to the width - bufferRow.insertCells(buffer.x, chWidth, buffer.getNullCell(curAttr)); + bufferRow.insertCells(buffer.x, chWidth, buffer.getNullCell(curAttr), curAttr); // test last cell - since the last cell has only room for // a halfwidth char any fullwidth shifted there is lost // and will be set to empty cell @@ -489,7 +598,7 @@ export class InputHandler extends Disposable implements IInputHandler { // This needs to check whether: // - fullwidth + surrogates: reset // - combining: only base char gets carried on (bug in xterm?) - if (end) { + if (end - start > 0) { bufferRow.loadCell(buffer.x - 1, this._workCell); if (this._workCell.getWidth() === 2 || this._workCell.getCode() > 0xFFFF) { this._parser.precedingCodepoint = 0; @@ -499,6 +608,12 @@ export class InputHandler extends Disposable implements IInputHandler { this._parser.precedingCodepoint = this._workCell.content; } } + + // handle wide chars: reset cell to the right if it is second cell of a wide char + if (buffer.x < cols && end - start > 0 && bufferRow.getWidth(buffer.x) === 0 && !bufferRow.hasContent(buffer.x)) { + bufferRow.setCellFromCodePoint(buffer.x, 0, 1, curAttr.fg, curAttr.bg); + } + this._dirtyRowService.markDirty(buffer.y); } @@ -506,6 +621,15 @@ export class InputHandler extends Disposable implements IInputHandler { * Forward addCsiHandler from parser. */ public addCsiHandler(id: IFunctionIdentifier, callback: (params: IParams) => boolean): IDisposable { + if (id.final === 't' && !id.prefix && !id.intermediates) { + // security: always check whether window option is allowed + return this._parser.addCsiHandler(id, params => { + if (!paramToWindowOption(params.params[0], this._optionsService.options.windowOptions)) { + return true; + } + return callback(params); + }); + } return this._parser.addCsiHandler(id, callback); } @@ -533,26 +657,37 @@ export class InputHandler extends Disposable implements IInputHandler { /** * BEL * Bell (Ctrl-G). + * + * @vt: #Y C0 BEL "Bell" "\a, \x07" "Ring the bell." + * The behavior of the bell is further customizable with `ITerminalOptions.bellStyle` + * and `ITerminalOptions.bellSound`. */ public bell(): void { - this._terminal.bell(); + this._onRequestBell.fire(); } /** * LF * Line Feed or New Line (NL). (LF is Ctrl-J). + * + * @vt: #Y C0 LF "Line Feed" "\n, \x0A" "Move the cursor one row down, scrolling if needed." + * Scrolling is restricted to scroll margins and will only happen on the bottom line. + * + * @vt: #Y C0 VT "Vertical Tabulation" "\v, \x0B" "Treated as LF." + * @vt: #Y C0 FF "Form Feed" "\f, \x0C" "Treated as LF." */ public lineFeed(): void { // make buffer local for faster access const buffer = this._bufferService.buffer; + this._dirtyRowService.markDirty(buffer.y); if (this._optionsService.options.convertEol) { buffer.x = 0; } buffer.y++; if (buffer.y === buffer.scrollBottom + 1) { buffer.y--; - this._terminal.scroll(); + this._terminal.scroll(this._eraseAttrData()); } else if (buffer.y >= this._bufferService.rows) { buffer.y = this._bufferService.rows - 1; } @@ -560,6 +695,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (buffer.x >= this._bufferService.cols) { buffer.x--; } + this._dirtyRowService.markDirty(buffer.y); this._onLineFeed.fire(); } @@ -567,6 +703,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CR * Carriage Return (Ctrl-M). + * + * @vt: #Y C0 CR "Carriage Return" "\r, \x0D" "Move the cursor to the beginning of the row." */ public carriageReturn(): void { this._bufferService.buffer.x = 0; @@ -575,6 +713,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * BS * Backspace (Ctrl-H). + * + * @vt: #Y C0 BS "Backspace" "\b, \x08" "Move the cursor one position to the left." */ public backspace(): void { this._restrictCursor(); @@ -586,6 +726,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * TAB * Horizontal Tab (HT) (Ctrl-I). + * + * @vt: #Y C0 HT "Horizontal Tabulation" "\t, \x09" "Move the cursor to the next character tab stop." */ public tab(): void { if (this._bufferService.buffer.x >= this._bufferService.cols) { @@ -602,18 +744,22 @@ export class InputHandler extends Disposable implements IInputHandler { * SO * Shift Out (Ctrl-N) -> Switch to Alternate Character Set. This invokes the * G1 character set. + * + * @vt: #P[Only limited ISO-2022 charset support.] C0 SO "Shift Out" "\x0E" "Switch to an alternative character set." */ public shiftOut(): void { - this._terminal.setgLevel(1); + this._charsetService.setgLevel(1); } /** * SI * Shift In (Ctrl-O) -> Switch to Standard Character Set. This invokes the G0 * character set (the default). + * + * @vt: #Y C0 SI "Shift In" "\x0F" "Return to regular character set after Shift Out." */ public shiftIn(): void { - this._terminal.setgLevel(0); + this._charsetService.setgLevel(0); } /** @@ -621,16 +767,18 @@ export class InputHandler extends Disposable implements IInputHandler { */ private _restrictCursor(): void { this._bufferService.buffer.x = Math.min(this._bufferService.cols - 1, Math.max(0, this._bufferService.buffer.x)); - this._bufferService.buffer.y = this._terminal.originMode + this._bufferService.buffer.y = this._coreService.decPrivateModes.origin ? Math.min(this._bufferService.buffer.scrollBottom, Math.max(this._bufferService.buffer.scrollTop, this._bufferService.buffer.y)) : Math.min(this._bufferService.rows - 1, Math.max(0, this._bufferService.buffer.y)); + this._dirtyRowService.markDirty(this._bufferService.buffer.y); } /** * Set absolute cursor position. */ private _setCursor(x: number, y: number): void { - if (this._terminal.originMode) { + this._dirtyRowService.markDirty(this._bufferService.buffer.y); + if (this._coreService.decPrivateModes.origin) { this._bufferService.buffer.x = x; this._bufferService.buffer.y = this._bufferService.buffer.scrollTop + y; } else { @@ -638,6 +786,7 @@ export class InputHandler extends Disposable implements IInputHandler { this._bufferService.buffer.y = y; } this._restrictCursor(); + this._dirtyRowService.markDirty(this._bufferService.buffer.y); } /** @@ -653,6 +802,9 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps A * Cursor Up Ps Times (default = 1) (CUU). + * + * @vt: #Y CSI CUU "Cursor Up" "CSI Ps A" "Move cursor `Ps` times up (default=1)." + * If the cursor would pass the top scroll margin, it will stop there. */ public cursorUp(params: IParams): void { // stop at scrollTop @@ -667,6 +819,9 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps B * Cursor Down Ps Times (default = 1) (CUD). + * + * @vt: #Y CSI CUD "Cursor Down" "CSI Ps B" "Move cursor `Ps` times down (default=1)." + * If the cursor would pass the bottom scroll margin, it will stop there. */ public cursorDown(params: IParams): void { // stop at scrollBottom @@ -681,6 +836,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps C * Cursor Forward Ps Times (default = 1) (CUF). + * + * @vt: #Y CSI CUF "Cursor Forward" "CSI Ps C" "Move cursor `Ps` times forward (default=1)." */ public cursorForward(params: IParams): void { this._moveCursor(params.params[0] || 1, 0); @@ -689,6 +846,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps D * Cursor Backward Ps Times (default = 1) (CUB). + * + * @vt: #Y CSI CUB "Cursor Backward" "CSI Ps D" "Move cursor `Ps` times backward (default=1)." */ public cursorBackward(params: IParams): void { this._moveCursor(-(params.params[0] || 1), 0); @@ -698,6 +857,9 @@ export class InputHandler extends Disposable implements IInputHandler { * CSI Ps E * Cursor Next Line Ps Times (default = 1) (CNL). * Other than cursorDown (CUD) also set the cursor to first column. + * + * @vt: #Y CSI CNL "Cursor Next Line" "CSI Ps E" "Move cursor `Ps` times down (default=1) and to the first column." + * Same as CUD, additionally places the cursor at the first column. */ public cursorNextLine(params: IParams): void { this.cursorDown(params); @@ -708,6 +870,9 @@ export class InputHandler extends Disposable implements IInputHandler { * CSI Ps F * Cursor Previous Line Ps Times (default = 1) (CPL). * Other than cursorUp (CUU) also set the cursor to first column. + * + * @vt: #Y CSI CPL "Cursor Backward" "CSI Ps F" "Move cursor `Ps` times up (default=1) and to the first column." + * Same as CUU, additionally places the cursor at the first column. */ public cursorPrecedingLine(params: IParams): void { this.cursorUp(params); @@ -717,6 +882,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps G * Cursor Character Absolute [column] (default = [row,1]) (CHA). + * + * @vt: #Y CSI CHA "Cursor Horizontal Absolute" "CSI Ps G" "Move cursor to `Ps`-th column of the active row (default=1)." */ public cursorCharAbsolute(params: IParams): void { this._setCursor((params.params[0] || 1) - 1, this._bufferService.buffer.y); @@ -725,6 +892,11 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps ; Ps H * Cursor Position [row;column] (default = [1,1]) (CUP). + * + * @vt: #Y CSI CUP "Cursor Position" "CSI Ps ; Ps H" "Set cursor to position [`Ps`, `Ps`] (default = [1, 1])." + * If ORIGIN mode is set, places the cursor to the absolute position within the scroll margins. + * If ORIGIN mode is not set, places the cursor to the absolute position within the viewport. + * Note that the coordinates are 1-based, thus the top left position starts at `1 ; 1`. */ public cursorPosition(params: IParams): void { this._setCursor( @@ -738,6 +910,8 @@ export class InputHandler extends Disposable implements IInputHandler { * CSI Pm ` Character Position Absolute * [column] (default = [row,1]) (HPA). * Currently same functionality as CHA. + * + * @vt: #Y CSI HPA "Horizontal Position Absolute" "CSI Ps ` " "Same as CHA." */ public charPosAbsolute(params: IParams): void { this._setCursor((params.params[0] || 1) - 1, this._bufferService.buffer.y); @@ -746,7 +920,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Pm a Character Position Relative * [columns] (default = [row,col+1]) (HPR) - * Currently same functionality as CUF. + * + * @vt: #Y CSI HPR "Horizontal Position Relative" "CSI Ps a" "Same as CUF." */ public hPositionRelative(params: IParams): void { this._moveCursor(params.params[0] || 1, 0); @@ -755,6 +930,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Pm d Vertical Position Absolute (VPA) * [row] (default = [1,column]) + * + * @vt: #Y CSI VPA "Vertical Position Absolute" "CSI Ps d" "Move cursor to `Ps`-th row (default=1)." */ public linePosAbsolute(params: IParams): void { this._setCursor(this._bufferService.buffer.x, (params.params[0] || 1) - 1); @@ -764,6 +941,8 @@ export class InputHandler extends Disposable implements IInputHandler { * CSI Pm e Vertical Position Relative (VPR) * [rows] (default = [row+1,column]) * reuse CSI Ps B ? + * + * @vt: #Y CSI VPR "Vertical Position Relative" "CSI Ps e" "Move cursor `Ps` times down (default=1)." */ public vPositionRelative(params: IParams): void { this._moveCursor(0, params.params[0] || 1); @@ -774,6 +953,8 @@ export class InputHandler extends Disposable implements IInputHandler { * Horizontal and Vertical Position [row;column] (default = * [1,1]) (HVP). * Same as CUP. + * + * @vt: #Y CSI HVP "Horizontal and Vertical Position" "CSI Ps ; Ps f" "Same as CUP." */ public hVPosition(params: IParams): void { this.cursorPosition(params); @@ -786,6 +967,9 @@ export class InputHandler extends Disposable implements IInputHandler { * Potentially: * Ps = 2 -> Clear Stops on Line. * http://vt100.net/annarbor/aaa-ug/section6.html + * + * @vt: #Y CSI TBC "Tab Clear" "CSI Ps g" "Clear tab stops at current position (0) or all (3) (default=0)." + * Clearing tabstops off the active row (Ps = 2, VT100) is currently not supported. */ public tabClear(params: IParams): void { const param = params.params[0]; @@ -799,6 +983,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps I * Cursor Forward Tabulation Ps tab stops (default = 1) (CHT). + * + * @vt: #Y CSI CHT "Cursor Horizontal Tabulation" "CSI Ps I" "Move cursor `Ps` times tabs forward (default=1)." */ public cursorForwardTab(params: IParams): void { if (this._bufferService.buffer.x >= this._bufferService.cols) { @@ -812,6 +998,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps Z Cursor Backward Tabulation Ps tab stops (default = 1) (CBT). + * + * @vt: #Y CSI CBT "Cursor Backward Tabulation" "CSI Ps Z" "Move cursor `Ps` tabs backward (default=1)." */ public cursorBackwardTab(params: IParams): void { if (this._bufferService.buffer.x >= this._bufferService.cols) { @@ -840,7 +1028,8 @@ export class InputHandler extends Disposable implements IInputHandler { line.replaceCells( start, end, - this._bufferService.buffer.getNullCell(this._terminal.eraseAttrData()) + this._bufferService.buffer.getNullCell(this._eraseAttrData()), + this._eraseAttrData() ); if (clearWrap) { line.isWrapped = false; @@ -854,7 +1043,7 @@ export class InputHandler extends Disposable implements IInputHandler { */ private _resetBufferLine(y: number): void { const line = this._bufferService.buffer.lines.get(this._bufferService.buffer.ybase + y); - line.fill(this._bufferService.buffer.getNullCell(this._terminal.eraseAttrData())); + line.fill(this._bufferService.buffer.getNullCell(this._eraseAttrData())); line.isWrapped = false; } @@ -869,6 +1058,18 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 0 -> Selective Erase Below (default). * Ps = 1 -> Selective Erase Above. * Ps = 2 -> Selective Erase All. + * + * @vt: #Y CSI ED "Erase In Display" "CSI Ps J" "Erase various parts of the viewport." + * Supported param values: + * + * | Ps | Effect | + * | -- | ------------------------------------------------------------ | + * | 0 | Erase from the cursor through the end of the viewport. | + * | 1 | Erase from the beginning of the viewport through the cursor. | + * | 2 | Erase complete viewport. | + * | 3 | Erase scrollback. | + * + * @vt: #P[Protection attributes are not supported.] CSI DECSED "Selective Erase In Display" "CSI ? Ps J" "Currently the same as ED." */ public eraseInDisplay(params: IParams): void { this._restrictCursor(); @@ -929,6 +1130,17 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 0 -> Selective Erase to Right (default). * Ps = 1 -> Selective Erase to Left. * Ps = 2 -> Selective Erase All. + * + * @vt: #Y CSI EL "Erase In Line" "CSI Ps K" "Erase various parts of the active row." + * Supported param values: + * + * | Ps | Effect | + * | -- | -------------------------------------------------------- | + * | 0 | Erase from the cursor through the end of the row. | + * | 1 | Erase from the beginning of the line through the cursor. | + * | 2 | Erase complete line. | + * + * @vt: #P[Protection attributes are not supported.] CSI DECSEL "Selective Erase In Line" "CSI ? Ps K" "Currently the same as EL." */ public eraseInLine(params: IParams): void { this._restrictCursor(); @@ -949,6 +1161,11 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps L * Insert Ps Line(s) (default = 1) (IL). + * + * @vt: #Y CSI IL "Insert Line" "CSI Ps L" "Insert `Ps` blank lines at active row (default=1)." + * For every inserted line at the scroll top one line at the scroll bottom gets removed. + * The cursor is set to the first column. + * IL has no effect if the cursor is outside the scroll margins. */ public insertLines(params: IParams): void { this._restrictCursor(); @@ -969,7 +1186,7 @@ export class InputHandler extends Disposable implements IInputHandler { // test: echo -e '\e[44m\e[1L\e[0m' // blankLine(true) - xterm/linux behavior buffer.lines.splice(scrollBottomAbsolute - 1, 1); - buffer.lines.splice(row, 0, buffer.getBlankLine(this._terminal.eraseAttrData())); + buffer.lines.splice(row, 0, buffer.getBlankLine(this._eraseAttrData())); } this._dirtyRowService.markRangeDirty(buffer.y, buffer.scrollBottom); @@ -979,6 +1196,11 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps M * Delete Ps Line(s) (default = 1) (DL). + * + * @vt: #Y CSI DL "Delete Line" "CSI Ps M" "Delete `Ps` lines at active row (default=1)." + * For every deleted line at the scroll top one blank line at the scroll bottom gets appended. + * The cursor is set to the first column. + * DL has no effect if the cursor is outside the scroll margins. */ public deleteLines(params: IParams): void { this._restrictCursor(); @@ -1000,7 +1222,7 @@ export class InputHandler extends Disposable implements IInputHandler { // test: echo -e '\e[44m\e[1M\e[0m' // blankLine(true) - xterm/linux behavior buffer.lines.splice(row, 1); - buffer.lines.splice(j, 0, buffer.getBlankLine(this._terminal.eraseAttrData())); + buffer.lines.splice(j, 0, buffer.getBlankLine(this._eraseAttrData())); } this._dirtyRowService.markRangeDirty(buffer.y, buffer.scrollBottom); @@ -1010,6 +1232,13 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps @ * Insert Ps (Blank) Character(s) (default = 1) (ICH). + * + * @vt: #Y CSI ICH "Insert Characters" "CSI Ps @" "Insert `Ps` (blank) characters (default = 1)." + * The ICH sequence inserts `Ps` blank characters. The cursor remains at the beginning of the blank characters. + * Text between the cursor and right margin moves to the right. Characters moved past the right margin are lost. + * + * + * FIXME: check against xterm - should not work outside of scroll margins (see VT520 manual) */ public insertChars(params: IParams): void { this._restrictCursor(); @@ -1018,7 +1247,8 @@ export class InputHandler extends Disposable implements IInputHandler { line.insertCells( this._bufferService.buffer.x, params.params[0] || 1, - this._bufferService.buffer.getNullCell(this._terminal.eraseAttrData()) + this._bufferService.buffer.getNullCell(this._eraseAttrData()), + this._eraseAttrData() ); this._dirtyRowService.markDirty(this._bufferService.buffer.y); } @@ -1027,6 +1257,13 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps P * Delete Ps Character(s) (default = 1) (DCH). + * + * @vt: #Y CSI DCH "Delete Character" "CSI Ps P" "Delete `Ps` characters (default=1)." + * As characters are deleted, the remaining characters between the cursor and right margin move to the left. + * Character attributes move with the characters. The terminal adds blank characters at the right margin. + * + * + * FIXME: check against xterm - should not work outside of scroll margins (see VT520 manual) */ public deleteChars(params: IParams): void { this._restrictCursor(); @@ -1035,7 +1272,8 @@ export class InputHandler extends Disposable implements IInputHandler { line.deleteCells( this._bufferService.buffer.x, params.params[0] || 1, - this._bufferService.buffer.getNullCell(this._terminal.eraseAttrData()) + this._bufferService.buffer.getNullCell(this._eraseAttrData()), + this._eraseAttrData() ); this._dirtyRowService.markDirty(this._bufferService.buffer.y); } @@ -1043,6 +1281,11 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps S Scroll up Ps lines (default = 1) (SU). + * + * @vt: #Y CSI SU "Scroll Up" "CSI Ps S" "Scroll `Ps` lines up (default=1)." + * + * + * FIXME: scrolled out lines at top = 1 should add to scrollback (xterm) */ public scrollUp(params: IParams): void { let param = params.params[0] || 1; @@ -1052,13 +1295,15 @@ export class InputHandler extends Disposable implements IInputHandler { while (param--) { buffer.lines.splice(buffer.ybase + buffer.scrollTop, 1); - buffer.lines.splice(buffer.ybase + buffer.scrollBottom, 0, buffer.getBlankLine(this._terminal.eraseAttrData())); + buffer.lines.splice(buffer.ybase + buffer.scrollBottom, 0, buffer.getBlankLine(this._eraseAttrData())); } this._dirtyRowService.markRangeDirty(buffer.scrollTop, buffer.scrollBottom); } /** * CSI Ps T Scroll down Ps lines (default = 1) (SD). + * + * @vt: #Y CSI SD "Scroll Down" "CSI Ps T" "Scroll `Ps` lines down (default=1)." */ public scrollDown(params: IParams): void { let param = params.params[0] || 1; @@ -1086,6 +1331,10 @@ export class InputHandler extends Disposable implements IInputHandler { * * Supported: * - always left shift (no line orientation setting respected) + * + * @vt: #Y CSI SL "Scroll Left" "CSI Ps SP @" "Scroll viewport `Ps` times to the left." + * SL moves the content of all lines within the scroll margins `Ps` times to the left. + * SL has no effect outside of the scroll margins. */ public scrollLeft(params: IParams): void { const buffer = this._bufferService.buffer; @@ -1095,7 +1344,7 @@ export class InputHandler extends Disposable implements IInputHandler { const param = params.params[0] || 1; for (let y = buffer.scrollTop; y <= buffer.scrollBottom; ++y) { const line = buffer.lines.get(buffer.ybase + y); - line.deleteCells(0, param, buffer.getNullCell(this._terminal.eraseAttrData())); + line.deleteCells(0, param, buffer.getNullCell(this._eraseAttrData()), this._eraseAttrData()); line.isWrapped = false; } this._dirtyRowService.markRangeDirty(buffer.scrollTop, buffer.scrollBottom); @@ -1114,6 +1363,11 @@ export class InputHandler extends Disposable implements IInputHandler { * * Supported: * - always right shift (no line orientation setting respected) + * + * @vt: #Y CSI SR "Scroll Right" "CSI Ps SP A" "Scroll viewport `Ps` times to the right." + * SL moves the content of all lines within the scroll margins `Ps` times to the right. + * Content at the right margin is lost. + * SL has no effect outside of the scroll margins. */ public scrollRight(params: IParams): void { const buffer = this._bufferService.buffer; @@ -1123,7 +1377,7 @@ export class InputHandler extends Disposable implements IInputHandler { const param = params.params[0] || 1; for (let y = buffer.scrollTop; y <= buffer.scrollBottom; ++y) { const line = buffer.lines.get(buffer.ybase + y); - line.insertCells(0, param, buffer.getNullCell(this._terminal.eraseAttrData())); + line.insertCells(0, param, buffer.getNullCell(this._eraseAttrData()), this._eraseAttrData()); line.isWrapped = false; } this._dirtyRowService.markRangeDirty(buffer.scrollTop, buffer.scrollBottom); @@ -1132,6 +1386,11 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Pm ' } * Insert Ps Column(s) (default = 1) (DECIC), VT420 and up. + * + * @vt: #Y CSI DECIC "Insert Columns" "CSI Ps ' }" "Insert `Ps` columns at cursor position." + * DECIC inserts `Ps` times blank columns at the cursor position for all lines with the scroll margins, + * moving content to the right. Content at the right margin is lost. + * DECIC has no effect outside the scrolling margins. */ public insertColumns(params: IParams): void { const buffer = this._bufferService.buffer; @@ -1141,7 +1400,7 @@ export class InputHandler extends Disposable implements IInputHandler { const param = params.params[0] || 1; for (let y = buffer.scrollTop; y <= buffer.scrollBottom; ++y) { const line = this._bufferService.buffer.lines.get(buffer.ybase + y); - line.insertCells(buffer.x, param, buffer.getNullCell(this._terminal.eraseAttrData())); + line.insertCells(buffer.x, param, buffer.getNullCell(this._eraseAttrData()), this._eraseAttrData()); line.isWrapped = false; } this._dirtyRowService.markRangeDirty(buffer.scrollTop, buffer.scrollBottom); @@ -1150,6 +1409,11 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Pm ' ~ * Delete Ps Column(s) (default = 1) (DECDC), VT420 and up. + * + * @vt: #Y CSI DECDC "Delete Columns" "CSI Ps ' ~" "Delete `Ps` columns at cursor position." + * DECDC deletes `Ps` times columns at the cursor position for all lines with the scroll margins, + * moving content to the left. Blank columns are added at the right margin. + * DECDC has no effect outside the scrolling margins. */ public deleteColumns(params: IParams): void { const buffer = this._bufferService.buffer; @@ -1159,7 +1423,7 @@ export class InputHandler extends Disposable implements IInputHandler { const param = params.params[0] || 1; for (let y = buffer.scrollTop; y <= buffer.scrollBottom; ++y) { const line = buffer.lines.get(buffer.ybase + y); - line.deleteCells(buffer.x, param, buffer.getNullCell(this._terminal.eraseAttrData())); + line.deleteCells(buffer.x, param, buffer.getNullCell(this._eraseAttrData()), this._eraseAttrData()); line.isWrapped = false; } this._dirtyRowService.markRangeDirty(buffer.scrollTop, buffer.scrollBottom); @@ -1168,6 +1432,10 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Ps X * Erase Ps Character(s) (default = 1) (ECH). + * + * @vt: #Y CSI ECH "Erase Character" "CSI Ps X" "Erase `Ps` characters from current cursor position to the right (default=1)." + * ED erases `Ps` characters from current cursor position to the right. + * ED works inside or outside the scrolling margins. */ public eraseChars(params: IParams): void { this._restrictCursor(); @@ -1176,7 +1444,8 @@ export class InputHandler extends Disposable implements IInputHandler { line.replaceCells( this._bufferService.buffer.x, this._bufferService.buffer.x + (params.params[0] || 1), - this._bufferService.buffer.getNullCell(this._terminal.eraseAttrData()) + this._bufferService.buffer.getNullCell(this._eraseAttrData()), + this._eraseAttrData() ); this._dirtyRowService.markDirty(this._bufferService.buffer.y); } @@ -1203,6 +1472,11 @@ export class InputHandler extends Disposable implements IInputHandler { * * Note: To get reset on a valid sequence working correctly without much runtime penalty, * the preceding codepoint is stored on the parser in `this.print` and reset during `parser.parse`. + * + * @vt: #Y CSI REP "Repeat Preceding Character" "CSI Ps b" "Repeat preceding character `Ps` times (default=1)." + * REP repeats the previous character `Ps` times advancing the cursor, also wrapping if DECAWM is set. + * REP has no effect if the sequence does not follow a printable ASCII character + * (NOOP for any other sequence in between or NON ASCII characters). */ public repeatPrecedingCharacter(params: IParams): void { if (!this._parser.precedingCodepoint) { @@ -1236,6 +1510,24 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 1 5 -> Technical characters. * Ps = 2 2 -> ANSI color, e.g., VT525. * Ps = 2 9 -> ANSI text locator (i.e., DEC Locator mode). + * + * @vt: #Y CSI DA1 "Primary Device Attributes" "CSI c" "Send primary device attributes." + * + * + * TODO: fix and cleanup response + */ + public sendDeviceAttributesPrimary(params: IParams): void { + if (params.params[0] > 0) { + return; + } + if (this._terminal.is('xterm') || this._terminal.is('rxvt-unicode') || this._terminal.is('screen')) { + this._coreService.triggerDataEvent(C0.ESC + '[?1;2c'); + } else if (this._terminal.is('linux')) { + this._coreService.triggerDataEvent(C0.ESC + '[?6c'); + } + } + + /** * CSI > Ps c * Send Device Attributes (Secondary DA). * Ps = 0 or omitted -> request the terminal's identification @@ -1253,17 +1545,12 @@ export class InputHandler extends Disposable implements IInputHandler { * More information: * xterm/charproc.c - line 2012, for more information. * vim responds with ^[[?0c or ^[[?1c after the terminal's response (?) + * + * @vt: #Y CSI DA2 "Secondary Device Attributes" "CSI > c" "Send primary device attributes." + * + * + * TODO: fix and cleanup response */ - public sendDeviceAttributesPrimary(params: IParams): void { - if (params.params[0] > 0) { - return; - } - if (this._terminal.is('xterm') || this._terminal.is('rxvt-unicode') || this._terminal.is('screen')) { - this._coreService.triggerDataEvent(C0.ESC + '[?1;2c'); - } else if (this._terminal.is('linux')) { - this._coreService.triggerDataEvent(C0.ESC + '[?6c'); - } - } public sendDeviceAttributesSecondary(params: IParams): void { if (params.params[0] > 0) { return; @@ -1290,6 +1577,34 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 4 -> Insert Mode (IRM). * Ps = 1 2 -> Send/receive (SRM). * Ps = 2 0 -> Automatic Newline (LNM). + * + * @vt: #P[Only IRM is supported.] CSI SM "Set Mode" "CSI Pm h" "Set various terminal modes." + * Supported param values by SM: + * + * | Param | Action | Support | + * | ----- | -------------------------------------- | ------- | + * | 2 | Keyboard Action Mode (KAM). Always on. | #N | + * | 4 | Insert Mode (IRM). | #Y | + * | 12 | Send/receive (SRM). Always off. | #N | + * | 20 | Automatic Newline (LNM). Always off. | #N | + * + * + * FIXME: why is LNM commented out? + */ + public setMode(params: IParams): void { + for (let i = 0; i < params.length; i++) { + switch (params.params[i]) { + case 4: + this._terminal.insertMode = true; + break; + case 20: + // this._t.convertEol = true; + break; + } + } + } + + /** * CSI ? Pm h * DEC Private Mode Set (DECSET). * Ps = 1 -> Application Cursor Keys (DECCKM). @@ -1369,19 +1684,38 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 2 0 0 4 -> Set bracketed paste mode. * Modes: * http: *vt100.net/docs/vt220-rm/chapter4.html + * + * @vt: #P[See below for supported modes.] CSI DECSET "DEC Private Set Mode" "CSI ? Pm h" "Set various terminal attributes." + * Supported param values by DECSET: + * + * | param | Action | Support | + * | ----- | ------------------------------------------------------- | --------| + * | 1 | Application Cursor Keys (DECCKM). | #Y | + * | 2 | Designate US-ASCII for character sets G0-G3 (DECANM). | #Y | + * | 3 | 132 Column Mode (DECCOLM). | #Y | + * | 6 | Origin Mode (DECOM). | #Y | + * | 7 | Auto-wrap Mode (DECAWM). | #Y | + * | 8 | Auto-repeat Keys (DECARM). Always on. | #N | + * | 9 | X10 xterm mouse protocol. | #Y | + * | 12 | Start Blinking Cursor. | #Y | + * | 25 | Show Cursor (DECTCEM). | #Y | + * | 47 | Use Alternate Screen Buffer. | #Y | + * | 66 | Application keypad (DECNKM). | #Y | + * | 1000 | X11 xterm mouse protocol. | #Y | + * | 1002 | Use Cell Motion Mouse Tracking. | #Y | + * | 1003 | Use All Motion Mouse Tracking. | #Y | + * | 1004 | Send FocusIn/FocusOut events | #Y | + * | 1005 | Enable UTF-8 Mouse Mode. | #N | + * | 1006 | Enable SGR Mouse Mode. | #Y | + * | 1015 | Enable urxvt Mouse Mode. | #N | + * | 1047 | Use Alternate Screen Buffer. | #Y | + * | 1048 | Save cursor as in DECSC. | #Y | + * | 1049 | Save cursor and switch to alternate buffer clearing it. | #P[Does not clear the alternate buffer.] | + * | 2004 | Set bracketed paste mode. | #Y | + * + * + * FIXME: implement DECSCNM, 1049 should clear altbuffer */ - public setMode(params: IParams): void { - for (let i = 0; i < params.length; i++) { - switch (params.params[i]) { - case 4: - this._terminal.insertMode = true; - break; - case 20: - // this._t.convertEol = true; - break; - } - } - } public setModePrivate(params: IParams): void { for (let i = 0; i < params.length; i++) { switch (params.params[i]) { @@ -1389,34 +1723,37 @@ export class InputHandler extends Disposable implements IInputHandler { this._coreService.decPrivateModes.applicationCursorKeys = true; break; case 2: - this._terminal.setgCharset(0, DEFAULT_CHARSET); - this._terminal.setgCharset(1, DEFAULT_CHARSET); - this._terminal.setgCharset(2, DEFAULT_CHARSET); - this._terminal.setgCharset(3, DEFAULT_CHARSET); + this._charsetService.setgCharset(0, DEFAULT_CHARSET); + this._charsetService.setgCharset(1, DEFAULT_CHARSET); + this._charsetService.setgCharset(2, DEFAULT_CHARSET); + this._charsetService.setgCharset(3, DEFAULT_CHARSET); // set VT100 mode here break; - case 3: // 132 col mode - // TODO: move DECCOLM into compat addon - this._terminal.savedCols = this._bufferService.cols; - this._terminal.resize(132, this._bufferService.rows); - this._terminal.reset(); + case 3: + /** + * DECCOLM - 132 column mode. + * This is only active if 'SetWinLines' (24) is enabled + * through `options.windowsOptions`. + */ + if (this._optionsService.options.windowOptions.setWinLines) { + this._terminal.resize(132, this._bufferService.rows); + this._onRequestReset.fire(); + } break; case 6: - this._terminal.originMode = true; + this._coreService.decPrivateModes.origin = true; this._setCursor(0, 0); break; case 7: - this._terminal.wraparoundMode = true; + this._coreService.decPrivateModes.wraparound = true; break; case 12: // this.cursorBlink = true; break; case 66: this._logService.debug('Serial port requested application keypad.'); - this._terminal.applicationKeypad = true; - if (this._terminal.viewport) { - this._terminal.viewport.syncScrollArea(); - } + this._coreService.decPrivateModes.applicationKeypad = true; + this._terminal.viewport?.syncScrollArea(); break; case 9: // X10 Mouse // no release, no motion, no wheel, no modifiers. @@ -1459,11 +1796,9 @@ export class InputHandler extends Disposable implements IInputHandler { // FALL-THROUGH case 47: // alt screen buffer case 1047: // alt screen buffer - this._bufferService.buffers.activateAltBuffer(this._terminal.eraseAttrData()); - this._terminal.refresh(0, this._bufferService.rows - 1); - if (this._terminal.viewport) { - this._terminal.viewport.syncScrollArea(); - } + this._bufferService.buffers.activateAltBuffer(this._eraseAttrData()); + this._onRequestRefreshRows.fire(0, this._bufferService.rows - 1); + this._terminal.viewport?.syncScrollArea(); this._terminal.showCursor(); break; case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste) @@ -1480,6 +1815,34 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 4 -> Replace Mode (IRM). * Ps = 1 2 -> Send/receive (SRM). * Ps = 2 0 -> Normal Linefeed (LNM). + * + * @vt: #P[Only IRM is supported.] CSI RM "Reset Mode" "CSI Pm l" "Set various terminal attributes." + * Supported param values by RM: + * + * | Param | Action | Support | + * | ----- | -------------------------------------- | ------- | + * | 2 | Keyboard Action Mode (KAM). Always on. | #N | + * | 4 | Replace Mode (IRM). (default) | #Y | + * | 12 | Send/receive (SRM). Always off. | #N | + * | 20 | Normal Linefeed (LNM). Always off. | #N | + * + * + * FIXME: why is LNM commented out? + */ + public resetMode(params: IParams): void { + for (let i = 0; i < params.length; i++) { + switch (params.params[i]) { + case 4: + this._terminal.insertMode = false; + break; + case 20: + // this._t.convertEol = false; + break; + } + } + } + + /** * CSI ? Pm l * DEC Private Mode Reset (DECRST). * Ps = 1 -> Normal Cursor Keys (DECCKM). @@ -1555,19 +1918,38 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 1 0 6 0 -> Reset legacy keyboard emulation (X11R6). * Ps = 1 0 6 1 -> Reset keyboard emulation to Sun/PC style. * Ps = 2 0 0 4 -> Reset bracketed paste mode. + * + * @vt: #P[See below for supported modes.] CSI DECRST "DEC Private Reset Mode" "CSI ? Pm l" "Reset various terminal attributes." + * Supported param values by DECRST: + * + * | param | Action | Support | + * | ----- | ------------------------------------------------------- | ------- | + * | 1 | Normal Cursor Keys (DECCKM). | #Y | + * | 2 | Designate VT52 mode (DECANM). | #N | + * | 3 | 80 Column Mode (DECCOLM). | #B[Switches to old column width instead of 80.] | + * | 6 | Normal Cursor Mode (DECOM). | #Y | + * | 7 | No Wraparound Mode (DECAWM). | #Y | + * | 8 | No Auto-repeat Keys (DECARM). | #N | + * | 9 | Don't send Mouse X & Y on button press. | #Y | + * | 12 | Stop Blinking Cursor. | #Y | + * | 25 | Hide Cursor (DECTCEM). | #Y | + * | 47 | Use Normal Screen Buffer. | #Y | + * | 66 | Numeric keypad (DECNKM). | #Y | + * | 1000 | Don't send Mouse reports. | #Y | + * | 1002 | Don't use Cell Motion Mouse Tracking. | #Y | + * | 1003 | Don't use All Motion Mouse Tracking. | #Y | + * | 1004 | Don't send FocusIn/FocusOut events. | #Y | + * | 1005 | Disable UTF-8 Mouse Mode. | #N | + * | 1006 | Disable SGR Mouse Mode. | #Y | + * | 1015 | Disable urxvt Mouse Mode. | #N | + * | 1047 | Use Normal Screen Buffer (clearing screen if in alt). | #Y | + * | 1048 | Restore cursor as in DECRC. | #Y | + * | 1049 | Use Normal Screen Buffer and restore cursor. | #Y | + * | 2004 | Reset bracketed paste mode. | #Y | + * + * + * FIXME: DECCOLM is currently broken (already fixed in window options PR) */ - public resetMode(params: IParams): void { - for (let i = 0; i < params.length; i++) { - switch (params.params[i]) { - case 4: - this._terminal.insertMode = false; - break; - case 20: - // this._t.convertEol = false; - break; - } - } - } public resetModePrivate(params: IParams): void { for (let i = 0; i < params.length; i++) { switch (params.params[i]) { @@ -1575,31 +1957,30 @@ export class InputHandler extends Disposable implements IInputHandler { this._coreService.decPrivateModes.applicationCursorKeys = false; break; case 3: - // TODO: move DECCOLM into compat addon - // Note: This impl currently does not enforce col 80, instead reverts - // to previous terminal width before entering DECCOLM 132 - if (this._bufferService.cols === 132 && this._terminal.savedCols) { - this._terminal.resize(this._terminal.savedCols, this._bufferService.rows); + /** + * DECCOLM - 80 column mode. + * This is only active if 'SetWinLines' (24) is enabled + * through `options.windowsOptions`. + */ + if (this._optionsService.options.windowOptions.setWinLines) { + this._terminal.resize(80, this._bufferService.rows); + this._onRequestReset.fire(); } - delete this._terminal.savedCols; - this._terminal.reset(); break; case 6: - this._terminal.originMode = false; + this._coreService.decPrivateModes.origin = false; this._setCursor(0, 0); break; case 7: - this._terminal.wraparoundMode = false; + this._coreService.decPrivateModes.wraparound = false; break; case 12: // this.cursorBlink = false; break; case 66: this._logService.debug('Switching back to normal keypad.'); - this._terminal.applicationKeypad = false; - if (this._terminal.viewport) { - this._terminal.viewport.syncScrollArea(); - } + this._coreService.decPrivateModes.applicationKeypad = false; + this._terminal.viewport?.syncScrollArea(); break; case 9: // X10 Mouse case 1000: // vt200 mouse @@ -1634,10 +2015,8 @@ export class InputHandler extends Disposable implements IInputHandler { if (params.params[i] === 1049) { this.restoreCursor(); } - this._terminal.refresh(0, this._bufferService.rows - 1); - if (this._terminal.viewport) { - this._terminal.viewport.syncScrollArea(); - } + this._onRequestRefreshRows.fire(0, this._bufferService.rows - 1); + this._terminal.viewport?.syncScrollArea(); this._terminal.showCursor(); break; case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste) @@ -1721,80 +2100,84 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI Pm m Character Attributes (SGR). - * Ps = 0 -> Normal (default). - * Ps = 1 -> Bold. - * Ps = 2 -> Faint, decreased intensity (ISO 6429). - * Ps = 4 -> Underlined. - * Ps = 5 -> Blink (appears as Bold). - * Ps = 7 -> Inverse. - * Ps = 8 -> Invisible, i.e., hidden (VT300). - * Ps = 2 2 -> Normal (neither bold nor faint). - * Ps = 2 4 -> Not underlined. - * Ps = 2 5 -> Steady (not blinking). - * Ps = 2 7 -> Positive (not inverse). - * Ps = 2 8 -> Visible, i.e., not hidden (VT300). - * Ps = 3 0 -> Set foreground color to Black. - * Ps = 3 1 -> Set foreground color to Red. - * Ps = 3 2 -> Set foreground color to Green. - * Ps = 3 3 -> Set foreground color to Yellow. - * Ps = 3 4 -> Set foreground color to Blue. - * Ps = 3 5 -> Set foreground color to Magenta. - * Ps = 3 6 -> Set foreground color to Cyan. - * Ps = 3 7 -> Set foreground color to White. - * Ps = 3 9 -> Set foreground color to default (original). - * Ps = 4 0 -> Set background color to Black. - * Ps = 4 1 -> Set background color to Red. - * Ps = 4 2 -> Set background color to Green. - * Ps = 4 3 -> Set background color to Yellow. - * Ps = 4 4 -> Set background color to Blue. - * Ps = 4 5 -> Set background color to Magenta. - * Ps = 4 6 -> Set background color to Cyan. - * Ps = 4 7 -> Set background color to White. - * Ps = 4 9 -> Set background color to default (original). * - * If 16-color support is compiled, the following apply. Assume - * that xterm's resources are set so that the ISO color codes are - * the first 8 of a set of 16. Then the aixterm colors are the - * bright versions of the ISO colors: - * Ps = 9 0 -> Set foreground color to Black. - * Ps = 9 1 -> Set foreground color to Red. - * Ps = 9 2 -> Set foreground color to Green. - * Ps = 9 3 -> Set foreground color to Yellow. - * Ps = 9 4 -> Set foreground color to Blue. - * Ps = 9 5 -> Set foreground color to Magenta. - * Ps = 9 6 -> Set foreground color to Cyan. - * Ps = 9 7 -> Set foreground color to White. - * Ps = 1 0 0 -> Set background color to Black. - * Ps = 1 0 1 -> Set background color to Red. - * Ps = 1 0 2 -> Set background color to Green. - * Ps = 1 0 3 -> Set background color to Yellow. - * Ps = 1 0 4 -> Set background color to Blue. - * Ps = 1 0 5 -> Set background color to Magenta. - * Ps = 1 0 6 -> Set background color to Cyan. - * Ps = 1 0 7 -> Set background color to White. + * @vt: #P[See below for supported attributes.] CSI SGR "Select Graphic Rendition" "CSI Pm m" "Set/Reset various text attributes." + * SGR selects one or more character attributes at the same time. Multiple params (up to 32) + * are applied from in order from left to right. The changed attributes are applied to all new + * characters received. If you move characters in the viewport by scrolling or any other means, + * then the attributes move with the characters. * - * If xterm is compiled with the 16-color support disabled, it - * supports the following, from rxvt: - * Ps = 1 0 0 -> Set foreground and background color to - * default. + * Supported param values by SGR: * - * If 88- or 256-color support is compiled, the following apply. - * Ps = 3 8 ; 5 ; Ps -> Set foreground color to the second - * Ps. - * Ps = 4 8 ; 5 ; Ps -> Set background color to the second - * Ps. + * | Param | Meaning | Support | + * | --------- | -------------------------------------------------------- | ------- | + * | 0 | Normal (default). Resets any other preceding SGR. | #Y | + * | 1 | Bold. (also see `options.drawBoldTextInBrightColors`) | #Y | + * | 2 | Faint, decreased intensity. | #Y | + * | 3 | Italic. | #Y | + * | 4 | Underlined. (no support for newer underline styles) | #Y | + * | 5 | Slowly blinking. | #N | + * | 6 | Rapidly blinking. | #N | + * | 7 | Inverse. Flips foreground and background color. | #Y | + * | 8 | Invisible (hidden). | #Y | + * | 9 | Crossed-out characters. | #N | + * | 21 | Doubly underlined. | #N | + * | 22 | Normal (neither bold nor faint). | #Y | + * | 23 | No italic. | #Y | + * | 24 | Not underlined. | #Y | + * | 25 | Steady (not blinking). | #Y | + * | 27 | Positive (not inverse). | #Y | + * | 28 | Visible (not hidden). | #Y | + * | 29 | Not Crossed-out. | #N | + * | 30 | Foreground color: Black. | #Y | + * | 31 | Foreground color: Red. | #Y | + * | 32 | Foreground color: Green. | #Y | + * | 33 | Foreground color: Yellow. | #Y | + * | 34 | Foreground color: Blue. | #Y | + * | 35 | Foreground color: Magenta. | #Y | + * | 36 | Foreground color: Cyan. | #Y | + * | 37 | Foreground color: White. | #Y | + * | 38 | Foreground color: Extended color. | #P[Support for RGB and indexed colors, see below.] | + * | 39 | Foreground color: Default (original). | #Y | + * | 40 | Background color: Black. | #Y | + * | 41 | Background color: Red. | #Y | + * | 42 | Background color: Green. | #Y | + * | 43 | Background color: Yellow. | #Y | + * | 44 | Background color: Blue. | #Y | + * | 45 | Background color: Magenta. | #Y | + * | 46 | Background color: Cyan. | #Y | + * | 47 | Background color: White. | #Y | + * | 48 | Background color: Extended color. | #P[Support for RGB and indexed colors, see below.] | + * | 49 | Background color: Default (original). | #Y | + * | 90 - 97 | Bright foreground color (analogous to 30 - 37). | #Y | + * | 100 - 107 | Bright background color (analogous to 40 - 47). | #Y | + * + * Extended colors are supported for foreground (Ps=38) and background (Ps=48) as follows: + * + * | Ps + 1 | Meaning | Support | + * | ------ | ------------------------------------------------------------- | ------- | + * | 0 | Implementation defined. | #N | + * | 1 | Transparent. | #N | + * | 2 | RGB color as `Ps ; 2 ; R ; G ; B` or `Ps : 2 : : R : G : B`. | #Y | + * | 3 | CMY color. | #N | + * | 4 | CMYK color. | #N | + * | 5 | Indexed (256 colors) as `Ps ; 5 ; INDEX` or `Ps : 5 : INDEX`. | #Y | + * + * + * FIXME: blinking is implemented in attrs, but not working in renderers? + * FIXME: remove dead branch for p=100 */ public charAttributes(params: IParams): void { // Optimize a single SGR0. if (params.length === 1 && params.params[0] === 0) { - this._terminal.curAttrData.fg = DEFAULT_ATTR_DATA.fg; - this._terminal.curAttrData.bg = DEFAULT_ATTR_DATA.bg; + this._curAttrData.fg = DEFAULT_ATTR_DATA.fg; + this._curAttrData.bg = DEFAULT_ATTR_DATA.bg; return; } const l = params.length; let p; - const attr = this._terminal.curAttrData; + const attr = this._curAttrData; for (let i = 0; i < l; i++) { p = params.params[i]; @@ -1870,7 +2253,7 @@ export class InputHandler extends Disposable implements IInputHandler { } else if (p === 38 || p === 48) { // fg color 256 and RGB i += this._extractColor(params, i, attr); - } else if (p === 100) { + } else if (p === 100) { // FIXME: dead branch, p=100 already handled above! // reset fg/bg attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); attr.fg |= DEFAULT_ATTR_DATA.fg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK); @@ -1904,6 +2287,8 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 5 3 -> Report Locator status as * CSI ? 5 3 n Locator available, if compiled-in, or * CSI ? 5 0 n No Locator, if not. + * + * @vt: #Y CSI DSR "Device Status Report" "CSI Ps n" "Request cursor position (CPR) with `Ps` = 6." */ public deviceStatus(params: IParams): void { switch (params.params[0]) { @@ -1920,6 +2305,7 @@ export class InputHandler extends Disposable implements IInputHandler { } } + // @vt: #P[Only CPR is supported.] CSI DECDSR "DEC Device Status Report" "CSI ? Ps n" "Only CPR is supported (same as DSR)." public deviceStatusPrivate(params: IParams): void { // modern xterm doesnt seem to // respond to any of these except ?6, 6, and 5 @@ -1952,24 +2338,31 @@ export class InputHandler extends Disposable implements IInputHandler { /** * CSI ! p Soft terminal reset (DECSTR). * http://vt100.net/docs/vt220-rm/table4-10.html + * + * @vt: #Y CSI DECSTR "Soft Terminal Reset" "CSI ! p" "Reset several terminal attributes to initial state." + * There are two terminal reset sequences - RIS and DECSTR. While RIS performs almost a full terminal bootstrap, + * DECSTR only resets certain attributes. For most needs DECSTR should be sufficient. + * + * The following terminal attributes are reset to default values: + * - cursor is reset (default = visible, home position) + * - IRM is reset (dafault = false) + * - scroll margins are reset (default = viewport size) + * - erase attributes are reset to default + * - charsets are reset + * + * + * FIXME: there are several more attributes missing (see VT520 manual) */ public softReset(params: IParams): void { this._coreService.isCursorHidden = false; this._terminal.insertMode = false; - this._terminal.originMode = false; - this._terminal.wraparoundMode = true; // defaults: xterm - true, vt100 - false - this._terminal.applicationKeypad = false; // ? - if (this._terminal.viewport) { - this._terminal.viewport.syncScrollArea(); - } - this._coreService.decPrivateModes.applicationCursorKeys = false; + this._terminal.viewport?.syncScrollArea(); this._bufferService.buffer.scrollTop = 0; this._bufferService.buffer.scrollBottom = this._bufferService.rows - 1; - this._terminal.curAttrData = DEFAULT_ATTR_DATA.clone(); + this._curAttrData = DEFAULT_ATTR_DATA.clone(); this._bufferService.buffer.x = this._bufferService.buffer.y = 0; // ? - this._terminal.charset = null; - this._terminal.glevel = 0; // ?? - this._terminal.charsets = [null]; // ?? + this._coreService.reset(); + this._charsetService.reset(); } /** @@ -1981,6 +2374,15 @@ export class InputHandler extends Disposable implements IInputHandler { * Ps = 4 -> steady underline. * Ps = 5 -> blinking bar (xterm). * Ps = 6 -> steady bar (xterm). + * + * @vt: #Y CSI DECSCUSR "Set Cursor Style" "CSI Ps SP q" "Set cursor style." + * Supported cursor styles: + * - empty, 0 or 1: steady block + * - 2: blink block + * - 3: steady underline + * - 4: blink underline + * - 5: steady bar + * - 6: blink bar */ public setCursorStyle(params: IParams): void { const param = params.params[0] || 1; @@ -2006,6 +2408,8 @@ export class InputHandler extends Disposable implements IInputHandler { * CSI Ps ; Ps r * Set Scrolling Region [top;bottom] (default = full size of win- * dow) (DECSTBM). + * + * @vt: #Y CSI DECSTBM "Set Top and Bottom Margin" "CSI Ps ; Ps r" "Set top and bottom margins of the viewport [top;bottom] (default = viewport size)." */ public setScrollRegion(params: IParams): void { const top = params.params[0] || 1; @@ -2022,18 +2426,107 @@ export class InputHandler extends Disposable implements IInputHandler { } } + /** + * CSI Ps ; Ps ; Ps t - Various window manipulations and reports (xterm) + * + * Note: Only those listed below are supported. All others are left to integrators and + * need special treatment based on the embedding environment. + * + * Ps = 1 4 supported + * Report xterm text area size in pixels. + * Result is CSI 4 ; height ; width t + * Ps = 14 ; 2 not implemented + * Ps = 16 supported + * Report xterm character cell size in pixels. + * Result is CSI 6 ; height ; width t + * Ps = 18 supported + * Report the size of the text area in characters. + * Result is CSI 8 ; height ; width t + * Ps = 20 supported + * Report xterm window's icon label. + * Result is OSC L label ST + * Ps = 21 supported + * Report xterm window's title. + * Result is OSC l label ST + * Ps = 22 ; 0 -> Save xterm icon and window title on stack. supported + * Ps = 22 ; 1 -> Save xterm icon title on stack. supported + * Ps = 22 ; 2 -> Save xterm window title on stack. supported + * Ps = 23 ; 0 -> Restore xterm icon and window title from stack. supported + * Ps = 23 ; 1 -> Restore xterm icon title from stack. supported + * Ps = 23 ; 2 -> Restore xterm window title from stack. supported + * Ps >= 24 not implemented + */ + public windowOptions(params: IParams): void { + if (!paramToWindowOption(params.params[0], this._optionsService.options.windowOptions)) { + return; + } + const second = (params.length > 1) ? params.params[1] : 0; + const rs = this._instantiationService.getService(IRenderService); + switch (params.params[0]) { + case 14: // GetWinSizePixels, returns CSI 4 ; height ; width t + if (rs && second !== 2) { + console.log(rs.dimensions); + const w = rs.dimensions.scaledCanvasWidth.toFixed(0); + const h = rs.dimensions.scaledCanvasHeight.toFixed(0); + this._coreService.triggerDataEvent(`${C0.ESC}[4;${h};${w}t`); + } + break; + case 16: // GetCellSizePixels, returns CSI 6 ; height ; width t + if (rs) { + const w = rs.dimensions.scaledCellWidth.toFixed(0); + const h = rs.dimensions.scaledCellHeight.toFixed(0); + this._coreService.triggerDataEvent(`${C0.ESC}[6;${h};${w}t`); + } + break; + case 18: // GetWinSizeChars, returns CSI 8 ; height ; width t + if (this._bufferService) { + this._coreService.triggerDataEvent(`${C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`); + } + break; + case 22: // PushTitle + if (second === 0 || second === 2) { + this._windowTitleStack.push(this._windowTitle); + if (this._windowTitleStack.length > STACK_LIMIT) { + this._windowTitleStack.shift(); + } + } + if (second === 0 || second === 1) { + this._iconNameStack.push(this._iconName); + if (this._iconNameStack.length > STACK_LIMIT) { + this._iconNameStack.shift(); + } + } + break; + case 23: // PopTitle + if (second === 0 || second === 2) { + if (this._windowTitleStack.length) { + this.setTitle(this._windowTitleStack.pop()); + } + } + if (second === 0 || second === 1) { + if (this._iconNameStack.length) { + this.setIconName(this._iconNameStack.pop()); + } + } + break; + } + } + /** * CSI s * ESC 7 * Save cursor (ANSI.SYS). + * + * @vt: #P[TODO...] CSI SCOSC "Save Cursor" "CSI s" "Save cursor position, charmap and text attributes." + * @vt: #Y ESC SC "Save Cursor" "ESC 7" "Save cursor position, charmap and text attributes." */ public saveCursor(params?: IParams): void { this._bufferService.buffer.savedX = this._bufferService.buffer.x; this._bufferService.buffer.savedY = this._bufferService.buffer.ybase + this._bufferService.buffer.y; - this._bufferService.buffer.savedCurAttrData.fg = this._terminal.curAttrData.fg; - this._bufferService.buffer.savedCurAttrData.bg = this._terminal.curAttrData.bg; - this._bufferService.buffer.savedCharset = this._terminal.charset; + this._bufferService.buffer.savedCurAttrData.fg = this._curAttrData.fg; + this._bufferService.buffer.savedCurAttrData.bg = this._curAttrData.bg; + this._bufferService.buffer.savedCharset = this._charsetService.charset; } @@ -2041,34 +2534,54 @@ export class InputHandler extends Disposable implements IInputHandler { * CSI u * ESC 8 * Restore cursor (ANSI.SYS). + * + * @vt: #P[TODO...] CSI SCORC "Restore Cursor" "CSI u" "Restore cursor position, charmap and text attributes." + * @vt: #Y ESC RC "Restore Cursor" "ESC 8" "Restore cursor position, charmap and text attributes." */ public restoreCursor(params?: IParams): void { this._bufferService.buffer.x = this._bufferService.buffer.savedX || 0; this._bufferService.buffer.y = Math.max(this._bufferService.buffer.savedY - this._bufferService.buffer.ybase, 0); - this._terminal.curAttrData.fg = this._bufferService.buffer.savedCurAttrData.fg; - this._terminal.curAttrData.bg = this._bufferService.buffer.savedCurAttrData.bg; - this._terminal.charset = (this as any)._savedCharset; + this._curAttrData.fg = this._bufferService.buffer.savedCurAttrData.fg; + this._curAttrData.bg = this._bufferService.buffer.savedCurAttrData.bg; + this._charsetService.charset = (this as any)._savedCharset; if (this._bufferService.buffer.savedCharset) { - this._terminal.charset = this._bufferService.buffer.savedCharset; + this._charsetService.charset = this._bufferService.buffer.savedCharset; } this._restrictCursor(); } /** - * OSC 0; ST (set icon name + window title) * OSC 2; ST (set window title) - * Proxy to set window title. Icon name is not supported. + * Proxy to set window title. + * + * @vt: #P[Icon name is not exposed.] OSC 0 "Set Windows Title and Icon Name" "OSC 0 ; Pt BEL" "Set window title and icon name." + * Icon name is not supported. For Window Title see below. + * + * @vt: #Y OSC 2 "Set Windows Title" "OSC 2 ; Pt BEL" "Set window title." + * xterm.js does not manipulate the title directly, instead exposes changes via the event `Terminal.onTitleChange`. */ public setTitle(data: string): void { + this._windowTitle = data; this._terminal.handleTitle(data); } + /** + * OSC 1; ST + * Note: Icon name is not exposed. + */ + public setIconName(data: string): void { + this._iconName = data; + } + /** * ESC E * C1.NEL * DEC mnemonic: NEL (https://vt100.net/docs/vt510-rm/NEL) * Moves cursor to first position on next line. + * + * @vt: #Y C1 NEL "Next Line" "\x85" "Move the cursor to the beginning of the next row." + * @vt: #Y ESC NEL "Next Line" "ESC E" "Move the cursor to the beginning of the next row." */ public nextLine(): void { this._bufferService.buffer.x = 0; @@ -2082,10 +2595,8 @@ export class InputHandler extends Disposable implements IInputHandler { */ public keypadApplicationMode(): void { this._logService.debug('Serial port requested application keypad.'); - this._terminal.applicationKeypad = true; - if (this._terminal.viewport) { - this._terminal.viewport.syncScrollArea(); - } + this._coreService.decPrivateModes.applicationKeypad = true; + this._terminal.viewport?.syncScrollArea(); } /** @@ -2095,10 +2606,8 @@ export class InputHandler extends Disposable implements IInputHandler { */ public keypadNumericMode(): void { this._logService.debug('Switching back to normal keypad.'); - this._terminal.applicationKeypad = false; - if (this._terminal.viewport) { - this._terminal.viewport.syncScrollArea(); - } + this._coreService.decPrivateModes.applicationKeypad = false; + this._terminal.viewport?.syncScrollArea(); } /** @@ -2108,8 +2617,8 @@ export class InputHandler extends Disposable implements IInputHandler { * therefore ESC % G does the same. */ public selectDefaultCharset(): void { - this._terminal.setgLevel(0); - this._terminal.setgCharset(0, DEFAULT_CHARSET); // US (default) + this._charsetService.setgLevel(0); + this._charsetService.setgCharset(0, DEFAULT_CHARSET); // US (default) } /** @@ -2136,7 +2645,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (collectAndFlag[0] === '/') { return; // TODO: Is this supported? } - this._terminal.setgCharset(GLEVEL[collectAndFlag[0]], CHARSETS[collectAndFlag[1]] || DEFAULT_CHARSET); + this._charsetService.setgCharset(GLEVEL[collectAndFlag[0]], CHARSETS[collectAndFlag[1]] || DEFAULT_CHARSET); return; } @@ -2145,6 +2654,9 @@ export class InputHandler extends Disposable implements IInputHandler { * C1.IND * DEC mnemonic: IND (https://vt100.net/docs/vt510-rm/IND.html) * Moves the cursor down one line in the same column. + * + * @vt: #Y C1 IND "Index" "\x84" "Move the cursor one line down scrolling if needed." + * @vt: #Y ESC IND "Index" "ESC D" "Move the cursor one line down scrolling if needed." */ public index(): void { this._restrictCursor(); @@ -2152,7 +2664,7 @@ export class InputHandler extends Disposable implements IInputHandler { this._bufferService.buffer.y++; if (buffer.y === buffer.scrollBottom + 1) { buffer.y--; - this._terminal.scroll(); + this._terminal.scroll(this._eraseAttrData()); } else if (buffer.y >= this._bufferService.rows) { buffer.y = this._bufferService.rows - 1; } @@ -2165,6 +2677,9 @@ export class InputHandler extends Disposable implements IInputHandler { * DEC mnemonic: HTS (https://vt100.net/docs/vt510-rm/HTS.html) * Sets a horizontal tab stop at the column position indicated by * the value of the active column when the terminal receives an HTS. + * + * @vt: #Y C1 HTS "Horizontal Tabulation Set" "\x88" "Places a tab stop at the current cursor position." + * @vt: #Y ESC HTS "Horizontal Tabulation Set" "ESC H" "Places a tab stop at the current cursor position." */ public tabSet(): void { this._bufferService.buffer.tabs[this._bufferService.buffer.x] = true; @@ -2176,6 +2691,8 @@ export class InputHandler extends Disposable implements IInputHandler { * DEC mnemonic: HTS * Moves the cursor up one line in the same column. If the cursor is at the top margin, * the page scrolls down. + * + * @vt: #Y ESC IR "Reverse Index" "ESC M" "Move the cursor one line up scrolling if needed." */ public reverseIndex(): void { this._restrictCursor(); @@ -2186,7 +2703,7 @@ export class InputHandler extends Disposable implements IInputHandler { // blankLine(true) is xterm/linux behavior const scrollRegionHeight = buffer.scrollBottom - buffer.scrollTop; buffer.lines.shiftElements(buffer.y + buffer.ybase, scrollRegionHeight, 1); - buffer.lines.set(buffer.y + buffer.ybase, buffer.getBlankLine(this._terminal.eraseAttrData())); + buffer.lines.set(buffer.y + buffer.ybase, buffer.getBlankLine(this._eraseAttrData())); this._dirtyRowService.markRangeDirty(buffer.scrollTop, buffer.scrollBottom); } else { buffer.y--; @@ -2199,9 +2716,23 @@ export class InputHandler extends Disposable implements IInputHandler { * DEC mnemonic: RIS (https://vt100.net/docs/vt510-rm/RIS.html) * Reset to initial state. */ - public reset(): void { + public fullReset(): void { this._parser.reset(); - this._terminal.reset(); // TODO: save to move from terminal? + this._onRequestReset.fire(); + } + + public reset(): void { + this._curAttrData = DEFAULT_ATTR_DATA.clone(); + this._eraseAttrDataInternal = DEFAULT_ATTR_DATA.clone(); + } + + /** + * back_color_erase feature for xterm. + */ + private _eraseAttrData(): IAttributeData { + this._eraseAttrDataInternal.bg &= ~(Attributes.CM_MASK | 0xFFFFFF); + this._eraseAttrDataInternal.bg |= this._curAttrData.bg & ~0xFC000000; + return this._eraseAttrDataInternal; } /** @@ -2215,7 +2746,7 @@ export class InputHandler extends Disposable implements IInputHandler { * you use another locking shift. (partly supported) */ public setgLevel(level: number): void { - this._terminal.setgLevel(level); // TODO: save to move from terminal? + this._charsetService.setgLevel(level); } /** @@ -2224,14 +2755,14 @@ export class InputHandler extends Disposable implements IInputHandler { * This control function fills the complete screen area with * a test pattern (E) used for adjusting screen alignment. * - * TODO: move DECALN into compat addon + * @vt: #Y ESC DECALN "Screen Alignment Pattern" "ESC # 8" "Fill viewport with a test pattern (E)." */ public screenAlignmentPattern(): void { // prepare cell data const cell = new CellData(); cell.content = 1 << Content.WIDTH_SHIFT | 'E'.charCodeAt(0); - cell.fg = this._terminal.curAttrData.fg; - cell.bg = this._terminal.curAttrData.bg; + cell.fg = this._curAttrData.fg; + cell.bg = this._curAttrData.bg; const buffer = this._bufferService.buffer; diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index b55a516b2..84879b764 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -7,15 +7,17 @@ import { assert, expect } from 'chai'; import { MockViewport, MockCompositionHelper, MockRenderer, TestTerminal } from './TestUtils.test'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; -import { wcwidth } from 'common/CharWidth'; -import { IBufferService } from 'common/services/Services'; +import { IBufferService, IUnicodeService } from 'common/services/Services'; import { Linkifier } from 'browser/Linkifier'; -import { MockLogService } from 'common/TestUtils.test'; +import { MockLogService, MockOptionsService, MockUnicodeService } from 'common/TestUtils.test'; import { IRegisteredLinkMatcher, IMouseZoneManager, IMouseZone } from 'browser/Types'; const INIT_COLS = 80; const INIT_ROWS = 24; +// grab wcwidth from mock unicode service (hardcoded to V6) +const wcwidth = (new MockUnicodeService()).wcwidth; + describe('Terminal', () => { let term: TestTerminal; const termOptions = { @@ -112,7 +114,7 @@ describe('Terminal', () => { assert.equal(typeof e, 'number'); done(); }); - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); }); it('should fire the onTitleChange event', (done) => { term.onTitleChange(e => { @@ -397,7 +399,7 @@ describe('Terminal', () => { term.buffer.lines.get(0).setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)])); term.buffer.lines.get(INIT_ROWS - 1).setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)])); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS + 1); assert.equal(term.buffer.lines.get(0).loadCell(0, new CellData()).getChars(), 'a'); assert.equal(term.buffer.lines.get(INIT_ROWS - 1).loadCell(0, new CellData()).getChars(), 'b'); @@ -410,7 +412,7 @@ describe('Terminal', () => { term.buffer.lines.get(2).setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)])); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.buffer.scrollTop = 1; - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS); assert.equal(term.buffer.lines.get(0).loadCell(0, new CellData()).getChars(), 'a'); assert.equal(term.buffer.lines.get(1).loadCell(0, new CellData()).getChars(), 'c'); @@ -424,7 +426,7 @@ describe('Terminal', () => { term.buffer.lines.get(4).setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)])); term.buffer.y = 3; term.buffer.scrollBottom = 3; - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS + 1); assert.equal(term.buffer.lines.get(0).loadCell(0, new CellData()).getChars(), 'a', '\'a\' should be pushed to the scrollback'); assert.equal(term.buffer.lines.get(1).loadCell(0, new CellData()).getChars(), 'b'); @@ -443,7 +445,7 @@ describe('Terminal', () => { term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.buffer.scrollTop = 1; term.buffer.scrollBottom = 3; - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS); assert.equal(term.buffer.lines.get(0).loadCell(0, new CellData()).getChars(), 'a'); assert.equal(term.buffer.lines.get(1).loadCell(0, new CellData()).getChars(), 'c', '\'b\' should be removed from the buffer'); @@ -465,7 +467,7 @@ describe('Terminal', () => { term.buffer.lines.get(INIT_ROWS - 1).setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)])); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line assert.equal(term.buffer.lines.length, INIT_ROWS); - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS); // 'a' gets pushed out of buffer assert.equal(term.buffer.lines.get(0).loadCell(0, new CellData()).getChars(), 'b'); @@ -480,7 +482,7 @@ describe('Terminal', () => { term.buffer.lines.get(2).setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)])); term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.buffer.scrollTop = 1; - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS); assert.equal(term.buffer.lines.get(0).loadCell(0, new CellData()).getChars(), 'a'); assert.equal(term.buffer.lines.get(1).loadCell(0, new CellData()).getChars(), 'c'); @@ -494,7 +496,7 @@ describe('Terminal', () => { term.buffer.lines.get(4).setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)])); term.buffer.y = 3; term.buffer.scrollBottom = 3; - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS); assert.equal(term.buffer.lines.get(0).loadCell(0, new CellData()).getChars(), 'b'); assert.equal(term.buffer.lines.get(1).loadCell(0, new CellData()).getChars(), 'c'); @@ -512,7 +514,7 @@ describe('Terminal', () => { term.buffer.y = INIT_ROWS - 1; // Move cursor to last line term.buffer.scrollTop = 1; term.buffer.scrollBottom = 3; - term.scroll(); + term.scroll(DEFAULT_ATTR_DATA.clone()); assert.equal(term.buffer.lines.length, INIT_ROWS); assert.equal(term.buffer.lines.get(0).loadCell(0, new CellData()).getChars(), 'a'); assert.equal(term.buffer.lines.get(1).loadCell(0, new CellData()).getChars(), 'c', '\'b\' should be removed from the buffer'); @@ -747,7 +749,7 @@ describe('Terminal', () => { const cell = new CellData(); for (let i = 0xDC00; i <= 0xDCFF; ++i) { term.buffer.x = term.cols - 1; - term.wraparoundMode = true; + term.writeSync('a' + high + String.fromCharCode(i)); expect(term.buffer.lines.get(0).loadCell(term.cols - 1, cell).getChars()).eql('a'); expect(term.buffer.lines.get(1).loadCell(0, cell).getChars()).eql(high + String.fromCharCode(i)); @@ -761,7 +763,7 @@ describe('Terminal', () => { const cell = new CellData(); for (let i = 0xDC00; i <= 0xDCFF; ++i) { term.buffer.x = term.cols - 1; - term.wraparoundMode = false; + term.writeSync('\x1b[?7l'); // Disable wraparound mode const width = wcwidth((0xD800 - 0xD800) * 0x400 + i - 0xDC00 + 0x10000); if (width !== 1) { continue; @@ -812,7 +814,6 @@ describe('Terminal', () => { expect(cell.getWidth()).eql(1); }); it('multiple combined é', () => { - term.wraparoundMode = true; term.writeSync(Array(100).join('e\u0301')); for (let i = 0; i < term.cols; ++i) { term.buffer.lines.get(0).loadCell(i, cell); @@ -826,7 +827,6 @@ describe('Terminal', () => { expect(cell.getWidth()).eql(1); }); it('multiple surrogate with combined', () => { - term.wraparoundMode = true; term.writeSync(Array(100).join('\uD800\uDC00\u0301')); for (let i = 0; i < term.cols; ++i) { term.buffer.lines.get(0).loadCell(i, cell); @@ -855,7 +855,6 @@ describe('Terminal', () => { expect(term.buffer.x).eql(3); }); it('line of ¥ even', () => { - term.wraparoundMode = true; term.writeSync(Array(50).join('¥')); for (let i = 0; i < term.cols; ++i) { term.buffer.lines.get(0).loadCell(i, cell); @@ -875,7 +874,6 @@ describe('Terminal', () => { expect(cell.getWidth()).eql(2); }); it('line of ¥ odd', () => { - term.wraparoundMode = true; term.buffer.x = 1; term.writeSync(Array(50).join('¥')); for (let i = 1; i < term.cols - 1; ++i) { @@ -900,7 +898,6 @@ describe('Terminal', () => { expect(cell.getWidth()).eql(2); }); it('line of ¥ with combining odd', () => { - term.wraparoundMode = true; term.buffer.x = 1; term.writeSync(Array(50).join('¥\u0301')); for (let i = 1; i < term.cols - 1; ++i) { @@ -925,7 +922,6 @@ describe('Terminal', () => { expect(cell.getWidth()).eql(2); }); it('line of ¥ with combining even', () => { - term.wraparoundMode = true; term.writeSync(Array(50).join('¥\u0301')); for (let i = 0; i < term.cols; ++i) { term.buffer.lines.get(0).loadCell(i, cell); @@ -945,7 +941,6 @@ describe('Terminal', () => { expect(cell.getWidth()).eql(2); }); it('line of surrogate fullwidth with combining odd', () => { - term.wraparoundMode = true; term.buffer.x = 1; term.writeSync(Array(50).join('\ud843\ude6d\u0301')); for (let i = 1; i < term.cols - 1; ++i) { @@ -970,7 +965,6 @@ describe('Terminal', () => { expect(cell.getWidth()).eql(2); }); it('line of surrogate fullwidth with combining even', () => { - term.wraparoundMode = true; term.writeSync(Array(50).join('\ud843\ude6d\u0301')); for (let i = 0; i < term.cols; ++i) { term.buffer.lines.get(0).loadCell(i, cell); @@ -1045,7 +1039,7 @@ describe('Terminal', () => { // to get the special handling of fullwidth, surrogate and combining chars in the input handler beforeEach(() => { terminal = new TestTerminal({ cols: 10, rows: 5 }); - linkifier = new TestLinkifier((terminal as any)._bufferService); + linkifier = new TestLinkifier((terminal as any)._bufferService, terminal.unicodeService); mouseZoneManager = new TestMouseZoneManager(); linkifier.attachToDom({} as any, mouseZoneManager); }); @@ -1367,11 +1361,53 @@ describe('Terminal', () => { }).to.not.throw(); }); }); + + describe('Windows Mode', () => { + it('should mark lines as wrapped when the line ends in a non-null character after a LF', () => { + const data = [ + 'aaaaaaaaaa\n\r', // cannot wrap as it's the first + 'aaaaaaaaa\n\r', // wrapped (windows mode only) + 'aaaaaaaaa' // not wrapped + ]; + + const normalTerminal = new TestTerminal({rows: 5, cols: 10, windowsMode: false}); + normalTerminal.writeSync(data.join('')); + assert.equal(normalTerminal.buffer.lines.get(0).isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(1).isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(2).isWrapped, false); + + const windowsModeTerminal = new TestTerminal({rows: 5, cols: 10, windowsMode: true}); + windowsModeTerminal.writeSync(data.join('')); + assert.equal(windowsModeTerminal.buffer.lines.get(0).isWrapped, false); + assert.equal(windowsModeTerminal.buffer.lines.get(1).isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character'); + assert.equal(windowsModeTerminal.buffer.lines.get(2).isWrapped, false); + }); + + it('should mark lines as wrapped when the line ends in a non-null character after a CUP', () => { + const data = [ + 'aaaaaaaaaa\x1b[2;1H', // cannot wrap as it's the first + 'aaaaaaaaa\x1b[3;1H', // wrapped (windows mode only) + 'aaaaaaaaa' // not wrapped + ]; + + const normalTerminal = new TestTerminal({rows: 5, cols: 10, windowsMode: false}); + normalTerminal.writeSync(data.join('')); + assert.equal(normalTerminal.buffer.lines.get(0).isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(1).isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(2).isWrapped, false); + + const windowsModeTerminal = new TestTerminal({rows: 5, cols: 10, windowsMode: true}); + windowsModeTerminal.writeSync(data.join('')); + assert.equal(windowsModeTerminal.buffer.lines.get(0).isWrapped, false); + assert.equal(windowsModeTerminal.buffer.lines.get(1).isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character'); + assert.equal(windowsModeTerminal.buffer.lines.get(2).isWrapped, false); + }); + }); }); class TestLinkifier extends Linkifier { - constructor(bufferService: IBufferService) { - super(bufferService, new MockLogService()); + constructor(bufferService: IBufferService, unicodeService: IUnicodeService) { + super(bufferService, new MockLogService(), new MockOptionsService(), unicodeService); Linkifier._timeBeforeLatency = 0; } diff --git a/src/Terminal.ts b/src/Terminal.ts index 5c9154fc2..aec51c82b 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -39,21 +39,20 @@ import { MouseZoneManager } from 'browser/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider } from 'xterm'; import { DomRenderer } from 'browser/renderer/dom/DomRenderer'; -import { IKeyboardEvent, KeyboardResultType, ICharset, IBufferLine, IAttributeData, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types'; +import { IKeyboardEvent, KeyboardResultType, IBufferLine, IAttributeData, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; import { EventEmitter, IEvent } from 'common/EventEmitter'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; -import { applyWindowsMode } from './WindowsMode'; +import { updateWindowsModeWrappedState } from 'common/WindowsMode'; import { ColorManager } from 'browser/ColorManager'; import { RenderService } from 'browser/services/RenderService'; -import { IOptionsService, IBufferService, ICoreMouseService, ICoreService, ILogService, IDirtyRowService, IInstantiationService } from 'common/services/Services'; +import { IOptionsService, IBufferService, ICoreMouseService, ICoreService, ILogService, IDirtyRowService, IInstantiationService, ICharsetService, IUnicodeService } from 'common/services/Services'; import { OptionsService } from 'common/services/OptionsService'; import { ICharSizeService, IRenderService, IMouseService, ISelectionService, ISoundService, ICoreBrowserService } from 'browser/services/Services'; import { CharSizeService } from 'browser/services/CharSizeService'; import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService'; import { Disposable } from 'common/Lifecycle'; import { IBufferSet, IBuffer } from 'common/buffer/Types'; -import { Attributes } from 'common/buffer/Constants'; import { MouseService } from 'browser/services/MouseService'; import { IParams, IFunctionIdentifier } from 'common/parser/Types'; import { CoreService } from 'common/services/CoreService'; @@ -65,6 +64,8 @@ import { CoreMouseService } from 'common/services/CoreMouseService'; import { WriteBuffer } from 'common/input/WriteBuffer'; import { Linkifier2 } from 'browser/Linkifier2'; import { CoreBrowserService } from 'browser/services/CoreBrowserService'; +import { UnicodeService } from 'common/services/UnicodeService'; +import { CharsetService } from 'common/services/CharsetService'; // Let it work inside Node.js for automated testing purposes. const document = (typeof window !== 'undefined') ? window.document : null; @@ -75,10 +76,6 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp public element: HTMLElement; public screenElement: HTMLElement; - /** - * The HTMLElement that the terminal is created in, set by Terminal.open. - */ - private _parent: HTMLElement | null; private _document: Document; private _viewportScrollArea: HTMLElement; private _viewportElement: HTMLElement; @@ -97,11 +94,13 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // common services private _bufferService: IBufferService; private _coreService: ICoreService; + private _charsetService: ICharsetService; private _coreMouseService: ICoreMouseService; private _dirtyRowService: IDirtyRowService; private _instantiationService: IInstantiationService; private _logService: ILogService; public optionsService: IOptionsService; + public unicodeService: IUnicodeService; // browser services private _charSizeService: ICharSizeService; @@ -111,32 +110,13 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp private _soundService: ISoundService; // modes - public applicationKeypad: boolean; - public originMode: boolean; public insertMode: boolean; - public wraparoundMode: boolean; // defaults: xterm - true, vt100 - false public bracketedPasteMode: boolean; - // charset - // The current charset - public charset: ICharset; - public gcharset: number; - public glevel: number; - public charsets: ICharset[]; - // mouse properties public mouseEvents: CoreMouseEventType = CoreMouseEventType.NONE; public sendFocus: boolean; - // misc - public savedCols: number; - - public curAttrData: IAttributeData; - private _eraseAttrData: IAttributeData; - - public params: (string | number)[]; - public currentParam: string | number; - // write buffer private _writeBuffer: WriteBuffer; @@ -171,6 +151,8 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp public get onCursorMove(): IEvent { return this._onCursorMove.event; } private _onData = new EventEmitter(); public get onData(): IEvent { return this._onData.event; } + private _onBinary = new EventEmitter(); + public get onBinary(): IEvent { return this._onBinary.event; } private _onKey = new EventEmitter<{ key: string, domEvent: KeyboardEvent }>(); public get onKey(): IEvent<{ key: string, domEvent: KeyboardEvent }> { return this._onKey.event; } private _onLineFeed = new EventEmitter(); @@ -223,10 +205,15 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._coreService = this._instantiationService.createInstance(CoreService, () => this.scrollToBottom()); this._instantiationService.setService(ICoreService, this._coreService); this._coreService.onData(e => this._onData.fire(e)); + this._coreService.onBinary(e => this._onBinary.fire(e)); this._coreMouseService = this._instantiationService.createInstance(CoreMouseService); this._instantiationService.setService(ICoreMouseService, this._coreMouseService); this._dirtyRowService = this._instantiationService.createInstance(DirtyRowService); this._instantiationService.setService(IDirtyRowService, this._dirtyRowService); + this.unicodeService = this._instantiationService.createInstance(UnicodeService); + this._instantiationService.setService(IUnicodeService, this.unicodeService); + this._charsetService = this._instantiationService.createInstance(CharsetService); + this._instantiationService.setService(ICharsetService, this._charsetService); this._setupOptionsListeners(); this._setup(); @@ -248,43 +235,51 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp } private _setup(): void { - this._parent = document ? document.body : null; - this._customKeyEventHandler = null; // modes - this.applicationKeypad = false; - this.originMode = false; this.insertMode = false; - this.wraparoundMode = true; // defaults: xterm - true, vt100 - false this.bracketedPasteMode = false; - // charset - this.charset = null; - this.gcharset = null; - this.glevel = 0; - // TODO: Can this be just []? - this.charsets = [null]; - - this.curAttrData = DEFAULT_ATTR_DATA.clone(); - this._eraseAttrData = DEFAULT_ATTR_DATA.clone(); - - this.params = []; - this.currentParam = 0; - this._userScrolling = false; - // Register input handler and refire/handle events - this._inputHandler = new InputHandler(this, this._bufferService, this._coreService, this._dirtyRowService, this._logService, this.optionsService, this._coreMouseService); - this._inputHandler.onCursorMove(() => this._onCursorMove.fire()); - this._inputHandler.onLineFeed(() => this._onLineFeed.fire()); - this.register(this._inputHandler); + if (this._inputHandler) { + this._inputHandler.reset(); + } else { + // Register input handler and refire/handle events + this._inputHandler = new InputHandler(this, this._bufferService, this._charsetService, this._coreService, this._dirtyRowService, this._logService, this.optionsService, this._coreMouseService, this.unicodeService, this._instantiationService); + this._inputHandler.onRequestBell(() => this.bell()); + this._inputHandler.onRequestRefreshRows((start, end) => this.refresh(start, end)); + this._inputHandler.onRequestReset(() => this.reset()); + this._inputHandler.onCursorMove(() => this._onCursorMove.fire()); + this._inputHandler.onLineFeed(() => this._onLineFeed.fire()); + this.register(this._inputHandler); + } + + if (!this.linkifier) { + this.linkifier = new Linkifier(this._bufferService, this._logService, this.optionsService, this.unicodeService); + } - this.linkifier = this.linkifier || new Linkifier(this._bufferService, this._logService); this.linkifier2 = this.linkifier2 || new Linkifier2(this._bufferService); if (this.options.windowsMode) { - this._windowsMode = applyWindowsMode(this); + this._enableWindowsMode(); + } + } + + private _enableWindowsMode(): void { + if (!this._windowsMode) { + const disposables: IDisposable[] = []; + disposables.push(this.onLineFeed(updateWindowsModeWrappedState.bind(null, this._bufferService))); + disposables.push(this.addCsiHandler({ final: 'H' }, () => { + updateWindowsModeWrappedState(this._bufferService); + return false; + })); + this._windowsMode = { + dispose: () => { + disposables.forEach(d => d.dispose()); + } + }; } } @@ -299,15 +294,6 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp return this._bufferService.buffers; } - /** - * back_color_erase feature for xterm. - */ - public eraseAttrData(): IAttributeData { - this._eraseAttrData.bg &= ~(Attributes.CM_MASK | 0xFFFFFF); - this._eraseAttrData.bg |= this.curAttrData.bg & ~0xFC000000; - return this._eraseAttrData; - } - /** * Focus the terminal. Delegates focus handling to the terminal's DOM element. */ @@ -327,11 +313,17 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._renderService?.clear(); this._charSizeService?.measure(); break; + case 'cursorBlink': + case 'cursorStyle': + // The DOM renderer needs a row refresh to update the cursor styles + this.refresh(this.buffer.y, this.buffer.y); + break; case 'drawBoldTextInBrightColors': case 'letterSpacing': case 'lineHeight': case 'fontWeight': case 'fontWeightBold': + case 'minimumContrastRatio': // When the font changes the size of the cells may change which requires a renderer clear if (this._renderService) { this._renderService.clear(); @@ -365,9 +357,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp break; case 'windowsMode': if (this.optionsService.options.windowsMode) { - if (!this._windowsMode) { - this._windowsMode = applyWindowsMode(this); - } + this._enableWindowsMode(); } else { this._windowsMode?.dispose(); this._windowsMode = undefined; @@ -480,9 +470,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * @param parent The element to create the terminal within. */ public open(parent: HTMLElement): void { - this._parent = parent || this._parent; - - if (!this._parent) { + if (!parent) { throw new Error('Terminal requires a parent element.'); } @@ -490,7 +478,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._logService.warn('Terminal.open was called on an element that was not attached to the DOM'); } - this._document = this._parent.ownerDocument; + this._document = parent.ownerDocument; // Create main element container this.element = this._document.createElement('div'); @@ -498,7 +486,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this.element.classList.add('terminal'); this.element.classList.add('xterm'); this.element.setAttribute('tabindex', '0'); - this._parent.appendChild(this.element); + parent.appendChild(this.element); // Performance: Use a document fragment to build the terminal // viewport and helper elements detached from the DOM @@ -548,6 +536,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._theme = this.options.theme || this._theme; this.options.theme = undefined; this._colorManager = new ColorManager(document, this.options.allowTransparency); + this.optionsService.onOptionChange(e => this._colorManager.onOptionsChange(e)); this._colorManager.setTheme(this._theme); const renderer = this._createRenderer(); @@ -953,10 +942,9 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * 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 = false): void { + public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void { let newLine: IBufferLine; newLine = this._blankLine; - const eraseAttr = this.eraseAttrData(); if (!newLine || newLine.length !== this.cols || newLine.getFg(0) !== eraseAttr.fg || newLine.getBg(0) !== eraseAttr.bg) { newLine = this.buffer.getBlankLine(eraseAttr, isWrapped); this._blankLine = newLine; @@ -1305,27 +1293,6 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp return thirdLevelKey && (!ev.keyCode || ev.keyCode > 47); } - /** - * Set the G level of the terminal - * @param g - */ - public setgLevel(g: number): void { - this.glevel = g; - this.charset = this.charsets[g]; - } - - /** - * Set the charset for the given G level of the terminal - * @param g - * @param charset - */ - public setgCharset(g: number, charset: ICharset): void { - this.charsets[g] = charset; - if (this.glevel === g) { - this.charset = charset; - } - } - protected _keyUp(ev: KeyboardEvent): void { if (this._customKeyEventHandler && this._customKeyEventHandler(ev) === false) { return; @@ -1431,7 +1398,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp // Sync the scroll area to make sure scroll events don't fire and scroll the viewport to an // invalid location - this.viewport.syncScrollArea(true); + this.viewport?.syncScrollArea(true); this.refresh(0, this.rows - 1); this._onResize.fire({ cols: x, rows: y }); @@ -1511,18 +1478,17 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this.options.rows = this.rows; this.options.cols = this.cols; const customKeyEventHandler = this._customKeyEventHandler; - const inputHandler = this._inputHandler; const userScrolling = this._userScrolling; this._setup(); this._bufferService.reset(); + this._charsetService.reset(); this._coreService.reset(); this._coreMouseService.reset(); this._selectionService?.reset(); // reattach this._customKeyEventHandler = customKeyEventHandler; - this._inputHandler = inputHandler; this._userScrolling = userScrolling; // do a full screen refresh diff --git a/src/TestUtils.test.ts b/src/TestUtils.test.ts index 605d7c931..d0bc906dd 100644 --- a/src/TestUtils.test.ts +++ b/src/TestUtils.test.ts @@ -6,19 +6,20 @@ import { IRenderer, IRenderDimensions, CharacterJoinerHandler, IRequestRefreshRowsEvent } from 'browser/renderer/Types'; import { IInputHandlingTerminal, ICompositionHelper, ITerminal, IBrowser, ITerminalOptions } from './Types'; import { IBuffer, IBufferStringIterator, IBufferSet } from 'common/buffer/Types'; -import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, ICharset, CoreMouseEventType } from 'common/Types'; +import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, ICharset } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; import * as Browser from 'common/Platform'; import { IDisposable, IMarker, IEvent, ISelectionPosition, ILinkProvider } from 'xterm'; import { Terminal } from './Terminal'; import { AttributeData } from 'common/buffer/AttributeData'; import { IColorManager, IColorSet, ILinkMatcherOptions, ILinkifier, IViewport, ILinkifier2 } from 'browser/Types'; -import { IOptionsService } from 'common/services/Services'; +import { IOptionsService, IUnicodeService } from 'common/services/Services'; import { EventEmitter } from 'common/EventEmitter'; import { IParams, IFunctionIdentifier } from 'common/parser/Types'; import { ISelectionService } from 'browser/services/Services'; export class TestTerminal extends Terminal { + get curAttrData(): IAttributeData { return (this as any)._inputHandler._curAttrData; } keyDown(ev: any): boolean { return this._keyDown(ev); } keyPress(ev: any): boolean { return this._keyPress(ev); } } @@ -32,6 +33,7 @@ export class MockTerminal implements ITerminal { onLineFeed: IEvent; onSelectionChange: IEvent; onData: IEvent; + onBinary: IEvent; onTitleChange: IEvent; onScroll: IEvent; onKey: IEvent<{ key: string; domEvent: KeyboardEvent; }>; @@ -39,6 +41,7 @@ export class MockTerminal implements ITerminal { onResize: IEvent<{ cols: number; rows: number; }>; markers: IMarker[]; optionsService: IOptionsService; + unicodeService: IUnicodeService; addMarker(cursorYOffset: number): IMarker { throw new Error('Method not implemented.'); } @@ -200,112 +203,24 @@ export class MockTerminal implements ITerminal { export class MockInputHandlingTerminal implements IInputHandlingTerminal { onA11yCharEmitter: EventEmitter; onA11yTabEmitter: EventEmitter; - element: HTMLElement; - options: ITerminalOptions = {}; - cols: number; - rows: number; - charset: { [key: string]: string; }; - gcharset: number; - glevel: number; - charsets: { [key: string]: string; }[]; - applicationKeypad: boolean; - applicationCursor: boolean; - originMode: boolean; insertMode: boolean; - wraparoundMode: boolean; bracketedPasteMode: boolean; - curAttrData = new AttributeData(); - savedCols: number; - x10Mouse: boolean; - vt200Mouse: boolean; - normalMouse: boolean; - mouseEvents: CoreMouseEventType; sendFocus: boolean; - utfMouse: boolean; - sgrMouse: boolean; - urxvtMouse: boolean; - cursorHidden: boolean; buffers: IBufferSet; buffer: IBuffer = new MockBuffer(); viewport: IViewport; - selectionService: ISelectionService; - focus(): void { - throw new Error('Method not implemented.'); - } - convertEol: boolean; - bell(): void { - throw new Error('Method not implemented.'); - } - - updateRange(y: number): void { - throw new Error('Method not implemented.'); - } - scroll(isWrapped?: boolean): void { - throw new Error('Method not implemented.'); - } - nextStop(x?: number): number { - throw new Error('Method not implemented.'); - } - setgLevel(g: number): void { - throw new Error('Method not implemented.'); - } - eraseAttrData(): IAttributeData { - throw new Error('Method not implemented.'); - } - eraseRight(x: number, y: number): void { - throw new Error('Method not implemented.'); - } - eraseLine(y: number): void { - throw new Error('Method not implemented.'); - } - eraseLeft(x: number, y: number): void { - throw new Error('Method not implemented.'); - } - prevStop(x?: number): number { + scroll(eraseAttr: IAttributeData, isWrapped?: boolean): void { throw new Error('Method not implemented.'); } is(term: string): boolean { throw new Error('Method not implemented.'); } - setgCharset(g: number, charset: { [key: string]: string; }): void { - throw new Error('Method not implemented.'); - } resize(x: number, y: number): void { throw new Error('Method not implemented.'); } - log(text: string, data?: any): void { - throw new Error('Method not implemented.'); - } - reset(): void { - throw new Error('Method not implemented.'); - } showCursor(): void { throw new Error('Method not implemented.'); } - refresh(start: number, end: number): void { - throw new Error('Method not implemented.'); - } - matchColor(r1: number, g1: number, b1: number): number { - throw new Error('Method not implemented.'); - } - error(text: string, data?: any): void { - throw new Error('Method not implemented.'); - } - setOption(key: string, value: any): void { - (this.options)[key] = value; - } - on(type: string, listener: XtermListener): void { - throw new Error('Method not implemented.'); - } - off(type: string, listener: XtermListener): void { - throw new Error('Method not implemented.'); - } - emit(type: string, data?: any): void { - throw new Error('Method not implemented.'); - } - addDisposableListener(type: string, handler: XtermListener): IDisposable { - throw new Error('Method not implemented.'); - } handler(data: string): void { throw new Error('Method not implemented.'); } diff --git a/src/Types.d.ts b/src/Types.d.ts index 2fdc3a01d..cb0ba8444 100644 --- a/src/Types.d.ts +++ b/src/Types.d.ts @@ -7,7 +7,7 @@ import { ITerminalOptions as IPublicTerminalOptions, IDisposable, IMarker, ISele import { ICharset, IAttributeData, CharData, CoreMouseEventType } from 'common/Types'; import { IEvent, IEventEmitter } from 'common/EventEmitter'; import { IColorSet, ILinkifier, ILinkMatcherOptions, IViewport, ILinkifier2 } from 'browser/Types'; -import { IOptionsService } from 'common/services/Services'; +import { IOptionsService, IUnicodeService } from 'common/services/Services'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IParams, IFunctionIdentifier } from 'common/parser/Types'; @@ -21,22 +21,8 @@ export type LineData = CharData[]; * InputHandler cleanly from the ITerminal interface. */ export interface IInputHandlingTerminal { - element: HTMLElement; - options: ITerminalOptions; - cols: number; - rows: number; - charset: ICharset; - gcharset: number; - glevel: number; - charsets: ICharset[]; - applicationKeypad: boolean; - originMode: boolean; insertMode: boolean; - wraparoundMode: boolean; bracketedPasteMode: boolean; - curAttrData: IAttributeData; - savedCols: number; - mouseEvents: CoreMouseEventType; sendFocus: boolean; buffers: IBufferSet; @@ -46,17 +32,10 @@ export interface IInputHandlingTerminal { onA11yCharEmitter: IEventEmitter; onA11yTabEmitter: IEventEmitter; - bell(): void; - focus(): void; - scroll(isWrapped?: boolean): void; - setgLevel(g: number): void; - eraseAttrData(): IAttributeData; + scroll(eraseAttr: IAttributeData, isWrapped?: boolean): void; is(term: string): boolean; - setgCharset(g: number, charset: ICharset): void; resize(x: number, y: number): void; - reset(): void; showCursor(): void; - refresh(start: number, end: number): void; handleTitle(title: string): void; } @@ -141,7 +120,7 @@ export interface IInputHandler { /** ESC D */ index(): void; /** ESC H */ tabSet(): void; /** ESC M */ reverseIndex(): void; - /** ESC c */ reset(): void; + /** ESC c */ fullReset(): void; /** ESC n ESC o ESC | @@ -160,6 +139,7 @@ export interface ITerminal extends IPublicTerminal, IElementAccessor, IBufferAcc optionsService: IOptionsService; // TODO: We should remove options once components adopt optionsService options: ITerminalOptions; + unicodeService: IUnicodeService; onBlur: IEvent; onFocus: IEvent; @@ -180,6 +160,7 @@ export interface IPublicTerminal extends IDisposable { markers: IMarker[]; onCursorMove: IEvent; onData: IEvent; + onBinary: IEvent; onKey: IEvent<{ key: string, domEvent: KeyboardEvent }>; onLineFeed: IEvent; onScroll: IEvent; @@ -240,10 +221,7 @@ export interface ITerminalOptions extends IPublicTerminalOptions { [key: string]: any; cancelEvents?: boolean; convertEol?: boolean; - handler?: (data: string) => void; - screenKeys?: boolean; termName?: string; - useFlowControl?: boolean; } export interface IBrowser { diff --git a/src/browser/Color.test.ts b/src/browser/Color.test.ts new file mode 100644 index 000000000..ff8da246f --- /dev/null +++ b/src/browser/Color.test.ts @@ -0,0 +1,280 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { channels, color, css, rgb, rgba, toPaddedHex, contrastRatio } from 'browser/Color'; + +describe('Color', () => { + + describe('channels', () => { + describe('toCss', () => { + it('should convert an rgb array to css hex string', () => { + assert.equal(channels.toCss(0x00, 0x00, 0x00), '#000000'); + assert.equal(channels.toCss(0x10, 0x10, 0x10), '#101010'); + assert.equal(channels.toCss(0x20, 0x20, 0x20), '#202020'); + assert.equal(channels.toCss(0x30, 0x30, 0x30), '#303030'); + assert.equal(channels.toCss(0x40, 0x40, 0x40), '#404040'); + assert.equal(channels.toCss(0x50, 0x50, 0x50), '#505050'); + assert.equal(channels.toCss(0x60, 0x60, 0x60), '#606060'); + assert.equal(channels.toCss(0x70, 0x70, 0x70), '#707070'); + assert.equal(channels.toCss(0x80, 0x80, 0x80), '#808080'); + assert.equal(channels.toCss(0x90, 0x90, 0x90), '#909090'); + assert.equal(channels.toCss(0xa0, 0xa0, 0xa0), '#a0a0a0'); + assert.equal(channels.toCss(0xb0, 0xb0, 0xb0), '#b0b0b0'); + assert.equal(channels.toCss(0xc0, 0xc0, 0xc0), '#c0c0c0'); + assert.equal(channels.toCss(0xd0, 0xd0, 0xd0), '#d0d0d0'); + assert.equal(channels.toCss(0xe0, 0xe0, 0xe0), '#e0e0e0'); + assert.equal(channels.toCss(0xf0, 0xf0, 0xf0), '#f0f0f0'); + assert.equal(channels.toCss(0xff, 0xff, 0xff), '#ffffff'); + }); + }); + + describe('toRgba', () => { + it('should convert an rgb array to an rgba number', () => { + assert.equal(channels.toRgba(0x00, 0x00, 0x00), 0x000000FF); + assert.equal(channels.toRgba(0x10, 0x10, 0x10), 0x101010FF); + assert.equal(channels.toRgba(0x20, 0x20, 0x20), 0x202020FF); + assert.equal(channels.toRgba(0x30, 0x30, 0x30), 0x303030FF); + assert.equal(channels.toRgba(0x40, 0x40, 0x40), 0x404040FF); + assert.equal(channels.toRgba(0x50, 0x50, 0x50), 0x505050FF); + assert.equal(channels.toRgba(0x60, 0x60, 0x60), 0x606060FF); + assert.equal(channels.toRgba(0x70, 0x70, 0x70), 0x707070FF); + assert.equal(channels.toRgba(0x80, 0x80, 0x80), 0x808080FF); + assert.equal(channels.toRgba(0x90, 0x90, 0x90), 0x909090FF); + assert.equal(channels.toRgba(0xa0, 0xa0, 0xa0), 0xa0a0a0FF); + assert.equal(channels.toRgba(0xb0, 0xb0, 0xb0), 0xb0b0b0FF); + assert.equal(channels.toRgba(0xc0, 0xc0, 0xc0), 0xc0c0c0FF); + assert.equal(channels.toRgba(0xd0, 0xd0, 0xd0), 0xd0d0d0FF); + assert.equal(channels.toRgba(0xe0, 0xe0, 0xe0), 0xe0e0e0FF); + assert.equal(channels.toRgba(0xf0, 0xf0, 0xf0), 0xf0f0f0FF); + assert.equal(channels.toRgba(0xff, 0xff, 0xff), 0xffffffFF); + }); + it('should convert an rgba array to an rgba number', () => { + assert.equal(channels.toRgba(0x00, 0x00, 0x00, 0x00), 0x00000000); + assert.equal(channels.toRgba(0x10, 0x10, 0x10, 0x10), 0x10101010); + assert.equal(channels.toRgba(0x20, 0x20, 0x20, 0x20), 0x20202020); + assert.equal(channels.toRgba(0x30, 0x30, 0x30, 0x30), 0x30303030); + assert.equal(channels.toRgba(0x40, 0x40, 0x40, 0x40), 0x40404040); + assert.equal(channels.toRgba(0x50, 0x50, 0x50, 0x50), 0x50505050); + assert.equal(channels.toRgba(0x60, 0x60, 0x60, 0x60), 0x60606060); + assert.equal(channels.toRgba(0x70, 0x70, 0x70, 0x70), 0x70707070); + assert.equal(channels.toRgba(0x80, 0x80, 0x80, 0x80), 0x80808080); + assert.equal(channels.toRgba(0x90, 0x90, 0x90, 0x90), 0x90909090); + assert.equal(channels.toRgba(0xa0, 0xa0, 0xa0, 0xa0), 0xa0a0a0a0); + assert.equal(channels.toRgba(0xb0, 0xb0, 0xb0, 0xb0), 0xb0b0b0b0); + assert.equal(channels.toRgba(0xc0, 0xc0, 0xc0, 0xc0), 0xc0c0c0c0); + assert.equal(channels.toRgba(0xd0, 0xd0, 0xd0, 0xd0), 0xd0d0d0d0); + assert.equal(channels.toRgba(0xe0, 0xe0, 0xe0, 0xe0), 0xe0e0e0e0); + assert.equal(channels.toRgba(0xf0, 0xf0, 0xf0, 0xf0), 0xf0f0f0f0); + assert.equal(channels.toRgba(0xff, 0xff, 0xff, 0xff), 0xffffffff); + }); + }); + }); + + describe('color', () => { + describe('blend', () => { + it('should blend colors based on the alpha channel', () => { + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF00', rgba: 0xFFFFFF00 }), { css: '#000000', rgba: 0x000000FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF10', rgba: 0xFFFFFF10 }), { css: '#101010', rgba: 0x101010FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF20', rgba: 0xFFFFFF20 }), { css: '#202020', rgba: 0x202020FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF30', rgba: 0xFFFFFF30 }), { css: '#303030', rgba: 0x303030FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF40', rgba: 0xFFFFFF40 }), { css: '#404040', rgba: 0x404040FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF50', rgba: 0xFFFFFF50 }), { css: '#505050', rgba: 0x505050FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF60', rgba: 0xFFFFFF60 }), { css: '#606060', rgba: 0x606060FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF70', rgba: 0xFFFFFF70 }), { css: '#707070', rgba: 0x707070FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF80', rgba: 0xFFFFFF80 }), { css: '#808080', rgba: 0x808080FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFF90', rgba: 0xFFFFFF90 }), { css: '#909090', rgba: 0x909090FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFFA0', rgba: 0xFFFFFFA0 }), { css: '#a0a0a0', rgba: 0xA0A0A0FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFFB0', rgba: 0xFFFFFFB0 }), { css: '#b0b0b0', rgba: 0xB0B0B0FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFFC0', rgba: 0xFFFFFFC0 }), { css: '#c0c0c0', rgba: 0xC0C0C0FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFFD0', rgba: 0xFFFFFFD0 }), { css: '#d0d0d0', rgba: 0xD0D0D0FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFFE0', rgba: 0xFFFFFFE0 }), { css: '#e0e0e0', rgba: 0xE0E0E0FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFFF0', rgba: 0xFFFFFFF0 }), { css: '#f0f0f0', rgba: 0xF0F0F0FF }); + assert.deepEqual(color.blend({ css: '#000000', rgba: 0x000000FF }, { css: '#FFFFFFFF', rgba: 0xFFFFFFFF }), { css: '#FFFFFFFF', rgba: 0xFFFFFFFF }); + }); + }); + + describe('opaque', () => { + it('should make the color opaque', () => { + assert.deepEqual(color.opaque({ css: '#00000000', rgba: 0x00000000 }), { css: '#000000', rgba: 0x000000FF }); + assert.deepEqual(color.opaque({ css: '#10101010', rgba: 0x10101010 }), { css: '#101010', rgba: 0x101010FF }); + assert.deepEqual(color.opaque({ css: '#20202020', rgba: 0x20202020 }), { css: '#202020', rgba: 0x202020FF }); + assert.deepEqual(color.opaque({ css: '#30303030', rgba: 0x30303030 }), { css: '#303030', rgba: 0x303030FF }); + assert.deepEqual(color.opaque({ css: '#40404040', rgba: 0x40404040 }), { css: '#404040', rgba: 0x404040FF }); + assert.deepEqual(color.opaque({ css: '#50505050', rgba: 0x50505050 }), { css: '#505050', rgba: 0x505050FF }); + assert.deepEqual(color.opaque({ css: '#60606060', rgba: 0x60606060 }), { css: '#606060', rgba: 0x606060FF }); + assert.deepEqual(color.opaque({ css: '#70707070', rgba: 0x70707070 }), { css: '#707070', rgba: 0x707070FF }); + assert.deepEqual(color.opaque({ css: '#80808080', rgba: 0x80808080 }), { css: '#808080', rgba: 0x808080FF }); + assert.deepEqual(color.opaque({ css: '#90909090', rgba: 0x90909090 }), { css: '#909090', rgba: 0x909090FF }); + assert.deepEqual(color.opaque({ css: '#a0a0a0a0', rgba: 0xa0a0a0a0 }), { css: '#a0a0a0', rgba: 0xa0a0a0FF }); + assert.deepEqual(color.opaque({ css: '#b0b0b0b0', rgba: 0xb0b0b0b0 }), { css: '#b0b0b0', rgba: 0xb0b0b0FF }); + assert.deepEqual(color.opaque({ css: '#c0c0c0c0', rgba: 0xc0c0c0c0 }), { css: '#c0c0c0', rgba: 0xc0c0c0FF }); + assert.deepEqual(color.opaque({ css: '#d0d0d0d0', rgba: 0xd0d0d0d0 }), { css: '#d0d0d0', rgba: 0xd0d0d0FF }); + assert.deepEqual(color.opaque({ css: '#e0e0e0e0', rgba: 0xe0e0e0e0 }), { css: '#e0e0e0', rgba: 0xe0e0e0FF }); + assert.deepEqual(color.opaque({ css: '#f0f0f0f0', rgba: 0xf0f0f0f0 }), { css: '#f0f0f0', rgba: 0xf0f0f0FF }); + assert.deepEqual(color.opaque({ css: '#ffffffff', rgba: 0xffffffff }), { css: '#ffffff', rgba: 0xffffffFF }); + }); + }); + }); + + describe('css', () => { + describe('toColor', () => { + it('should covert a CSS string to an IColor', () => { + assert.deepEqual(css.toColor('#000000'), { css: '#000000', rgba: 0x000000FF }); + assert.deepEqual(css.toColor('#101010'), { css: '#101010', rgba: 0x101010FF }); + assert.deepEqual(css.toColor('#202020'), { css: '#202020', rgba: 0x202020FF }); + assert.deepEqual(css.toColor('#303030'), { css: '#303030', rgba: 0x303030FF }); + assert.deepEqual(css.toColor('#404040'), { css: '#404040', rgba: 0x404040FF }); + assert.deepEqual(css.toColor('#505050'), { css: '#505050', rgba: 0x505050FF }); + assert.deepEqual(css.toColor('#606060'), { css: '#606060', rgba: 0x606060FF }); + assert.deepEqual(css.toColor('#707070'), { css: '#707070', rgba: 0x707070FF }); + assert.deepEqual(css.toColor('#808080'), { css: '#808080', rgba: 0x808080FF }); + assert.deepEqual(css.toColor('#909090'), { css: '#909090', rgba: 0x909090FF }); + assert.deepEqual(css.toColor('#a0a0a0'), { css: '#a0a0a0', rgba: 0xa0a0a0FF }); + assert.deepEqual(css.toColor('#b0b0b0'), { css: '#b0b0b0', rgba: 0xb0b0b0FF }); + assert.deepEqual(css.toColor('#c0c0c0'), { css: '#c0c0c0', rgba: 0xc0c0c0FF }); + assert.deepEqual(css.toColor('#d0d0d0'), { css: '#d0d0d0', rgba: 0xd0d0d0FF }); + assert.deepEqual(css.toColor('#e0e0e0'), { css: '#e0e0e0', rgba: 0xe0e0e0FF }); + assert.deepEqual(css.toColor('#f0f0f0'), { css: '#f0f0f0', rgba: 0xf0f0f0FF }); + assert.deepEqual(css.toColor('#ffffff'), { css: '#ffffff', rgba: 0xffffffFF }); + }); + }); + }); + + describe('rgb', () => { + describe('relativeLuminance', () => { + it('should calculate the relative luminance of the color', () => { + assert.equal(rgb.relativeLuminance(0x000000), 0); + assert.equal(rgb.relativeLuminance(0x101010).toFixed(4), '0.0052'); + assert.equal(rgb.relativeLuminance(0x202020).toFixed(4), '0.0144'); + assert.equal(rgb.relativeLuminance(0x303030).toFixed(4), '0.0296'); + assert.equal(rgb.relativeLuminance(0x404040).toFixed(4), '0.0513'); + assert.equal(rgb.relativeLuminance(0x505050).toFixed(4), '0.0802'); + assert.equal(rgb.relativeLuminance(0x606060).toFixed(4), '0.1170'); + assert.equal(rgb.relativeLuminance(0x707070).toFixed(4), '0.1620'); + assert.equal(rgb.relativeLuminance(0x808080).toFixed(4), '0.2159'); + assert.equal(rgb.relativeLuminance(0x909090).toFixed(4), '0.2789'); + assert.equal(rgb.relativeLuminance(0xA0A0A0).toFixed(4), '0.3515'); + assert.equal(rgb.relativeLuminance(0xB0B0B0).toFixed(4), '0.4342'); + assert.equal(rgb.relativeLuminance(0xC0C0C0).toFixed(4), '0.5271'); + assert.equal(rgb.relativeLuminance(0xD0D0D0).toFixed(4), '0.6308'); + assert.equal(rgb.relativeLuminance(0xE0E0E0).toFixed(4), '0.7454'); + assert.equal(rgb.relativeLuminance(0xF0F0F0).toFixed(4), '0.8714'); + assert.equal(rgb.relativeLuminance(0xFFFFFF), 1); + }); + }); + }); + + describe('rgba', () => { + describe('ensureContrastRatio', () => { + it('should return undefined if the color already meets the contrast ratio (black bg)', () => { + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 1), undefined); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 2), undefined); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 3), undefined); + }); + it('should return a color that meets the contrast ratio (black bg)', () => { + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 4), 0x707070ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 5), 0x7f7f7fff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 6), 0x8c8c8cff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 7), 0x989898ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 8), 0xa3a3a3ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 9), 0xadadadff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 10), 0xb6b6b6ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 11), 0xbebebeff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 12), 0xc5c5c5ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 13), 0xd1d1d1ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 14), 0xd6d6d6ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 15), 0xdbdbdbff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 16), 0xe3e3e3ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 17), 0xe9e9e9ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 18), 0xeeeeeeff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 19), 0xf4f4f4ff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 20), 0xfafafaff); + assert.equal(rgba.ensureContrastRatio(0x000000ff, 0x606060ff, 21), 0xffffffff); + }); + it('should return undefined if the color already meets the contrast ratio (white bg)', () => { + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 1), undefined); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 2), undefined); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 3), undefined); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 4), undefined); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 5), undefined); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 6), undefined); + }); + it('should return a color that meets the contrast ratio (white bg)', () => { + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 7), 0x565656ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 8), 0x4d4d4dff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 9), 0x454545ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 10), 0x3e3e3eff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 11), 0x373737ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 12), 0x313131ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 13), 0x313131ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 14), 0x272727ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 15), 0x232323ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 16), 0x1f1f1fff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 17), 0x1b1b1bff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 18), 0x151515ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 19), 0x101010ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 20), 0x080808ff); + assert.equal(rgba.ensureContrastRatio(0xffffffff, 0x606060ff, 21), 0x000000ff); + }); + }); + + describe('toChannels', () => { + it('should convert an rgba number to an rgba array', () => { + assert.deepEqual(rgba.toChannels(0x00000000), [0x00, 0x00, 0x00, 0x00]); + assert.deepEqual(rgba.toChannels(0x10101010), [0x10, 0x10, 0x10, 0x10]); + assert.deepEqual(rgba.toChannels(0x20202020), [0x20, 0x20, 0x20, 0x20]); + assert.deepEqual(rgba.toChannels(0x30303030), [0x30, 0x30, 0x30, 0x30]); + assert.deepEqual(rgba.toChannels(0x40404040), [0x40, 0x40, 0x40, 0x40]); + assert.deepEqual(rgba.toChannels(0x50505050), [0x50, 0x50, 0x50, 0x50]); + assert.deepEqual(rgba.toChannels(0x60606060), [0x60, 0x60, 0x60, 0x60]); + assert.deepEqual(rgba.toChannels(0x70707070), [0x70, 0x70, 0x70, 0x70]); + assert.deepEqual(rgba.toChannels(0x80808080), [0x80, 0x80, 0x80, 0x80]); + assert.deepEqual(rgba.toChannels(0x90909090), [0x90, 0x90, 0x90, 0x90]); + assert.deepEqual(rgba.toChannels(0xa0a0a0a0), [0xa0, 0xa0, 0xa0, 0xa0]); + assert.deepEqual(rgba.toChannels(0xb0b0b0b0), [0xb0, 0xb0, 0xb0, 0xb0]); + assert.deepEqual(rgba.toChannels(0xc0c0c0c0), [0xc0, 0xc0, 0xc0, 0xc0]); + assert.deepEqual(rgba.toChannels(0xd0d0d0d0), [0xd0, 0xd0, 0xd0, 0xd0]); + assert.deepEqual(rgba.toChannels(0xe0e0e0e0), [0xe0, 0xe0, 0xe0, 0xe0]); + assert.deepEqual(rgba.toChannels(0xf0f0f0f0), [0xf0, 0xf0, 0xf0, 0xf0]); + assert.deepEqual(rgba.toChannels(0xffffffff), [0xff, 0xff, 0xff, 0xff]); + }); + }); + }); + + describe('toPaddedHex', () => { + it('should convert numbers to 2-digit hex values', () => { + assert.equal(toPaddedHex(0x00), '00'); + assert.equal(toPaddedHex(0x10), '10'); + assert.equal(toPaddedHex(0x20), '20'); + assert.equal(toPaddedHex(0x30), '30'); + assert.equal(toPaddedHex(0x40), '40'); + assert.equal(toPaddedHex(0x50), '50'); + assert.equal(toPaddedHex(0x60), '60'); + assert.equal(toPaddedHex(0x70), '70'); + assert.equal(toPaddedHex(0x80), '80'); + assert.equal(toPaddedHex(0x90), '90'); + assert.equal(toPaddedHex(0xa0), 'a0'); + assert.equal(toPaddedHex(0xb0), 'b0'); + assert.equal(toPaddedHex(0xc0), 'c0'); + assert.equal(toPaddedHex(0xd0), 'd0'); + assert.equal(toPaddedHex(0xe0), 'e0'); + assert.equal(toPaddedHex(0xf0), 'f0'); + assert.equal(toPaddedHex(0xff), 'ff'); + }); + }); + + describe('contrastRatio', () => { + it('should calculate the relative luminance of the color', () => { + assert.equal(contrastRatio(0, 0), 1); + assert.equal(contrastRatio(0, 0.5), 11); + assert.equal(contrastRatio(0, 1), 21); + }); + it('should work regardless of the parameter order', () => { + assert.equal(contrastRatio(0, 1), 21); + assert.equal(contrastRatio(1, 0), 21); + }); + }); +}); diff --git a/src/browser/Color.ts b/src/browser/Color.ts new file mode 100644 index 000000000..9649227e2 --- /dev/null +++ b/src/browser/Color.ts @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColor } from 'browser/Types'; + +/** + * Helper functions where the source type is "channels" (individual color channels as numbers). + */ +export namespace channels { + export function toCss(r: number, g: number, b: number, a?: number): string { + if (a !== undefined) { + return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}${toPaddedHex(a)}`; + } + return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`; + } + + export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number { + // >>> 0 forces an unsigned int + return (r << 24 | g << 16 | b << 8 | a) >>> 0; + } +} + +/** + * Helper functions where the source type is `IColor`. + */ +export namespace color { + export function blend(bg: IColor, fg: IColor): IColor { + const a = (fg.rgba & 0xFF) / 255; + if (a === 1) { + return { + css: fg.css, + rgba: fg.rgba + }; + } + const fgR = (fg.rgba >> 24) & 0xFF; + const fgG = (fg.rgba >> 16) & 0xFF; + const fgB = (fg.rgba >> 8) & 0xFF; + const bgR = (bg.rgba >> 24) & 0xFF; + const bgG = (bg.rgba >> 16) & 0xFF; + const bgB = (bg.rgba >> 8) & 0xFF; + const r = bgR + Math.round((fgR - bgR) * a); + const g = bgG + Math.round((fgG - bgG) * a); + const b = bgB + Math.round((fgB - bgB) * a); + const css = channels.toCss(r, g, b); + const rgba = channels.toRgba(r, g, b); + return { css, rgba }; + } + + export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined { + const result = rgba.ensureContrastRatio(bg.rgba, fg.rgba, ratio); + if (!result) { + return undefined; + } + return rgba.toColor( + (result >> 24 & 0xFF), + (result >> 16 & 0xFF), + (result >> 8 & 0xFF) + ); + } + + export function opaque(color: IColor): IColor { + const rgbaColor = (color.rgba | 0xFF) >>> 0; + const [r, g, b] = rgba.toChannels(rgbaColor); + return { + css: channels.toCss(r, g, b), + rgba: rgbaColor + }; + } +} + +/** + * Helper functions where the source type is "css" (string: '#rgb', '#rgba', '#rrggbb', '#rrggbbaa'). + */ +export namespace css { + export function toColor(css: string): IColor { + return { + css, + rgba: (parseInt(css.slice(1), 16) << 8 | 0xFF) >>> 0 + }; + } +} + +/** + * Helper functions where the source type is "rgb" (number: 0xrrggbb). + */ +export namespace rgb { + /** + * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio + * between two colors. + * @param rgb The color to use. + * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef + */ + export function relativeLuminance(rgb: number): number { + return relativeLuminance2( + (rgb >> 16) & 0xFF, + (rgb >> 8 ) & 0xFF, + (rgb ) & 0xFF); + } + + /** + * Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio + * between two colors. + * @param r The red channel (0x00 to 0xFF). + * @param g The green channel (0x00 to 0xFF). + * @param b The blue channel (0x00 to 0xFF). + * @see https://www.w3.org/TR/WCAG20/#relativeluminancedef + */ + export function relativeLuminance2(r: number, g: number, b: number): number { + const rs = r / 255; + const gs = g / 255; + const bs = b / 255; + const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4); + const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4); + const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4); + return rr * 0.2126 + rg * 0.7152 + rb * 0.0722; + } +} + +/** + * Helper functions where the source type is "rgba" (number: 0xrrggbbaa). + */ +export namespace rgba { + export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined { + const bgL = rgb.relativeLuminance(bgRgba >> 8); + const fgL = rgb.relativeLuminance(fgRgba >> 8); + const cr = contrastRatio(bgL, fgL); + if (cr < ratio) { + if (fgL < bgL) { + return reduceLuminance(bgRgba, fgRgba, ratio); + } + return increaseLuminance(bgRgba, fgRgba, ratio); + } + return undefined; + } + + export function reduceLuminance(bgRgba: number, fgRgba: number, ratio: number): number { + // This is a naive but fast approach to reducing luminance as converting to + // HSL and back is expensive + const bgR = (bgRgba >> 24) & 0xFF; + const bgG = (bgRgba >> 16) & 0xFF; + const bgB = (bgRgba >> 8) & 0xFF; + let fgR = (fgRgba >> 24) & 0xFF; + let fgG = (fgRgba >> 16) & 0xFF; + let fgB = (fgRgba >> 8) & 0xFF; + let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgB, fgG), rgb.relativeLuminance2(bgR, bgG, bgB)); + while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) { + // Reduce by 10% until the ratio is hit + fgR -= Math.max(0, Math.ceil(fgR * 0.1)); + fgG -= Math.max(0, Math.ceil(fgG * 0.1)); + fgB -= Math.max(0, Math.ceil(fgB * 0.1)); + cr = contrastRatio(rgb.relativeLuminance2(fgR, fgB, fgG), rgb.relativeLuminance2(bgR, bgG, bgB)); + } + return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0; + } + + export function increaseLuminance(bgRgba: number, fgRgba: number, ratio: number): number { + // This is a naive but fast approach to increasing luminance as converting to + // HSL and back is expensive + const bgR = (bgRgba >> 24) & 0xFF; + const bgG = (bgRgba >> 16) & 0xFF; + const bgB = (bgRgba >> 8) & 0xFF; + let fgR = (fgRgba >> 24) & 0xFF; + let fgG = (fgRgba >> 16) & 0xFF; + let fgB = (fgRgba >> 8) & 0xFF; + let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgB, fgG), rgb.relativeLuminance2(bgR, bgG, bgB)); + while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) { + // Increase by 10% until the ratio is hit + fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1)); + fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1)); + fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1)); + cr = contrastRatio(rgb.relativeLuminance2(fgR, fgB, fgG), rgb.relativeLuminance2(bgR, bgG, bgB)); + } + return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0; + } + + export function toChannels(value: number): [number, number, number, number] { + return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]; + } + + export function toColor(r: number, g: number, b: number): IColor { + return { + css: channels.toCss(r, g, b), + rgba: channels.toRgba(r, g, b) + }; + } +} + +export function toPaddedHex(c: number): string { + const s = c.toString(16); + return s.length < 2 ? '0' + s : s; +} + +/** + * Gets the contrast ratio between two relative luminance values. + * @param l1 The first relative luminance. + * @param l2 The first relative luminance. + * @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef + */ +export function contrastRatio(l1: number, l2: number): number { + if (l1 < l2) { + return (l2 + 0.05) / (l1 + 0.05); + } + return (l1 + 0.05) / (l2 + 0.05); +} diff --git a/src/browser/ColorContrastCache.test.ts b/src/browser/ColorContrastCache.test.ts new file mode 100644 index 000000000..17e5df383 --- /dev/null +++ b/src/browser/ColorContrastCache.test.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { ColorContrastCache } from 'browser/ColorContrastCache'; + +describe('ColorContrastCache', () => { + let cache: ColorContrastCache; + + beforeEach(() => { + cache = new ColorContrastCache(); + }); + + it('should save and get color values', () => { + assert.strictEqual(cache.getColor(0x01, 0x00), undefined); + cache.setColor(0x01, 0x01, null); + assert.strictEqual(cache.getColor(0x01, 0x01), null); + cache.setColor(0x01, 0x02, { css: '#030303', rgba: 0x030303ff}); + assert.deepEqual(cache.getColor(0x01, 0x02), { css: '#030303', rgba: 0x030303ff}); + }); + + it('should save and get css values', () => { + assert.strictEqual(cache.getCss(0x01, 0x00), undefined); + cache.setCss(0x01, 0x01, null); + assert.strictEqual(cache.getCss(0x01, 0x01), null); + cache.setCss(0x01, 0x02, '#030303'); + assert.deepEqual(cache.getCss(0x01, 0x02), '#030303'); + }); + + it('should clear all values on clear', () => { + cache.setColor(0x01, 0x01, null); + cache.setColor(0x01, 0x02, { css: '#030303', rgba: 0x030303ff}); + cache.setCss(0x01, 0x01, null); + cache.setCss(0x01, 0x02, '#030303'); + cache.clear(); + assert.strictEqual(cache.getColor(0x01, 0x01), undefined); + assert.strictEqual(cache.getColor(0x01, 0x02), undefined); + assert.strictEqual(cache.getCss(0x01, 0x01), undefined); + assert.strictEqual(cache.getCss(0x01, 0x02), undefined); + }); +}); diff --git a/src/browser/ColorContrastCache.ts b/src/browser/ColorContrastCache.ts new file mode 100644 index 000000000..b96b66cc4 --- /dev/null +++ b/src/browser/ColorContrastCache.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColor, IColorContrastCache } from 'browser/Types'; + +export class ColorContrastCache implements IColorContrastCache { + private _color: { [bg: number]: { [fg: number]: IColor | null | undefined } | undefined } = {}; + private _rgba: { [bg: number]: { [fg: number]: string | null | undefined } | undefined } = {}; + + public clear(): void { + this._color = {}; + this._rgba = {}; + } + + public setCss(bg: number, fg: number, value: string | null): void { + if (!this._rgba[bg]) { + this._rgba[bg] = {}; + } + this._rgba[bg]![fg] = value; + } + + public getCss(bg: number, fg: number): string | null | undefined { + return this._rgba[bg] ? this._rgba[bg]![fg] : undefined; + } + + public setColor(bg: number, fg: number, value: IColor | null): void { + if (!this._color[bg]) { + this._color[bg] = {}; + } + this._color[bg]![fg] = value; + } + + public getColor(bg: number, fg: number): IColor | null | undefined { + return this._color[bg] ? this._color[bg]![fg] : undefined; + } +} diff --git a/src/browser/ColorManager.test.ts b/src/browser/ColorManager.test.ts index a213c6166..39a8f8c2f 100644 --- a/src/browser/ColorManager.test.ts +++ b/src/browser/ColorManager.test.ts @@ -34,7 +34,7 @@ describe('ColorManager', () => { describe('constructor', () => { it('should fill all colors with values', () => { for (const key of Object.keys(cm.colors)) { - if (key !== 'ansi') { + if (key !== 'ansi' && key !== 'contrastCache') { // A #rrggbb or rgba(...) assert.ok((cm.colors)[key].css.length >= 7); } diff --git a/src/browser/ColorManager.ts b/src/browser/ColorManager.ts index 70d21a7a4..c8224a921 100644 --- a/src/browser/ColorManager.ts +++ b/src/browser/ColorManager.ts @@ -3,16 +3,18 @@ * @license MIT */ -import { IColorManager, IColor, IColorSet } from 'browser/Types'; +import { IColorManager, IColor, IColorSet, IColorContrastCache } from 'browser/Types'; import { ITheme } from 'common/services/Services'; +import { channels, color, css } from 'browser/Color'; +import { ColorContrastCache } from 'browser/ColorContrastCache'; -const DEFAULT_FOREGROUND = fromHex('#ffffff'); -const DEFAULT_BACKGROUND = fromHex('#000000'); -const DEFAULT_CURSOR = fromHex('#ffffff'); -const DEFAULT_CURSOR_ACCENT = fromHex('#000000'); +const DEFAULT_FOREGROUND = css.toColor('#ffffff'); +const DEFAULT_BACKGROUND = css.toColor('#000000'); +const DEFAULT_CURSOR = css.toColor('#ffffff'); +const DEFAULT_CURSOR_ACCENT = css.toColor('#000000'); const DEFAULT_SELECTION = { css: 'rgba(255, 255, 255, 0.3)', - rgba: 0xFFFFFF77 + rgba: 0xFFFFFF4D }; // An IIFE to generate DEFAULT_ANSI_COLORS. Do not mutate DEFAULT_ANSI_COLORS, instead make a copy @@ -20,23 +22,23 @@ const DEFAULT_SELECTION = { export const DEFAULT_ANSI_COLORS = (() => { const colors = [ // dark: - fromHex('#2e3436'), - fromHex('#cc0000'), - fromHex('#4e9a06'), - fromHex('#c4a000'), - fromHex('#3465a4'), - fromHex('#75507b'), - fromHex('#06989a'), - fromHex('#d3d7cf'), + css.toColor('#2e3436'), + css.toColor('#cc0000'), + css.toColor('#4e9a06'), + css.toColor('#c4a000'), + css.toColor('#3465a4'), + css.toColor('#75507b'), + css.toColor('#06989a'), + css.toColor('#d3d7cf'), // bright: - fromHex('#555753'), - fromHex('#ef2929'), - fromHex('#8ae234'), - fromHex('#fce94f'), - fromHex('#729fcf'), - fromHex('#ad7fa8'), - fromHex('#34e2e2'), - fromHex('#eeeeec') + css.toColor('#555753'), + css.toColor('#ef2929'), + css.toColor('#8ae234'), + css.toColor('#fce94f'), + css.toColor('#729fcf'), + css.toColor('#ad7fa8'), + css.toColor('#34e2e2'), + css.toColor('#eeeeec') ]; // Fill in the remaining 240 ANSI colors. @@ -47,37 +49,23 @@ export const DEFAULT_ANSI_COLORS = (() => { const g = v[(i / 6) % 6 | 0]; const b = v[i % 6]; colors.push({ - css: `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`, - // Use >>> 0 to force a conversion to an unsigned int - rgba: ((r << 24) | (g << 16) | (b << 8) | 0xFF) >>> 0 + css: channels.toCss(r, g, b), + rgba: channels.toRgba(r, g, b) }); } // Generate greys (232-255) for (let i = 0; i < 24; i++) { const c = 8 + i * 10; - const ch = toPaddedHex(c); colors.push({ - css: `#${ch}${ch}${ch}`, - rgba: ((c << 24) | (c << 16) | (c << 8) | 0xFF) >>> 0 + css: channels.toCss(c, c, c), + rgba: channels.toRgba(c, c, c) }); } return colors; })(); -function fromHex(css: string): IColor { - return { - css, - rgba: parseInt(css.slice(1), 16) << 8 | 0xFF - }; -} - -function toPaddedHex(c: number): string { - const s = c.toString(16); - return s.length < 2 ? '0' + s : s; -} - /** * Manages the source of truth for a terminal's colors. */ @@ -85,6 +73,7 @@ export class ColorManager implements IColorManager { public colors: IColorSet; private _ctx: CanvasRenderingContext2D; private _litmusColor: CanvasGradient; + private _contrastCache: IColorContrastCache; constructor(document: Document, public allowTransparency: boolean) { const canvas = document.createElement('canvas'); @@ -97,16 +86,25 @@ export class ColorManager implements IColorManager { this._ctx = ctx; this._ctx.globalCompositeOperation = 'copy'; this._litmusColor = this._ctx.createLinearGradient(0, 0, 1, 1); + this._contrastCache = new ColorContrastCache(); this.colors = { foreground: DEFAULT_FOREGROUND, background: DEFAULT_BACKGROUND, cursor: DEFAULT_CURSOR, cursorAccent: DEFAULT_CURSOR_ACCENT, selection: DEFAULT_SELECTION, - ansi: DEFAULT_ANSI_COLORS.slice() + selectionOpaque: color.blend(DEFAULT_BACKGROUND, DEFAULT_SELECTION), + ansi: DEFAULT_ANSI_COLORS.slice(), + contrastCache: this._contrastCache }; } + public onOptionsChange(key: string): void { + if (key === 'minimumContrastRatio') { + this._contrastCache.clear(); + } + } + /** * Sets the terminal's theme. * @param theme The theme to use. If a partial theme is provided then default @@ -118,6 +116,7 @@ export class ColorManager implements IColorManager { this.colors.cursor = this._parseColor(theme.cursor, DEFAULT_CURSOR, true); this.colors.cursorAccent = this._parseColor(theme.cursorAccent, DEFAULT_CURSOR_ACCENT, true); this.colors.selection = this._parseColor(theme.selection, DEFAULT_SELECTION, true); + this.colors.selectionOpaque = color.blend(this.colors.background, this.colors.selection); this.colors.ansi[0] = this._parseColor(theme.black, DEFAULT_ANSI_COLORS[0]); this.colors.ansi[1] = this._parseColor(theme.red, DEFAULT_ANSI_COLORS[1]); this.colors.ansi[2] = this._parseColor(theme.green, DEFAULT_ANSI_COLORS[2]); @@ -134,6 +133,8 @@ export class ColorManager implements IColorManager { this.colors.ansi[13] = this._parseColor(theme.brightMagenta, DEFAULT_ANSI_COLORS[13]); this.colors.ansi[14] = this._parseColor(theme.brightCyan, DEFAULT_ANSI_COLORS[14]); this.colors.ansi[15] = this._parseColor(theme.brightWhite, DEFAULT_ANSI_COLORS[15]); + // Clear our the cache + this._contrastCache.clear(); } private _parseColor( @@ -158,33 +159,48 @@ export class ColorManager implements IColorManager { this._ctx.fillRect(0, 0, 1, 1); const data = this._ctx.getImageData(0, 0, 1, 1).data; - if (!allowTransparency && data[3] !== 0xFF) { - // Ideally we'd just ignore the alpha channel, but... - // - // Browsers may not give back exactly the same RGB values we put in, because most/all - // convert the color to a pre-multiplied representation. getImageData converts that back to - // a un-premultipled representation, but the precision loss may make the RGB channels unuable - // on their own. - // - // E.g. In Chrome #12345610 turns into #10305010, and in the extreme case, 0xFFFFFF00 turns - // into 0x00000000. - // - // "Note: Due to the lossy nature of converting to and from premultiplied alpha color values, - // pixels that have just been set using putImageData() might be returned to an equivalent - // getImageData() as different values." - // -- https://html.spec.whatwg.org/multipage/canvas.html#pixel-manipulation - // - // So let's just use the fallback color in this case instead. - console.warn( - `Color: ${css} is using transparency, but allowTransparency is false. ` + - `Using fallback ${fallback.css}.` - ); - return fallback; + // Check if the printed color was transparent + if (data[3] !== 0xFF) { + if (!allowTransparency) { + // Ideally we'd just ignore the alpha channel, but... + // + // Browsers may not give back exactly the same RGB values we put in, because most/all + // convert the color to a pre-multiplied representation. getImageData converts that back to + // a un-premultipled representation, but the precision loss may make the RGB channels unuable + // on their own. + // + // E.g. In Chrome #12345610 turns into #10305010, and in the extreme case, 0xFFFFFF00 turns + // into 0x00000000. + // + // "Note: Due to the lossy nature of converting to and from premultiplied alpha color values, + // pixels that have just been set using putImageData() might be returned to an equivalent + // getImageData() as different values." + // -- https://html.spec.whatwg.org/multipage/canvas.html#pixel-manipulation + // + // So let's just use the fallback color in this case instead. + console.warn( + `Color: ${css} is using transparency, but allowTransparency is false. ` + + `Using fallback ${fallback.css}.` + ); + return fallback; + } + + // https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color + // the color value has alpha less than 1.0, and the string is the color value in the CSS rgba() + const [r, g, b, a] = this._ctx.fillStyle.substring(5, this._ctx.fillStyle.length - 1).split(',').map(component => Number(component)); + const alpha = Math.round(a * 255); + const rgba: number = channels.toRgba(r, g, b, alpha); + return { + rgba, + css: channels.toCss(r, g, b, alpha) + }; } return { - css, - rgba: (data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3]) >>> 0 + // https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color + // if it has alpha equal to 1.0, then the string is a lowercase six-digit hex value, prefixed with a "#" character + css: this._ctx.fillStyle, + rgba: channels.toRgba(data[0], data[1], data[2], data[3]) }; } } diff --git a/src/browser/Lifecycle.ts b/src/browser/Lifecycle.ts index 1344ea9bc..a8172db50 100644 --- a/src/browser/Lifecycle.ts +++ b/src/browser/Lifecycle.ts @@ -17,12 +17,13 @@ export function addDisposableDomListener( useCapture?: boolean ): IDisposable { node.addEventListener(type, handler, useCapture); + let disposed = false; return { dispose: () => { - if (!handler) { - // Already disposed + if (!disposed) { return; } + disposed = true; node.removeEventListener(type, handler, useCapture); } }; diff --git a/src/browser/Linkifier.test.ts b/src/browser/Linkifier.test.ts index 128f786d9..f107d646d 100644 --- a/src/browser/Linkifier.test.ts +++ b/src/browser/Linkifier.test.ts @@ -9,12 +9,13 @@ import { IBufferLine } from 'common/Types'; import { Linkifier } from 'browser/Linkifier'; import { BufferLine } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; -import { MockLogService, MockBufferService } from 'common/TestUtils.test'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; import { IBufferService } from 'common/services/Services'; +import { UnicodeService } from 'common/services/UnicodeService'; class TestLinkifier extends Linkifier { constructor(bufferService: IBufferService) { - super(bufferService, new MockLogService()); + super(bufferService, new MockLogService(), new MockOptionsService(), new UnicodeService()); Linkifier._timeBeforeLatency = 0; } diff --git a/src/browser/Linkifier.ts b/src/browser/Linkifier.ts index 58aca70eb..971501b70 100644 --- a/src/browser/Linkifier.ts +++ b/src/browser/Linkifier.ts @@ -5,9 +5,8 @@ import { ILinkifierEvent, ILinkMatcher, LinkMatcherHandler, ILinkMatcherOptions, ILinkifier, IMouseZoneManager, IMouseZone, IRegisteredLinkMatcher } from 'browser/Types'; import { IBufferStringIteratorResult } from 'common/buffer/Types'; -import { getStringCellWidth } from 'common/CharWidth'; import { EventEmitter, IEvent } from 'common/EventEmitter'; -import { ILogService, IBufferService } from 'common/services/Services'; +import { ILogService, IBufferService, IOptionsService, IUnicodeService } from 'common/services/Services'; /** * Limit of the unwrapping line expansion (overscan) at the top and bottom @@ -45,7 +44,9 @@ export class Linkifier implements ILinkifier { constructor( protected readonly _bufferService: IBufferService, - private readonly _logService: ILogService + private readonly _logService: ILogService, + private readonly _optionsService: IOptionsService, + private readonly _unicodeService: IUnicodeService ) { this._rowsToLinkify = { start: undefined, @@ -277,8 +278,8 @@ export class Linkifier implements ILinkifier { if (!this._mouseZoneManager || !this._element) { return; } - - const width = getStringCellWidth(uri); + // FIXME: get cell length from buffer to avoid mismatch after Unicode version change + const width = this._unicodeService.getStringCellWidth(uri); const x1 = x % this._bufferService.cols; const y1 = y + Math.floor(x / this._bufferService.cols); let x2 = (x1 + width) % this._bufferService.cols; @@ -297,7 +298,13 @@ export class Linkifier implements ILinkifier { if (matcher.handler) { return matcher.handler(e, uri); } - window.open(uri, '_blank'); + const newWindow = window.open(); + if (newWindow) { + newWindow.opener = null; + newWindow.location.href = uri; + } else { + console.warn('Opening link blocked as opener could not be cleared'); + } }, () => { this._onLinkHover.fire(this._createLinkHoverEvent(x1, y1, x2, y2, fg)); diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 387fa32e1..f6b17bbff 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -9,6 +9,7 @@ import { IMouseService, IRenderService } from './services/Services'; export interface IColorManager { colors: IColorSet; + onOptionsChange(key: string): void; } export interface IColor { @@ -22,7 +23,18 @@ export interface IColorSet { cursor: IColor; cursorAccent: IColor; selection: IColor; + /** The selection blended on top of background. */ + selectionOpaque: IColor; ansi: IColor[]; + contrastCache: IColorContrastCache; +} + +export interface IColorContrastCache { + clear(): void; + setCss(bg: number, fg: number, value: string | null): void; + getCss(bg: number, fg: number): string | null | undefined; + setColor(bg: number, fg: number, value: IColor | null): void; + getColor(bg: number, fg: number): IColor | null | undefined; } export interface IPartialColorSet { diff --git a/src/browser/input/MoveToCell.test.ts b/src/browser/input/MoveToCell.test.ts index bc4012c04..61e5f90b9 100644 --- a/src/browser/input/MoveToCell.test.ts +++ b/src/browser/input/MoveToCell.test.ts @@ -19,19 +19,40 @@ describe('MoveToCell', () => { describe('normal buffer', () => { it('should use the right directional escape sequences', () => { + assert.equal(moveToCellSequence(1, 3, bufferService, false), '\x1b[D\x1b[D'); assert.equal(moveToCellSequence(2, 3, bufferService, false), '\x1b[D'); assert.equal(moveToCellSequence(4, 3, bufferService, false), '\x1b[C'); + assert.equal(moveToCellSequence(5, 3, bufferService, false), '\x1b[C\x1b[C'); }); - it('should ignore the Y value', () => { - assert.equal(moveToCellSequence(1, 1, bufferService, false), '\x1b[D\x1b[D'); - assert.equal(moveToCellSequence(1, 2, bufferService, false), '\x1b[D\x1b[D'); - assert.equal(moveToCellSequence(1, 3, bufferService, false), '\x1b[D\x1b[D'); - assert.equal(moveToCellSequence(1, 4, bufferService, false), '\x1b[D\x1b[D'); - assert.equal(moveToCellSequence(1, 5, bufferService, false), '\x1b[D\x1b[D'); + it('should wrap around entire row instead of doing up and down when the Y value differs', () => { + assert.equal(moveToCellSequence(1, 1, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(2, 1, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(3, 1, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(4, 1, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(5, 1, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(1, 2, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(2, 2, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(3, 2, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(4, 2, bufferService, false), '\x1b[D\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(5, 2, bufferService, false), '\x1b[D\x1b[D\x1b[D'); + assert.equal(moveToCellSequence(1, 4, bufferService, false), '\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(2, 4, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(3, 4, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(4, 4, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(5, 4, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(1, 5, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(2, 5, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(3, 5, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(4, 5, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); + assert.equal(moveToCellSequence(5, 5, bufferService, false), '\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C\x1b[C'); }); it('should use the correct character for application cursor', () => { - assert.equal(moveToCellSequence(2, 1, bufferService, false), '\x1b[D'); - assert.equal(moveToCellSequence(2, 1, bufferService, true), '\x1bOD'); + assert.equal(moveToCellSequence(3, 1, bufferService, true), '\x1bOD\x1bOD\x1bOD\x1bOD\x1bOD\x1bOD\x1bOD\x1bOD\x1bOD\x1bOD'); + assert.equal(moveToCellSequence(3, 2, bufferService, true), '\x1bOD\x1bOD\x1bOD\x1bOD\x1bOD'); + assert.equal(moveToCellSequence(2, 3, bufferService, true), '\x1bOD'); + assert.equal(moveToCellSequence(4, 3, bufferService, true), '\x1bOC'); + assert.equal(moveToCellSequence(3, 4, bufferService, true), '\x1bOC\x1bOC\x1bOC\x1bOC\x1bOC'); + assert.equal(moveToCellSequence(3, 5, bufferService, true), '\x1bOC\x1bOC\x1bOC\x1bOC\x1bOC\x1bOC\x1bOC\x1bOC\x1bOC\x1bOC'); }); }); diff --git a/src/browser/input/MoveToCell.ts b/src/browser/input/MoveToCell.ts index 406ec8075..4a7b55aaa 100644 --- a/src/browser/input/MoveToCell.ts +++ b/src/browser/input/MoveToCell.ts @@ -30,7 +30,31 @@ export function moveToCellSequence(targetX: number, targetY: number, bufferServi } // Only move horizontally for the normal buffer - return moveHorizontallyOnly(startX, startY, targetX, targetY, bufferService, applicationCursor); + let direction; + if (startY === targetY) { + direction = startX > targetX ? Direction.LEFT : Direction.RIGHT; + return repeat(Math.abs(startX - targetX), sequence(direction, applicationCursor)); + } + direction = startY > targetY ? Direction.LEFT : Direction.RIGHT; + const rowDifference = Math.abs(startY - targetY); + const cellsToMove = colsFromRowEnd(startY > targetY ? targetX : startX, bufferService) + + (rowDifference - 1) * bufferService.cols + 1 /*wrap around 1 row*/ + + colsFromRowBeginning(startY > targetY ? startX : targetX, bufferService); + return repeat(cellsToMove, sequence(direction, applicationCursor)); +} + +/** + * Find the number of cols from a row beginning to a col. + */ +function colsFromRowBeginning(currX: number, bufferService: IBufferService): number { + return currX - 1; +} + +/** + * Find the number of cols from a col to row end. + */ +function colsFromRowEnd(currX: number, bufferService: IBufferService): number { + return bufferService.cols - currX; } /** diff --git a/src/browser/renderer/BaseRenderLayer.ts b/src/browser/renderer/BaseRenderLayer.ts index a33bef28e..f109f42e6 100644 --- a/src/browser/renderer/BaseRenderLayer.ts +++ b/src/browser/renderer/BaseRenderLayer.ts @@ -5,16 +5,17 @@ import { IRenderDimensions, IRenderLayer } from 'browser/renderer/Types'; import { ICellData } from 'common/Types'; -import { DEFAULT_COLOR, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE } from 'common/buffer/Constants'; +import { DEFAULT_COLOR, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, Attributes } from 'common/buffer/Constants'; import { IGlyphIdentifier } from 'browser/renderer/atlas/Types'; import { DIM_OPACITY, INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; import { BaseCharAtlas } from 'browser/renderer/atlas/BaseCharAtlas'; import { acquireCharAtlas } from 'browser/renderer/atlas/CharAtlasCache'; import { AttributeData } from 'common/buffer/AttributeData'; -import { IColorSet } from 'browser/Types'; +import { IColorSet, IColor } from 'browser/Types'; import { CellData } from 'common/buffer/CellData'; import { IBufferService, IOptionsService } from 'common/services/Services'; import { throwIfFalsy } from 'browser/renderer/RendererUtils'; +import { channels, color, rgba } from 'browser/Color'; export abstract class BaseRenderLayer implements IRenderLayer { private _canvas: HTMLCanvasElement; @@ -170,11 +171,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param x The column to fill. * @param y The row to fill. */ - protected _fillLeftLineAtCell(x: number, y: number): void { + protected _fillLeftLineAtCell(x: number, y: number, width: number): void { this._ctx.fillRect( x * this._scaledCellWidth, y * this._scaledCellHeight, - window.devicePixelRatio, + window.devicePixelRatio * width, this._scaledCellHeight); } @@ -262,13 +263,14 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param bold Whether the text is bold. */ protected _drawChars(cell: ICellData, x: number, y: number): void { + const contrastColor = this._getContrastColor(cell); // skip cache right away if we draw in RGB // Note: to avoid bad runtime JoinedCellData will be skipped // in the cache handler itself (atlasDidDraw == false) and // fall through to uncached later down below - if (cell.isFgRGB() || cell.isBgRGB()) { - this._drawUncachedChars(cell, x, y); + if (contrastColor || cell.isFgRGB() || cell.isBgRGB()) { + this._drawUncachedChars(cell, x, y, contrastColor); return; } @@ -282,7 +284,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { fg = (cell.isFgDefault()) ? DEFAULT_COLOR : cell.getFgColor(); } - const drawInBrightColor = this._optionsService.options.drawBoldTextInBrightColors && cell.isBold() && fg < 8 && fg !== INVERTED_DEFAULT_COLOR; + const drawInBrightColor = this._optionsService.options.drawBoldTextInBrightColors && cell.isBold() && fg < 8; fg += drawInBrightColor ? 8 : 0; this._currentGlyphIdentifier.chars = cell.getChars() || WHITESPACE_CELL_CHAR; @@ -314,21 +316,29 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param x The column to draw at. * @param y The row to draw at. */ - private _drawUncachedChars(cell: ICellData, x: number, y: number): void { + private _drawUncachedChars(cell: ICellData, x: number, y: number, fgOverride?: IColor): void { this._ctx.save(); this._ctx.font = this._getFont(!!cell.isBold(), !!cell.isItalic()); this._ctx.textBaseline = 'middle'; if (cell.isInverse()) { - if (cell.isBgDefault()) { - this._ctx.fillStyle = this._colors.background.css; + if (fgOverride) { + this._ctx.fillStyle = fgOverride.css; + } else if (cell.isBgDefault()) { + this._ctx.fillStyle = color.opaque(this._colors.background).css; } else if (cell.isBgRGB()) { this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getBgColor()).join(',')})`; } else { - this._ctx.fillStyle = this._colors.ansi[cell.getBgColor()].css; + let bg = cell.getBgColor(); + if (this._optionsService.options.drawBoldTextInBrightColors && cell.isBold() && bg < 8) { + bg += 8; + } + this._ctx.fillStyle = this._colors.ansi[bg].css; } } else { - if (cell.isFgDefault()) { + if (fgOverride) { + this._ctx.fillStyle = fgOverride.css; + } else if (cell.isFgDefault()) { this._ctx.fillStyle = this._colors.foreground.css; } else if (cell.isFgRGB()) { this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`; @@ -379,5 +389,88 @@ export abstract class BaseRenderLayer implements IRenderLayer { return `${fontStyle} ${fontWeight} ${this._optionsService.options.fontSize * window.devicePixelRatio}px ${this._optionsService.options.fontFamily}`; } + + private _getContrastColor(cell: CellData): IColor | undefined { + if (this._optionsService.options.minimumContrastRatio === 1) { + return undefined; + } + + // Try get from cache first + const adjustedColor = this._colors.contrastCache.getColor(cell.bg, cell.fg); + if (adjustedColor !== undefined) { + return adjustedColor || undefined; + } + + let fgColor = cell.getFgColor(); + let fgColorMode = cell.getFgColorMode(); + let bgColor = cell.getBgColor(); + let bgColorMode = cell.getBgColorMode(); + const isInverse = !!cell.isInverse(); + const isBold = !!cell.isInverse(); + if (isInverse) { + const temp = fgColor; + fgColor = bgColor; + bgColor = temp; + const temp2 = fgColorMode; + fgColorMode = bgColorMode; + bgColorMode = temp2; + } + + const bgRgba = this._resolveBackgroundRgba(bgColorMode, bgColor, isInverse); + const fgRgba = this._resolveForegroundRgba(fgColorMode, fgColor, isInverse, isBold); + const result = rgba.ensureContrastRatio(bgRgba, fgRgba, this._optionsService.options.minimumContrastRatio); + + if (!result) { + this._colors.contrastCache.setColor(cell.bg, cell.fg, null); + return undefined; + } + + const color: IColor = { + css: channels.toCss( + (result >> 24) & 0xFF, + (result >> 16) & 0xFF, + (result >> 8) & 0xFF + ), + rgba: result + }; + this._colors.contrastCache.setColor(cell.bg, cell.fg, color); + + return color; + } + + private _resolveBackgroundRgba(bgColorMode: number, bgColor: number, inverse: boolean): number { + switch (bgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + return this._colors.ansi[bgColor].rgba; + case Attributes.CM_RGB: + return bgColor << 8; + case Attributes.CM_DEFAULT: + default: + if (inverse) { + return this._colors.foreground.rgba; + } + return this._colors.background.rgba; + } + } + + private _resolveForegroundRgba(fgColorMode: number, fgColor: number, inverse: boolean, bold: boolean): number { + switch (fgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + if (this._optionsService.options.drawBoldTextInBrightColors && bold && fgColor < 8) { + fgColor += 8; + } + return this._colors.ansi[fgColor].rgba; + case Attributes.CM_RGB: + return fgColor << 8; + case Attributes.CM_DEFAULT: + default: + if (inverse) { + return this._colors.background.rgba; + } + return this._colors.foreground.rgba; + } + } } diff --git a/src/browser/renderer/CursorRenderLayer.ts b/src/browser/renderer/CursorRenderLayer.ts index 0ca1b97a8..64ecf33fa 100644 --- a/src/browser/renderer/CursorRenderLayer.ts +++ b/src/browser/renderer/CursorRenderLayer.ts @@ -209,7 +209,7 @@ export class CursorRenderLayer extends BaseRenderLayer { private _renderBarCursor(x: number, y: number, cell: ICellData): void { this._ctx.save(); this._ctx.fillStyle = this._colors.cursor.css; - this._fillLeftLineAtCell(x, y); + this._fillLeftLineAtCell(x, y, this._optionsService.options.cursorWidth); this._ctx.restore(); } diff --git a/src/browser/renderer/TextRenderLayer.ts b/src/browser/renderer/TextRenderLayer.ts index 330400ca2..fee261f60 100644 --- a/src/browser/renderer/TextRenderLayer.ts +++ b/src/browser/renderer/TextRenderLayer.ts @@ -227,7 +227,11 @@ export class TextRenderLayer extends BaseRenderLayer { } else if (cell.isBgRGB()) { this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getBgColor()).join(',')})`; } else { - this._ctx.fillStyle = this._colors.ansi[cell.getBgColor()].css; + let bg = cell.getBgColor(); + if (this._optionsService.options.drawBoldTextInBrightColors && cell.isBold() && bg < 8) { + bg += 8; + } + this._ctx.fillStyle = this._colors.ansi[bg].css; } } else { if (cell.isFgDefault()) { diff --git a/src/browser/renderer/atlas/DynamicCharAtlas.ts b/src/browser/renderer/atlas/DynamicCharAtlas.ts index 40103bc72..f2443a526 100644 --- a/src/browser/renderer/atlas/DynamicCharAtlas.ts +++ b/src/browser/renderer/atlas/DynamicCharAtlas.ts @@ -11,6 +11,7 @@ import { LRUMap } from 'browser/renderer/atlas/LRUMap'; import { isFirefox, isSafari } from 'common/Platform'; import { IColor } from 'browser/Types'; import { throwIfFalsy } from 'browser/renderer/RendererUtils'; +import { color } from 'browser/Color'; // In practice we're probably never going to exhaust a texture this large. For debugging purposes, // however, it can be useful to set this to a really tiny value, to verify that LRU eviction works. @@ -222,7 +223,7 @@ export class DynamicCharAtlas extends BaseCharAtlas { private _getForegroundColor(glyph: IGlyphIdentifier): IColor { if (glyph.fg === INVERTED_DEFAULT_COLOR) { - return this._config.colors.background; + return color.opaque(this._config.colors.background); } else if (glyph.fg < 256) { // 256 color support return this._getColorFromAnsiIndex(glyph.fg); diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index a1a610d2c..fb7abc553 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -11,6 +11,7 @@ import { IColorSet, ILinkifierEvent, ILinkifier, ILinkifier2 } from 'browser/Typ import { ICharSizeService } from 'browser/services/Services'; import { IOptionsService, IBufferService } from 'common/services/Services'; import { EventEmitter, IEvent } from 'common/EventEmitter'; +import { color } from 'browser/Color'; const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-'; const ROW_CONTAINER_CLASS = 'xterm-rows'; @@ -80,7 +81,7 @@ export class DomRenderer extends Disposable implements IRenderer { this._updateDimensions(); this._injectCss(); - this._rowFactory = new DomRendererRowFactory(document, this._optionsService); + this._rowFactory = new DomRendererRowFactory(document, this._optionsService, this._colors); this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass); this._screenElement.appendChild(this._rowContainer); @@ -176,13 +177,13 @@ export class DomRenderer extends Disposable implements IRenderer { `}`; // Blink animation styles += - `@keyframes blink_box_shadow {` + + `@keyframes blink_box_shadow` + `_` + this._terminalClass + ` {` + ` 50% {` + ` box-shadow: none;` + ` }` + `}`; styles += - `@keyframes blink_block {` + + `@keyframes blink_block` + `_` + this._terminalClass + ` {` + ` 0% {` + ` background-color: ${this._colors.cursor.css};` + ` color: ${this._colors.cursorAccent.css};` + @@ -199,17 +200,17 @@ export class DomRenderer extends Disposable implements IRenderer { ` outline-offset: -1px;` + `}` + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}:not(.${CURSOR_STYLE_BLOCK_CLASS}) {` + - ` animation: blink_box_shadow 1s step-end infinite;` + + ` animation: blink_box_shadow` + `_` + this._terminalClass + ` 1s step-end infinite;` + `}` + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` + - ` animation: blink_block 1s step-end infinite;` + + ` animation: blink_block` + `_` + this._terminalClass + ` 1s step-end infinite;` + `}` + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` + ` background-color: ${this._colors.cursor.css};` + ` color: ${this._colors.cursorAccent.css};` + `}` + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BAR_CLASS} {` + - ` box-shadow: 1px 0 0 ${this._colors.cursor.css} inset;` + + ` box-shadow: ${this._optionsService.options.cursorWidth}px 0 0 ${this._colors.cursor.css} inset;` + `}` + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_UNDERLINE_CLASS} {` + ` box-shadow: 0 -1px 0 ${this._colors.cursor.css} inset;` + @@ -234,7 +235,7 @@ export class DomRenderer extends Disposable implements IRenderer { `${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`; }); styles += - `${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${this._colors.background.css}; }` + + `${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(this._colors.background).css}; }` + `${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${this._colors.foreground.css}; }`; this._themeStyleElement.innerHTML = styles; @@ -344,7 +345,6 @@ export class DomRenderer extends Disposable implements IRenderer { // Force a refresh this._updateDimensions(); this._injectCss(); - this._onRequestRefreshRows.fire({ start: 0, end: this._bufferService.rows - 1 }); } public clear(): void { diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index b76d644d6..b6604b14f 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -11,6 +11,7 @@ import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; import { MockOptionsService } from 'common/TestUtils.test'; +import { css } from 'browser/Color'; describe('DomRendererRowFactory', () => { let dom: jsdom.JSDOM; @@ -19,7 +20,30 @@ describe('DomRendererRowFactory', () => { beforeEach(() => { dom = new jsdom.JSDOM(''); - rowFactory = new DomRendererRowFactory(dom.window.document, new MockOptionsService({ drawBoldTextInBrightColors: true })); + rowFactory = new DomRendererRowFactory(dom.window.document, new MockOptionsService({ drawBoldTextInBrightColors: true }), { + background: css.toColor('#010101'), + foreground: css.toColor('#020202'), + ansi: [ + // dark: + css.toColor('#2e3436'), + css.toColor('#cc0000'), + css.toColor('#4e9a06'), + css.toColor('#c4a000'), + css.toColor('#3465a4'), + css.toColor('#75507b'), + css.toColor('#06989a'), + css.toColor('#d3d7cf'), + // bright: + css.toColor('#555753'), + css.toColor('#ef2929'), + css.toColor('#8ae234'), + css.toColor('#fce94f'), + css.toColor('#729fcf'), + css.toColor('#ad7fa8'), + css.toColor('#34e2e2'), + css.toColor('#eeeeec') + ] + } as any); lineData = createEmptyLineData(2); }); @@ -142,7 +166,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); @@ -153,7 +177,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); @@ -163,7 +187,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); @@ -188,7 +212,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); @@ -199,7 +223,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, cell); const fragment = rowFactory.createRow(lineData, false, undefined, 0, false, 5, 20); assert.equal(getFragmentHtml(fragment), - 'a' + 'a' ); }); }); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 3e3b5df0d..c7c87451e 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -5,10 +5,11 @@ import { IBufferLine } from 'common/Types'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; -import { AttributeData } from 'common/buffer/AttributeData'; -import { NULL_CELL_CODE, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; +import { NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Attributes } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; import { IOptionsService } from 'common/services/Services'; +import { color, rgba } from 'browser/Color'; +import { IColorSet, IColor } from 'browser/Types'; export const BOLD_CLASS = 'xterm-bold'; export const DIM_CLASS = 'xterm-dim'; @@ -24,11 +25,16 @@ export class DomRendererRowFactory { private _workCell: CellData = new CellData(); constructor( - private _document: Document, - private _optionsService: IOptionsService + private readonly _document: Document, + private readonly _optionsService: IOptionsService, + private _colors: IColorSet ) { } + public setColors(colors: IColorSet): void { + this._colors = colors; + } + public createRow(lineData: IBufferLine, isCursorRow: boolean, cursorStyle: string | undefined, cursorX: number, cursorBlink: boolean, cellWidth: number, cols: number): DocumentFragment { const fragment = this._document.createDocumentFragment(); @@ -95,38 +101,107 @@ export class DomRendererRowFactory { charElement.classList.add(UNDERLINE_CLASS); } - charElement.textContent = this._workCell.getChars() || WHITESPACE_CELL_CHAR; - - const swapColor = this._workCell.isInverse(); - - // fg - if (this._workCell.isFgRGB()) { - let style = charElement.getAttribute('style') || ''; - style += `${swapColor ? 'background-' : ''}color:rgb(${(AttributeData.toColorRGB(this._workCell.getFgColor())).join(',')});`; - charElement.setAttribute('style', style); - } else if (this._workCell.isFgPalette()) { - let fg = this._workCell.getFgColor(); - if (this._workCell.isBold() && fg < 8 && !swapColor && this._optionsService.options.drawBoldTextInBrightColors) { - fg += 8; - } - charElement.classList.add(`xterm-${swapColor ? 'b' : 'f'}g-${fg}`); - } else if (swapColor) { - charElement.classList.add(`xterm-bg-${INVERTED_DEFAULT_COLOR}`); + if (this._workCell.isInvisible()) { + charElement.textContent = WHITESPACE_CELL_CHAR; + } else { + charElement.textContent = this._workCell.getChars() || WHITESPACE_CELL_CHAR; } - // bg - if (this._workCell.isBgRGB()) { - let style = charElement.getAttribute('style') || ''; - style += `${swapColor ? '' : 'background-'}color:rgb(${(AttributeData.toColorRGB(this._workCell.getBgColor())).join(',')});`; - charElement.setAttribute('style', style); - } else if (this._workCell.isBgPalette()) { - charElement.classList.add(`xterm-${swapColor ? 'f' : 'b'}g-${this._workCell.getBgColor()}`); - } else if (swapColor) { - charElement.classList.add(`xterm-fg-${INVERTED_DEFAULT_COLOR}`); + let fg = this._workCell.getFgColor(); + let fgColorMode = this._workCell.getFgColorMode(); + let bg = this._workCell.getBgColor(); + let bgColorMode = this._workCell.getBgColorMode(); + const isInverse = !!this._workCell.isInverse(); + if (isInverse) { + const temp = fg; + fg = bg; + bg = temp; + const temp2 = fgColorMode; + fgColorMode = bgColorMode; + bgColorMode = temp2; + } + + // Foreground + switch (fgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + if (this._workCell.isBold() && fg < 8 && this._optionsService.options.drawBoldTextInBrightColors) { + fg += 8; + } + if (!this._applyMinimumContrast(charElement, this._colors.background, this._colors.ansi[fg])) { + charElement.classList.add(`xterm-fg-${fg}`); + } + break; + case Attributes.CM_RGB: + const color = rgba.toColor( + (fg >> 16) & 0xFF, + (fg >> 8) & 0xFF, + (fg ) & 0xFF + ); + if (!this._applyMinimumContrast(charElement, this._colors.background, color)) { + this._addStyle(charElement, `color:#${padStart(fg.toString(16), '0', 6)}`); + } + break; + case Attributes.CM_DEFAULT: + default: + if (!this._applyMinimumContrast(charElement, this._colors.background, this._colors.foreground)) { + if (isInverse) { + charElement.classList.add(`xterm-fg-${INVERTED_DEFAULT_COLOR}`); + } + } + } + + // Background + switch (bgColorMode) { + case Attributes.CM_P16: + case Attributes.CM_P256: + charElement.classList.add(`xterm-bg-${bg}`); + break; + case Attributes.CM_RGB: + this._addStyle(charElement, `background-color:#${padStart(bg.toString(16), '0', 6)}`); + break; + case Attributes.CM_DEFAULT: + default: + if (isInverse) { + charElement.classList.add(`xterm-bg-${INVERTED_DEFAULT_COLOR}`); + } } fragment.appendChild(charElement); } return fragment; } + + private _applyMinimumContrast(element: HTMLElement, bg: IColor, fg: IColor): boolean { + if (this._optionsService.options.minimumContrastRatio === 1) { + return false; + } + + // Try get from cache first + let adjustedColor = this._colors.contrastCache.getColor(this._workCell.bg, this._workCell.fg); + + // Calculate and store in cache + if (adjustedColor === undefined) { + adjustedColor = color.ensureContrastRatio(bg, fg, this._optionsService.options.minimumContrastRatio); + this._colors.contrastCache.setColor(this._workCell.bg, this._workCell.fg, adjustedColor ?? null); + } + + if (adjustedColor) { + this._addStyle(element, `color:${adjustedColor.css}`); + return true; + } + + return false; + } + + private _addStyle(element: HTMLElement, style: string): void { + element.setAttribute('style', `${element.getAttribute('style') || ''}${style};`); + } +} + +function padStart(text: string, padChar: string, length: number): string { + while (text.length < length) { + text = padChar + text; + } + return text; } diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index b28c99713..2949c2c50 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -95,6 +95,7 @@ export class RenderService extends Disposable implements IRenderService { public changeOptions(): void { this._renderer.onOptionsChanged(); + this.refreshRows(0, this._rowCount - 1); this._fireOnCanvasResize(); } diff --git a/src/common/CharWidth.ts b/src/common/CharWidth.ts deleted file mode 100644 index 24f53450f..000000000 --- a/src/common/CharWidth.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Copyright (c) 2016 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { fill } from 'common/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; - } - } - 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; - } - if (num < 65536) { - return table[num]; - } - // do a full search for high codepoints - return wcwidthHigh(num); - }; -})({nul: 0, control: 0}); // configurable options - -/** - * Get the terminal cell width for a string. - */ -export function getStringCellWidth(s: string): number { - let result = 0; - const length = s.length; - for (let i = 0; i < length; ++i) { - let code = s.charCodeAt(i); - // surrogate pair first - if (0xD800 <= code && code <= 0xDBFF) { - 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); - } - } - result += wcwidth(code); - } - return result; -} diff --git a/src/common/EventEmitter.ts b/src/common/EventEmitter.ts index 5991e338d..301574e42 100644 --- a/src/common/EventEmitter.ts +++ b/src/common/EventEmitter.ts @@ -5,28 +5,28 @@ import { IDisposable } from 'common/Types'; -interface IListener { - (e: T): void; +interface IListener { + (arg1: T, arg2: U): void; } -export interface IEvent { - (listener: (e: T) => any): IDisposable; +export interface IEvent { + (listener: (arg1: T, arg2: U) => any): IDisposable; } -export interface IEventEmitter { - event: IEvent; - fire(data: T): void; +export interface IEventEmitter { + event: IEvent; + fire(arg1: T, arg2: U): void; dispose(): void; } -export class EventEmitter implements IEventEmitter { - private _listeners: IListener[] = []; - private _event?: IEvent; +export class EventEmitter implements IEventEmitter { + private _listeners: IListener[] = []; + private _event?: IEvent; private _disposed: boolean = false; - public get event(): IEvent { + public get event(): IEvent { if (!this._event) { - this._event = (listener: (e: T) => any) => { + this._event = (listener: (arg1: T, arg2: U) => any) => { this._listeners.push(listener); const disposable = { dispose: () => { @@ -46,13 +46,13 @@ export class EventEmitter implements IEventEmitter { return this._event; } - public fire(data: T): void { - const queue: IListener[] = []; + public fire(arg1: T, arg2: U): void { + const queue: IListener[] = []; for (let i = 0; i < this._listeners.length; i++) { queue.push(this._listeners[i]); } for (let i = 0; i < queue.length; i++) { - queue[i].call(undefined, data); + queue[i].call(undefined, arg1, arg2); } } diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 7d9c3aa60..4d9070cd8 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -3,13 +3,14 @@ * @license MIT */ -import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IPartialTerminalOptions, IDirtyRowService, ICoreMouseService } from 'common/services/Services'; +import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IPartialTerminalOptions, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider } from 'common/services/Services'; import { IEvent, EventEmitter } from 'common/EventEmitter'; import { clone } from 'common/Clone'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { IBufferSet, IBuffer } from 'common/buffer/Types'; import { BufferSet } from 'common/buffer/BufferSet'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset } from 'common/Types'; +import { UnicodeV6 } from 'common/input/UnicodeV6'; export class MockBufferService implements IBufferService { serviceBrand: any; @@ -42,16 +43,33 @@ export class MockCoreMouseService implements ICoreMouseService { } } +export class MockCharsetService implements ICharsetService { + serviceBrand: any; + charset: ICharset | undefined; + glevel: number = 0; + charsets: readonly ICharset[] = []; + reset(): void {} + setgLevel(g: number): void {} + setgCharset(g: number, charset: ICharset): void {} +} + export class MockCoreService implements ICoreService { + serviceBrand: any; isCursorInitialized: boolean = false; isCursorHidden: boolean = false; isFocused: boolean = false; - serviceBrand: any; - decPrivateModes: IDecPrivateModes = {} as any; + decPrivateModes: IDecPrivateModes = { + applicationCursorKeys: false, + applicationKeypad: false, + origin: false, + wraparound: true + }; onData: IEvent = new EventEmitter().event; onUserInput: IEvent = new EventEmitter().event; + onBinary: IEvent = new EventEmitter().event; reset(): void {} triggerDataEvent(data: string, wasUserInput?: boolean): void {} + triggerBinaryEvent(data: string): void {} } export class MockDirtyRowService implements IDirtyRowService { @@ -88,3 +106,18 @@ export class MockOptionsService implements IOptionsService { throw new Error('Method not implemented.'); } } + +// defaults to V6 always to keep tests passing +export class MockUnicodeService implements IUnicodeService { + private _provider = new UnicodeV6(); + register(provider: IUnicodeVersionProvider): void { + throw new Error('Method not implemented.'); + } + versions: string[] = []; + activeVersion: string = ''; + onChange: IEvent = new EventEmitter().event; + wcwidth = (codepoint: number) => this._provider.wcwidth(codepoint); + getStringCellWidth(s: string): number { + throw new Error('Method not implemented.'); + } +} diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 2f7420389..b250b45ea 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -62,7 +62,7 @@ export interface IKeyboardResult { } export interface ICharset { - [key: string]: string; + [key: string]: string | undefined; } export type CharData = [number, string, number, number]; @@ -93,6 +93,7 @@ export interface IAttributeData { isBgPalette(): boolean; isFgDefault(): boolean; isBgDefault(): boolean; + isAttributeDefault(): boolean; // colors getFgColor(): number; @@ -123,9 +124,9 @@ export interface IBufferLine { setCell(index: number, cell: ICellData): void; setCellFromCodePoint(index: number, codePoint: number, width: number, fg: number, bg: number): void; addCodepointToCell(index: number, codePoint: number): void; - insertCells(pos: number, n: number, ch: ICellData): void; - deleteCells(pos: number, n: number, fill: ICellData): void; - replaceCells(start: number, end: number, fill: ICellData): void; + insertCells(pos: number, n: number, ch: ICellData, eraseAttr?: IAttributeData): void; + deleteCells(pos: number, n: number, fill: ICellData, eraseAttr?: IAttributeData): void; + replaceCells(start: number, end: number, fill: ICellData, eraseAttr?: IAttributeData): void; resize(cols: number, fill: ICellData): void; fill(fillCellData: ICellData): void; copyFrom(line: IBufferLine): void; @@ -152,6 +153,9 @@ export interface IMarker extends IDisposable { export interface IDecPrivateModes { applicationCursorKeys: boolean; + applicationKeypad: boolean; + origin: boolean; + wraparound: boolean; // defaults: xterm - true, vt100 - false } export interface IRowRange { @@ -249,5 +253,36 @@ export interface ICoreMouseProtocol { * The tracking encoding can be registered and activated at the CoreMouseService. * If a ICoreMouseEvent passes all procotol restrictions it will be encoded * with the active encoding and sent out. + * Note: Returning an empty string will supress sending a mouse report, + * which can be used to skip creating falsey reports in limited encodings + * (DEFAULT only supports up to 223 1-based as coord value). */ export type CoreMouseEncoding = (event: ICoreMouseEvent) => string; + +/** + * windowOptions + */ +export interface IWindowOptions { + restoreWin?: boolean; + minimizeWin?: boolean; + setWinPosition?: boolean; + setWinSizePixels?: boolean; + raiseWin?: boolean; + lowerWin?: boolean; + refreshWin?: boolean; + setWinSizeChars?: boolean; + maximizeWin?: boolean; + fullscreenWin?: boolean; + getWinState?: boolean; + getWinPosition?: boolean; + getWinSizePixels?: boolean; + getScreenSizePixels?: boolean; + getCellSizePixels?: boolean; + getWinSizeChars?: boolean; + getScreenSizeChars?: boolean; + getIconTitle?: boolean; + getWinTitle?: boolean; + pushTitle?: boolean; + popTitle?: boolean; + setWinLines?: boolean; +} diff --git a/src/WindowsMode.ts b/src/common/WindowsMode.ts similarity index 67% rename from src/WindowsMode.ts rename to src/common/WindowsMode.ts index 0838cae20..7cff094b2 100644 --- a/src/WindowsMode.ts +++ b/src/common/WindowsMode.ts @@ -3,11 +3,10 @@ * @license MIT */ -import { IDisposable } from 'xterm'; -import { ITerminal } from './Types'; import { CHAR_DATA_CODE_INDEX, NULL_CELL_CODE, WHITESPACE_CELL_CODE } from 'common/buffer/Constants'; +import { IBufferService } from 'common/services/Services'; -export function applyWindowsMode(terminal: ITerminal): IDisposable { +export function updateWindowsModeWrappedState(bufferService: IBufferService): void { // Winpty does not support wraparound mode which means that lines will never // be marked as wrapped. This causes issues for things like copying a line // retaining the wrapped new line characters or if consumers are listening @@ -18,11 +17,11 @@ export function applyWindowsMode(terminal: ITerminal): IDisposable { // space. This is certainly not without its problems, but generally on // Windows when text reaches the end of the terminal it's likely going to be // wrapped. - return terminal.onLineFeed(() => { - const line = terminal.buffer.lines.get(terminal.buffer.ybase + terminal.buffer.y - 1); - const lastChar = line.get(terminal.cols - 1); + const line = bufferService.buffer.lines.get(bufferService.buffer.ybase + bufferService.buffer.y - 1); + const lastChar = line?.get(bufferService.cols - 1); - const nextLine = terminal.buffer.lines.get(terminal.buffer.ybase + terminal.buffer.y); + const nextLine = bufferService.buffer.lines.get(bufferService.buffer.ybase + bufferService.buffer.y); + if (nextLine && lastChar) { nextLine.isWrapped = (lastChar[CHAR_DATA_CODE_INDEX] !== NULL_CELL_CODE && lastChar[CHAR_DATA_CODE_INDEX] !== WHITESPACE_CELL_CODE); - }); + } } diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index 0e7e27059..0b2679ee4 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/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index ae80aa16f..686371f25 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -366,4 +366,94 @@ describe('BufferLine', function(): void { chai.assert.equal(cell.isCombined(), Content.IS_COMBINED_MASK); }); }); + describe('correct fullwidth handling', () => { + function populate(line: BufferLine): void { + const cell = CellData.fromCharData([1, '¥', 2, '¥'.charCodeAt(0)]); + for (let i = 0; i < line.length; i += 2) { + line.setCell(i, cell); + } + } + it('insert - wide char at pos', () => { + const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.insertCells(9, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), '¥¥¥¥ a'); + line.insertCells(8, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), '¥¥¥¥a '); + line.insertCells(1, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' a ¥¥¥a'); + }); + it('insert - wide char at end', () => { + const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.insertCells(0, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'aaa¥¥¥ '); + line.insertCells(4, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'aaa a ¥¥'); + line.insertCells(4, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'aaa aa ¥ '); + }); + it('delete', () => { + const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.deleteCells(0, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' ¥¥¥¥a'); + line.deleteCells(5, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' ¥¥¥aaa'); + line.deleteCells(0, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' ¥¥aaaaa'); + }); + it('replace - start at 0', () => { + let line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(0, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'a ¥¥¥¥'); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(0, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'aa¥¥¥¥'); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(0, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'aaa ¥¥¥'); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(0, 8, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'aaaaaaaa¥'); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(0, 9, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'aaaaaaaaa '); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(0, 10, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), 'aaaaaaaaaa'); + }); + it('replace - start at 1', () => { + let line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(1, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' a¥¥¥¥'); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(1, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' aa ¥¥¥'); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(1, 4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' aaa¥¥¥'); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(1, 8, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' aaaaaaa¥'); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(1, 9, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' aaaaaaaa '); + line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false); + populate(line); + line.replaceCells(1, 10, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)])); + chai.assert.equal(line.translateToString(), ' aaaaaaaaa'); + }); + }); }); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 1e95e0045..d54b59aaf 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { CharData, IBufferLine, ICellData } from 'common/Types'; +import { CharData, IBufferLine, ICellData, IAttributeData } from 'common/Types'; import { stringFromCodePoint } from 'common/input/TextDecoder'; import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Content } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; @@ -228,8 +228,14 @@ export class BufferLine implements IBufferLine { } } - public insertCells(pos: number, n: number, fillCellData: ICellData): void { + public insertCells(pos: number, n: number, fillCellData: ICellData, eraseAttr?: IAttributeData): void { pos %= this.length; + + // handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char + if (pos && this.getWidth(pos - 1) === 2) { + this.setCellFromCodePoint(pos - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0); + } + if (n < this.length - pos) { const cell = new CellData(); for (let i = this.length - pos - n - 1; i >= 0; --i) { @@ -243,9 +249,14 @@ export class BufferLine implements IBufferLine { this.setCell(i, fillCellData); } } + + // handle fullwidth at line end: reset last cell if it is first cell of a wide char + if (this.getWidth(this.length - 1) === 2) { + this.setCellFromCodePoint(this.length - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0); + } } - public deleteCells(pos: number, n: number, fillCellData: ICellData): void { + public deleteCells(pos: number, n: number, fillCellData: ICellData, eraseAttr?: IAttributeData): void { pos %= this.length; if (n < this.length - pos) { const cell = new CellData(); @@ -260,9 +271,28 @@ export class BufferLine implements IBufferLine { this.setCell(i, fillCellData); } } + + // handle fullwidth at pos: + // - reset pos-1 if wide char + // - reset pos if width==0 (previous second cell of a wide char) + if (pos && this.getWidth(pos - 1) === 2) { + this.setCellFromCodePoint(pos - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0); + } + if (this.getWidth(pos) === 0 && !this.hasContent(pos)) { + this.setCellFromCodePoint(pos, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0); + } } - public replaceCells(start: number, end: number, fillCellData: ICellData): void { + public replaceCells(start: number, end: number, fillCellData: ICellData, eraseAttr?: IAttributeData): void { + // handle fullwidth at start: reset cell one to the left if start is second cell of a wide char + if (start && this.getWidth(start - 1) === 2) { + this.setCellFromCodePoint(start - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0); + } + // handle fullwidth at last cell + 1: reset to empty cell if it is second part of a wide char + if (end < this.length && this.getWidth(end - 1) === 2) { + this.setCellFromCodePoint(end, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0); + } + while (start < end && start < this.length) { this.setCell(start++, fillCellData); } diff --git a/src/common/data/Charsets.ts b/src/common/data/Charsets.ts index 56ca67991..56513b8ad 100644 --- a/src/common/data/Charsets.ts +++ b/src/common/data/Charsets.ts @@ -30,14 +30,14 @@ export const DEFAULT_CHARSET: ICharset | null = CHARSETS['B']; CHARSETS['0'] = { '`': '\u25c6', // '◆' 'a': '\u2592', // '▒' - 'b': '\u0009', // '\t' - 'c': '\u000c', // '\f' - 'd': '\u000d', // '\r' - 'e': '\u000a', // '\n' + 'b': '\u2409', // '␉' (HT) + 'c': '\u240c', // '␌' (FF) + 'd': '\u240d', // '␍' (CR) + 'e': '\u240a', // '␊' (LF) 'f': '\u00b0', // '°' 'g': '\u00b1', // '±' - 'h': '\u2424', // '\u2424' (NL) - 'i': '\u000b', // '\v' + 'h': '\u2424', // '␤' (NL) + 'i': '\u240b', // '␋' (VT) 'j': '\u2518', // '┘' 'k': '\u2510', // '┐' 'l': '\u250c', // '┌' diff --git a/src/common/CharWidth.test.ts b/src/common/input/UnicodeV6.test.ts similarity index 96% rename from src/common/CharWidth.test.ts rename to src/common/input/UnicodeV6.test.ts index 6b2493eef..4a44f15bd 100644 --- a/src/common/CharWidth.test.ts +++ b/src/common/input/UnicodeV6.test.ts @@ -1,12 +1,15 @@ /** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * Copyright (c) 2019 The xterm.js authors. All rights reserved. * @license MIT */ import { assert } from 'chai'; -import { wcwidth } from 'common/CharWidth'; +import { UnicodeV6 } from 'common/input/UnicodeV6'; it('wcwidth should match all values from the old implementation', function(): void { + // new implementation + const wcwidthNew = (new UnicodeV6()).wcwidth; + // 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 @@ -177,6 +180,6 @@ it('wcwidth should match all values from the old implementation', function(): vo // 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}`); + assert.equal(wcwidthNew(i), wcwidthOld(i), `mismatch for i: ${i}`); } }); diff --git a/src/common/input/UnicodeV6.ts b/src/common/input/UnicodeV6.ts new file mode 100644 index 000000000..b30820306 --- /dev/null +++ b/src/common/input/UnicodeV6.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { IUnicodeVersionProvider } from 'common/services/Services'; +import { fill } from 'common/TypedArrayUtils'; + +type CharWidth = 0 | 1 | 2; + +const BMP_COMBINING = [ + [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 HIGH_COMBINING = [ + [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] +]; + +// BMP lookup table, lazy initialized during first addon loading +let table: Uint8Array; + +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; +} + +export class UnicodeV6 implements IUnicodeVersionProvider { + public readonly version = '6'; + + constructor() { + // init lookup table once + if (!table) { + table = new Uint8Array(65536); + fill(table, 1); + table[0] = 0; + // control chars + fill(table, 0, 1, 32); + fill(table, 0, 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 < BMP_COMBINING.length; ++r) { + fill(table, 0, BMP_COMBINING[r][0], BMP_COMBINING[r][1] + 1); + } + } + } + + public wcwidth(num: number): CharWidth { + if (num < 32) return 0; + if (num < 127) return 1; + if (num < 65536) return table[num] as CharWidth; + if (bisearch(num, HIGH_COMBINING)) return 0; + if ((num >= 0x20000 && num <= 0x2fffd) || (num >= 0x30000 && num <= 0x3fffd)) return 2; + return 1; + } +} diff --git a/src/common/services/CharsetService.ts b/src/common/services/CharsetService.ts new file mode 100644 index 000000000..5d20628f3 --- /dev/null +++ b/src/common/services/CharsetService.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ICharsetService } from 'common/services/Services'; +import { ICharset } from 'common/Types'; + +export class CharsetService implements ICharsetService { + serviceBrand: any; + + public charset: ICharset | undefined; + public charsets: ICharset[] = []; + public glevel: number = 0; + + public reset(): void { + this.charset = undefined; + this.charsets = []; + this.glevel = 0; + } + + public setgLevel(g: number): void { + this.glevel = g; + this.charset = this.charsets[g]; + } + + public setgCharset(g: number, charset: ICharset): void { + this.charsets[g] = charset; + if (this.glevel === g) { + this.charset = charset; + } + } +} diff --git a/src/common/services/CoreMouseService.test.ts b/src/common/services/CoreMouseService.test.ts index f7d3bf838..2bf011b02 100644 --- a/src/common/services/CoreMouseService.test.ts +++ b/src/common/services/CoreMouseService.test.ts @@ -79,6 +79,7 @@ describe('CoreMouseService', () => { cms = new CoreMouseService(bufferService, coreService); reports = []; coreService.triggerDataEvent = (data: string, userInput?: boolean) => reports.push(data); + coreService.triggerBinaryEvent = (data: string) => reports.push(data); }); it('NONE', () => { assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false); @@ -143,11 +144,11 @@ describe('CoreMouseService', () => { cms.activeProtocol = 'ANY'; for (let i = 0; i < bufferService.cols; ++i) { assert.equal(cms.triggerMouseEvent({ col: i, row: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true); - // capped at 95 - if (i < 95) { - assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]); + if (i > 222) { + // supress mouse reports if we are out of addressible range (max. 222) + assert.deepEqual(toBytes(reports.pop()), []); } else { - assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, 0x7f, 0x21]); + assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]); } } }); diff --git a/src/common/services/CoreMouseService.ts b/src/common/services/CoreMouseService.ts index 500655b90..0bd25dd03 100644 --- a/src/common/services/CoreMouseService.ts +++ b/src/common/services/CoreMouseService.ts @@ -121,15 +121,17 @@ const DEFAULT_ENCODINGS: {[key: string]: CoreMouseEncoding} = { /** * DEFAULT - CSI M Pb Px Py * Single byte encoding for coords and event code. - * Can encode values up to 223. The Encoding of higher - * values is not UTF-8 compatible (and currently limited - * to 95 in xterm.js). + * Can encode values up to 223 (1-based). */ DEFAULT: (e: ICoreMouseEvent) => { - let params = [eventCode(e, false) + 32, e.col + 32, e.row + 32]; - // FIXME: we are currently limited to ASCII range - params = params.map(v => (v > 127) ? 127 : v); - // FIXED: params = params.map(v => (v > 255) ? 0 : value); + const params = [eventCode(e, false) + 32, e.col + 32, e.row + 32]; + // supress mouse report if we exceed addressible range + // Note this is handled differently by emulators + // - xterm: sends 0;0 coords instead + // - vte, konsole: no report + if (params[0] > 255 || params[1] > 255 || params[2] > 255) { + return ''; + } return `\x1b[M${S(params[0])}${S(params[1])}${S(params[2])}`; }, /** @@ -266,7 +268,14 @@ export class CoreMouseService implements ICoreMouseService { // encode report and send const report = this._encodings[this._activeEncoding](e); - this._coreService.triggerDataEvent(report, true); + if (report) { + // always send DEFAULT as binary data + if (this._activeEncoding === 'DEFAULT') { + this._coreService.triggerBinaryEvent(report); + } else { + this._coreService.triggerDataEvent(report, true); + } + } this._lastEvent = e; diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index 11c3f3053..df9161e9c 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -5,11 +5,14 @@ import { ICoreService, ILogService, IOptionsService, IBufferService } from 'common/services/Services'; import { EventEmitter, IEvent } from 'common/EventEmitter'; -import { IDecPrivateModes } from 'common/Types'; +import { IDecPrivateModes, ICharset } from 'common/Types'; import { clone } from 'common/Clone'; const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({ - applicationCursorKeys: false + applicationCursorKeys: false, + applicationKeypad: false, + origin: false, + wraparound: true // defaults: xterm - true, vt100 - false }); export class CoreService implements ICoreService { @@ -23,6 +26,8 @@ export class CoreService implements ICoreService { public get onData(): IEvent { return this._onData.event; } private _onUserInput = new EventEmitter(); public get onUserInput(): IEvent { return this._onUserInput.event; } + private _onBinary = new EventEmitter(); + public get onBinary(): IEvent { return this._onBinary.event; } constructor( // TODO: Move this into a service @@ -59,4 +64,12 @@ export class CoreService implements ICoreService { this._logService.debug(`sending data "${data}"`, () => data.split('').map(e => e.charCodeAt(0))); this._onData.fire(data); } + + public triggerBinaryEvent(data: string): void { + if (this._optionsService.options.disableStdin) { + return; + } + this._logService.debug(`sending binary "${data}"`, () => data.split('').map(e => e.charCodeAt(0))); + this._onBinary.fire(data); + } } diff --git a/src/common/services/InstantiationService.ts b/src/common/services/InstantiationService.ts index abce8ac54..ade3bbe3b 100644 --- a/src/common/services/InstantiationService.ts +++ b/src/common/services/InstantiationService.ts @@ -36,7 +36,7 @@ export class ServiceCollection { return this._entries.has(id); } - get(id: IServiceIdentifier): T { + get(id: IServiceIdentifier): T | undefined { return this._entries.get(id); } } @@ -52,6 +52,10 @@ export class InstantiationService implements IInstantiationService { this._services.set(id, instance); } + public getService(id: IServiceIdentifier): T | undefined { + return this._services.get(id); + } + public createInstance(ctor: any, ...args: any[]): any { const serviceDependencies = getServiceDependencies(ctor).sort((a, b) => a.index - b.index); diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index d9ea60d5a..44a55d33d 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -20,6 +20,7 @@ export const DEFAULT_OPTIONS: ITerminalOptions = Object.freeze({ rows: 24, cursorBlink: false, cursorStyle: 'block', + cursorWidth: 1, bellSound: DEFAULT_BELL_SOUND, bellStyle: 'none', drawBoldTextInBrightColors: true, @@ -37,20 +38,20 @@ export const DEFAULT_OPTIONS: ITerminalOptions = Object.freeze({ screenReaderMode: false, macOptionIsMeta: false, macOptionClickForcesSelection: false, + minimumContrastRatio: 1, disableStdin: false, allowTransparency: false, tabStopWidth: 8, theme: {}, rightClickSelectsWord: isMac, rendererType: 'canvas', + windowOptions: {}, windowsMode: false, + wordSeparator: ' ()[]{}\',"`', convertEol: false, termName: 'xterm', - screenKeys: false, - cancelEvents: false, - useFlowControl: false, - wordSeparator: ' ()[]{}\',:;"' + cancelEvents: false }); /** @@ -110,12 +111,18 @@ export class OptionsService implements IOptionsService { value = DEFAULT_OPTIONS[key]; } break; + case 'cursorWidth': + value = Math.floor(value); + // Fall through for bounds check case 'lineHeight': case 'tabStopWidth': if (value < 1) { throw new Error(`${key} cannot be less than 1, value: ${value}`); } break; + case 'minimumContrastRatio': + value = Math.max(1, Math.min(21, Math.round(value * 10) / 10)); + break; case 'scrollback': value = Math.min(value, 4294967295); if (value < 0) { diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index e4ff90d0f..23b74e144 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -5,7 +5,7 @@ import { IEvent } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; export const IBufferService = createDecorator('BufferService'); @@ -64,10 +64,12 @@ export interface ICoreService { */ isCursorInitialized: boolean; isCursorHidden: boolean; + readonly decPrivateModes: IDecPrivateModes; readonly onData: IEvent; readonly onUserInput: IEvent; + readonly onBinary: IEvent; reset(): void; @@ -78,8 +80,38 @@ export interface ICoreService { * resulting from parsing incoming data). When true this will also: * - Scroll to the bottom of the buffer.s * - Fire the `onUserInput` event (so selection can be cleared). - */ + */ triggerDataEvent(data: string, wasUserInput?: boolean): void; + + /** + * Triggers the onBinary event in the public API. + * @param data The data that is being emitted. + */ + triggerBinaryEvent(data: string): void; +} + +export const ICharsetService = createDecorator('CharsetService'); +export interface ICharsetService { + serviceBrand: any; + + charset: ICharset | undefined; + readonly glevel: number; + readonly charsets: ReadonlyArray; + + reset(): void; + + /** + * Set the G level of the terminal. + * @param g + */ + setgLevel(g: number): void; + + /** + * Set the charset for the given G level of the terminal. + * @param g + * @param charset + */ + setgCharset(g: number, charset: ICharset): void; } export const IDirtyRowService = createDecorator('DirtyRowService'); @@ -139,6 +171,7 @@ export interface IConstructorSignature8 { export const IInstantiationService = createDecorator('InstantiationService'); export interface IInstantiationService { setService(id: IServiceIdentifier, instance: T): void; + getService(id: IServiceIdentifier): T | undefined; createInstance(ctor: IConstructorSignature0): T; createInstance(ctor: IConstructorSignature1, first: A1): T; @@ -184,6 +217,7 @@ export interface IPartialTerminalOptions { cols?: number; cursorBlink?: boolean; cursorStyle?: 'block' | 'underline' | 'bar'; + cursorWidth?: number; disableStdin?: boolean; drawBoldTextInBrightColors?: boolean; fastScrollModifier?: 'alt' | 'ctrl' | 'shift'; @@ -207,6 +241,7 @@ export interface IPartialTerminalOptions { theme?: ITheme; windowsMode?: boolean; wordSeparator?: string; + windowOptions?: IWindowOptions; } export interface ITerminalOptions { @@ -216,6 +251,7 @@ export interface ITerminalOptions { cols: number; cursorBlink: boolean; cursorStyle: 'block' | 'underline' | 'bar'; + cursorWidth: number; disableStdin: boolean; drawBoldTextInBrightColors: boolean; fastScrollModifier: 'alt' | 'ctrl' | 'shift' | undefined; @@ -229,6 +265,7 @@ export interface ITerminalOptions { logLevel: LogLevel; macOptionIsMeta: boolean; macOptionClickForcesSelection: boolean; + minimumContrastRatio: number; rendererType: RendererType; rightClickSelectsWord: boolean; rows: number; @@ -238,14 +275,13 @@ export interface ITerminalOptions { tabStopWidth: number; theme: ITheme; windowsMode: boolean; + windowOptions: IWindowOptions; wordSeparator: string; [key: string]: any; cancelEvents: boolean; convertEol: boolean; - screenKeys: boolean; termName: string; - useFlowControl: boolean; } export interface ITheme { @@ -271,3 +307,26 @@ export interface ITheme { brightCyan?: string; brightWhite?: string; } + +export const IUnicodeService = createDecorator('UnicodeService'); +export interface IUnicodeService { + /** Register an Unicode version provider. */ + register(provider: IUnicodeVersionProvider): void; + /** Registered Unicode versions. */ + readonly versions: string[]; + /** Currently active version. */ + activeVersion: string; + /** Event triggered, when activate version changed. */ + readonly onChange: IEvent; + + /** + * Unicode version dependent + */ + wcwidth(codepoint: number): number; + getStringCellWidth(s: string): number; +} + +export interface IUnicodeVersionProvider { + readonly version: string; + wcwidth(ucs: number): 0 | 1 | 2; +} diff --git a/src/common/services/UnicodeService.test.ts b/src/common/services/UnicodeService.test.ts new file mode 100644 index 000000000..cb03b09b0 --- /dev/null +++ b/src/common/services/UnicodeService.test.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { UnicodeService } from 'common/services/UnicodeService'; +import { IUnicodeVersionProvider } from 'common/services/Services'; + +class DummyProvider implements IUnicodeVersionProvider { + version = '123'; + wcwidth(n: number): 0 | 1 | 2 { + return 2; + } +} + +describe('unicode provider', () => { + let us: UnicodeService; + beforeEach(() => { + us = new UnicodeService(); + }); + it('default to V6', () => { + assert.equal(us.activeVersion, '6'); + assert.deepEqual(us.versions, ['6']); + assert.doesNotThrow(() => { us.activeVersion = '6'; }, `unknown Unicode version "6"`); + assert.equal(us.getStringCellWidth('hello'), 5); + }); + it('activate should throw for unknown version', () => { + assert.throws(() => { us.activeVersion = '55'; }, 'unknown Unicode version "55"'); + }); + it('should notify about version change', () => { + const notes: string[] = []; + us.onChange(version => notes.push(version)); + const dummyProvider = new DummyProvider(); + us.register(dummyProvider); + us.activeVersion = dummyProvider.version; + assert.deepEqual(notes, [dummyProvider.version]); + }); + it('correctly changes provider impl', () => { + assert.equal(us.getStringCellWidth('hello'), 5); + const dummyProvider = new DummyProvider(); + us.register(dummyProvider); + us.activeVersion = dummyProvider.version; + assert.equal(us.getStringCellWidth('hello'), 2 * 5); + }); + it('wcwidth V6 emoji test', () => { + const widthV6 = us.getStringCellWidth('🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣'); + assert.equal(widthV6, 10); + }); +}); diff --git a/src/common/services/UnicodeService.ts b/src/common/services/UnicodeService.ts new file mode 100644 index 000000000..14b9979f6 --- /dev/null +++ b/src/common/services/UnicodeService.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { IUnicodeService, IUnicodeVersionProvider } from 'common/services/Services'; +import { EventEmitter, IEvent } from 'common/EventEmitter'; +import { UnicodeV6 } from 'common/input/UnicodeV6'; + + +export class UnicodeService implements IUnicodeService { + private _providers: {[key: string]: IUnicodeVersionProvider} = Object.create(null); + private _active: string = ''; + private _activeProvider: IUnicodeVersionProvider; + private _onChange = new EventEmitter(); + public get onChange(): IEvent { return this._onChange.event; } + + constructor() { + const defaultProvider = new UnicodeV6(); + this.register(defaultProvider); + this._active = defaultProvider.version; + this._activeProvider = defaultProvider; + } + + public get versions(): string[] { + return Object.keys(this._providers); + } + + public get activeVersion(): string { + return this._active; + } + + public set activeVersion(version: string) { + if (!this._providers[version]) { + throw new Error(`unknown Unicode version "${version}"`); + } + this._active = version; + this._activeProvider = this._providers[version]; + this._onChange.fire(version); + } + + public register(provider: IUnicodeVersionProvider): void { + this._providers[provider.version] = provider; + } + + /** + * Unicode version dependent interface. + */ + public wcwidth(num: number): number { + return this._activeProvider.wcwidth(num); + } + + public getStringCellWidth(s: string): number { + let result = 0; + const length = s.length; + for (let i = 0; i < length; ++i) { + let code = s.charCodeAt(i); + // surrogate pair first + if (0xD800 <= code && code <= 0xDBFF) { + 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 + this.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 += this.wcwidth(second); + } + } + result += this.wcwidth(code); + } + return result; + } +} diff --git a/src/public/Terminal.ts b/src/public/Terminal.ts index 5d56a78f8..da03c127d 100644 --- a/src/public/Terminal.ts +++ b/src/public/Terminal.ts @@ -3,10 +3,11 @@ * @license MIT */ -import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBuffer as IBufferApi, IBufferLine as IBufferLineApi, IBufferCell as IBufferCellApi, IParser, IFunctionIdentifier, ILinkProvider } from 'xterm'; +import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBuffer as IBufferApi, IBufferLine as IBufferLineApi, IBufferCell as IBufferCellApi, IParser, IFunctionIdentifier, ILinkProvider, IUnicodeHandling, IUnicodeVersionProvider } 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'; @@ -27,6 +28,7 @@ export class Terminal implements ITerminalApi { public get onLineFeed(): IEvent { return this._core.onLineFeed; } public get onSelectionChange(): IEvent { return this._core.onSelectionChange; } public get onData(): IEvent { return this._core.onData; } + public get onBinary(): IEvent { return this._core.onBinary; } public get onTitleChange(): IEvent { return this._core.onTitleChange; } public get onScroll(): IEvent { return this._core.onScroll; } public get onKey(): IEvent<{ key: string, domEvent: KeyboardEvent }> { return this._core.onKey; } @@ -40,6 +42,9 @@ export class Terminal implements ITerminalApi { } return this._parser; } + public get unicode(): IUnicodeHandling { + return new UnicodeApi(this._core); + } public get textarea(): HTMLTextAreaElement | undefined { return this._core.textarea; } public get rows(): number { return this._core.rows; } public get cols(): number { return this._core.cols; } @@ -76,10 +81,13 @@ export class Terminal implements ITerminalApi { public deregisterCharacterJoiner(joinerId: number): void { this._core.deregisterCharacterJoiner(joinerId); } - public addMarker(cursorYOffset: number): IMarker { + public registerMarker(cursorYOffset: number): IMarker { this._verifyIntegers(cursorYOffset); return this._core.addMarker(cursorYOffset); } + public addMarker(cursorYOffset: number): IMarker { + return this.registerMarker(cursorYOffset); + } public hasSelection(): boolean { return this._core.hasSelection(); } @@ -142,10 +150,8 @@ export class Terminal implements ITerminalApi { this._core.paste(data); } public getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'fontWeight' | 'fontWeightBold' | 'logLevel' | 'rendererType' | 'termName' | 'wordSeparator'): string; - public getOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'disableStdin' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'screenKeys' | 'useFlowControl' | 'visualBell'): boolean; - public getOption(key: 'colors'): string[]; + public getOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'disableStdin' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'visualBell'): boolean; public getOption(key: 'cols' | 'fontSize' | 'letterSpacing' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback'): number; - public getOption(key: 'handler'): (data: string) => void; public getOption(key: string): any; public getOption(key: any): any { return this._core.optionsService.getOption(key); @@ -155,10 +161,8 @@ export class Terminal implements ITerminalApi { public setOption(key: 'logLevel', value: 'debug' | 'info' | 'warn' | 'error' | 'off'): void; public setOption(key: 'bellStyle', value: 'none' | 'visual' | 'sound' | 'both'): void; public setOption(key: 'cursorStyle', value: 'block' | 'underline' | 'bar'): void; - public setOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'disableStdin' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'screenKeys' | 'useFlowControl' | 'visualBell', value: boolean): void; - public setOption(key: 'colors', value: string[]): void; + public setOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'disableStdin' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'visualBell', value: boolean): void; public setOption(key: 'fontSize' | 'letterSpacing' | 'lineHeight' | 'tabStopWidth' | 'scrollback', value: number): void; - public setOption(key: 'handler', value: (data: string) => void): void; public setOption(key: 'theme', value: ITheme): void; public setOption(key: 'cols' | 'rows', value: number): void; public setOption(key: string, value: any): void; @@ -203,42 +207,75 @@ class BufferApiView implements IBufferApi { } return new BufferLineApiView(line); } + public getNullCell(): IBufferCellApi { return new CellData(); } } class BufferLineApiView implements IBufferLineApi { 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) { } - public addCsiHandler(id: IFunctionIdentifier, callback: (params: (number | number[])[]) => boolean): IDisposable { + public registerCsiHandler(id: IFunctionIdentifier, callback: (params: (number | number[])[]) => boolean): IDisposable { return this._core.addCsiHandler(id, (params: IParams) => callback(params.toArray())); } - public addDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: (number | number[])[]) => boolean): IDisposable { + public addCsiHandler(id: IFunctionIdentifier, callback: (params: (number | number[])[]) => boolean): IDisposable { + return this.registerCsiHandler(id, callback); + } + public registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: (number | number[])[]) => boolean): IDisposable { return this._core.addDcsHandler(id, (data: string, params: IParams) => callback(data, params.toArray())); } - public addEscHandler(id: IFunctionIdentifier, handler: () => boolean): IDisposable { + public addDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: (number | number[])[]) => boolean): IDisposable { + return this.registerDcsHandler(id, callback); + } + public registerEscHandler(id: IFunctionIdentifier, handler: () => boolean): IDisposable { return this._core.addEscHandler(id, handler); } - public addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable { + public addEscHandler(id: IFunctionIdentifier, handler: () => boolean): IDisposable { + return this.registerEscHandler(id, handler); + } + public registerOscHandler(ident: number, callback: (data: string) => boolean): IDisposable { return this._core.addOscHandler(ident, callback); } + public addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable { + return this.registerOscHandler(ident, callback); + } +} + +class UnicodeApi implements IUnicodeHandling { + constructor(private _core: ITerminal) { } + + public register(provider: IUnicodeVersionProvider): void { + this._core.unicodeService.register(provider); + } + + public get versions(): string[] { + return this._core.unicodeService.versions; + } + + public get activeVersion(): string { + return this._core.unicodeService.activeVersion; + } + + public set activeVersion(version: string) { + this._core.unicodeService.activeVersion = version; + } } diff --git a/test/api/CharWidth.api.ts b/test/api/CharWidth.api.ts index 0561bf075..a2352f40c 100644 --- a/test/api/CharWidth.api.ts +++ b/test/api/CharWidth.api.ts @@ -4,8 +4,8 @@ */ import * as puppeteer from 'puppeteer'; -import { assert } from 'chai'; import { ITerminalOptions } from 'xterm'; +import { pollFor } from './TestUtils'; const APP = 'http://127.0.0.1:3000/test'; @@ -15,12 +15,9 @@ const width = 800; const height = 600; describe('CharWidth Integration Tests', function(): void { - this.timeout(20000); - before(async function(): Promise { browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; @@ -41,37 +38,37 @@ describe('CharWidth Integration Tests', function(): void { it('ASCII chars', async function(): Promise { const input = 'This is just ASCII text.#'; await page.evaluate(`window.term.write('${input}')`); - assert.equal(25, await sumWidths(0, 1, '#')); + await pollFor(page, () => sumWidths(0, 1, '#'), 25); }); it('combining chars', async function(): Promise { const input = 'e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301#'; await page.evaluate(`window.term.write('${input}')`); - assert.equal(10, await sumWidths(0, 1, '#')); + await pollFor(page, () => sumWidths(0, 1, '#'), 10); }); it('surrogate chars', async function(): Promise { const input = '𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞#'; await page.evaluate(`window.term.write('${input}')`); - assert.equal(28, await sumWidths(0, 1, '#')); + await pollFor(page, () => sumWidths(0, 1, '#'), 28); }); it('surrogate combining chars', async function(): Promise { const input = '𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301#'; await page.evaluate(`window.term.write('${input}')`); - assert.equal(12, await sumWidths(0, 1, '#')); + await pollFor(page, () => sumWidths(0, 1, '#'), 12); }); it('fullwidth chars', async function(): Promise { const input = '1234567890#'; await page.evaluate(`window.term.write('${input}')`); - assert.equal(21, await sumWidths(0, 1, '#')); + await pollFor(page, () => sumWidths(0, 1, '#'), 21); }); it('fullwidth chars offset 1', async function(): Promise { const input = 'a1234567890#'; await page.evaluate(`window.term.write('${input}')`); - assert.equal(22, await sumWidths(0, 1, '#')); + await pollFor(page, () => sumWidths(0, 1, '#'), 22); }); // TODO: multiline tests once #1685 is resolved @@ -101,8 +98,8 @@ async function sumWidths(start: number, end: number, sentinel: string): Promise< if (!cell) { break; } - window.result += cell.width; - if (cell.char === '${sentinel}') { + window.result += cell.getWidth(); + if (cell.getChars() === '${sentinel}') { return; } } diff --git a/test/api/InputHandler.api.ts b/test/api/InputHandler.api.ts index fe0686103..ad30a51c1 100644 --- a/test/api/InputHandler.api.ts +++ b/test/api/InputHandler.api.ts @@ -6,6 +6,7 @@ import * as puppeteer from 'puppeteer'; import { assert } from 'chai'; import { ITerminalOptions } from 'xterm'; +import { pollFor } from './TestUtils'; const APP = 'http://127.0.0.1:3000/test'; @@ -15,12 +16,9 @@ const width = 800; const height = 600; describe('InputHandler Integration Tests', function(): void { - this.timeout(20000); - before(async function(): Promise { browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; @@ -45,7 +43,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('bar\\x1b[3D\\x1b[4@') `); - assert.deepEqual(await getLinesAsArray(2), [' foo', ' bar']); + await pollFor(page, () => getLinesAsArray(2), [' foo', ' bar']); }); it('CUU: Cursor Up Ps Times (default = 1) - CSI Ps A', async function(): Promise { @@ -55,7 +53,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[2Ab') `); - assert.deepEqual(await getLinesAsArray(4), ['', ' b', '', 'a']); + await pollFor(page, () => getLinesAsArray(4), ['', ' b', '', 'a']); }); it('CUD: Cursor Down Ps Times (default = 1) - CSI Ps B', async function(): Promise { @@ -65,7 +63,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[2Bb') `); - assert.deepEqual(await getLinesAsArray(4), ['', 'a', '', ' b']); + await pollFor(page, () => getLinesAsArray(4), ['', 'a', '', ' b']); }); it('CUF: Cursor Forward Ps Times (default = 1) - CSI Ps C', async function(): Promise { @@ -75,7 +73,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[2Cb') `); - assert.deepEqual(await getLinesAsArray(1), [' a b']); + await pollFor(page, () => getLinesAsArray(1), [' a b']); }); it('CUB: Cursor Backward Ps Times (default = 1) - CSI Ps D', async function(): Promise { @@ -85,7 +83,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[2Db') `); - assert.deepEqual(await getLinesAsArray(1), ['fba']); + await pollFor(page, () => getLinesAsArray(1), ['fba']); }); it('CNL: Cursor Next Line Ps Times (default = 1) - CSI Ps E', async function(): Promise { @@ -95,7 +93,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[2Eb') `); - assert.deepEqual(await getLinesAsArray(4), ['', 'a', '', 'b']); + await pollFor(page, () => getLinesAsArray(4), ['', 'a', '', 'b']); }); it('CPL: Cursor Preceding Line Ps Times (default = 1) - CSI Ps F', async function(): Promise { @@ -105,7 +103,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[2Fb') `); - assert.deepEqual(await getLinesAsArray(5), ['', 'b', '', 'a', '']); + await pollFor(page, () => getLinesAsArray(5), ['', 'b', '', 'a', '']); }); it('CHA: Cursor Character Absolute [column] (default = [row,1]) - CSI Ps G', async function(): Promise { @@ -115,7 +113,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[10Gb') `); - assert.deepEqual(await getLinesAsArray(1), ['aoo b']); + await pollFor(page, () => getLinesAsArray(1), ['aoo b']); }); it('CUP: Cursor Position [row;column] (default = [1,1]) - CSI Ps ; Ps H', async function(): Promise { @@ -125,7 +123,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[3;3Hb') `); - assert.deepEqual(await getLinesAsArray(3), ['aoo', '', ' b']); + await pollFor(page, () => getLinesAsArray(3), ['aoo', '', ' b']); }); it('CHT: Cursor Forward Tabulation Ps tab stops (default = 1) - CSI Ps I', async function(): Promise { @@ -135,7 +133,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\\n\\r\x1b[2Ib') `); - assert.deepEqual(await getLinesAsArray(2), [' a', ' b']); + await pollFor(page, () => getLinesAsArray(2), [' a', ' b']); }); it('ED: Erase in Display, VT100 - CSI Ps J', async function(): Promise { @@ -145,26 +143,26 @@ describe('InputHandler Integration Tests', function(): void { window.term.resize(5, 5); window.term.write('${fixture}\x1b[J') `); - assert.deepEqual(await getLinesAsArray(3), ['abc', 'd', '']); + await pollFor(page, () => getLinesAsArray(3), ['abc', 'd', '']); await page.evaluate(` // 0: Erase Below window.term.reset() window.term.write('${fixture}\x1b[0J') `); - assert.deepEqual(await getLinesAsArray(3), ['abc', 'd', '']); + await pollFor(page, () => getLinesAsArray(3), ['abc', 'd', '']); await page.evaluate(` // 1: Erase Above window.term.reset() window.term.write('${fixture}\x1b[1J') `); - assert.deepEqual(await getLinesAsArray(3), ['', ' f', 'ghi']); + await pollFor(page, () => getLinesAsArray(3), ['', ' f', 'ghi']); await page.evaluate(` // 2: Erase Saved Lines (scrollback) window.term.reset() window.term.write('1\\n2\\n3\\n4\\n5${fixture}\x1b[3J') `); - assert.equal(await page.evaluate(`window.term.buffer.length`), 5); - assert.deepEqual(await getLinesAsArray(5), [' 4', ' 5', 'abc', 'def', 'ghi']); + await pollFor(page, () => page.evaluate(`window.term.buffer.length`), 5); + await pollFor(page, () => getLinesAsArray(5), [' 4', ' 5', 'abc', 'def', 'ghi']); }); it('DECSED: Erase in Display, VT220 - CSI ? Ps J', async function(): Promise { @@ -174,26 +172,26 @@ describe('InputHandler Integration Tests', function(): void { window.term.resize(5, 5); window.term.write('${fixture}\x1b[?J') `); - assert.deepEqual(await getLinesAsArray(3), ['abc', 'd', '']); + await pollFor(page, () => getLinesAsArray(3), ['abc', 'd', '']); await page.evaluate(` // 0: Erase Below window.term.reset() window.term.write('${fixture}\x1b[?0J') `); - assert.deepEqual(await getLinesAsArray(3), ['abc', 'd', '']); + await pollFor(page, () => getLinesAsArray(3), ['abc', 'd', '']); await page.evaluate(` // 1: Erase Above window.term.reset() window.term.write('${fixture}\x1b[?1J') `); - assert.deepEqual(await getLinesAsArray(3), ['', ' f', 'ghi']); + await pollFor(page, () => getLinesAsArray(3), ['', ' f', 'ghi']); await page.evaluate(` // 2: Erase Saved Lines (scrollback) window.term.reset() window.term.write('1\\n2\\n3\\n4\\n5${fixture}\x1b[?3J') `); - assert.equal(await page.evaluate(`window.term.buffer.length`), 5); - assert.deepEqual(await getLinesAsArray(5), [' 4', ' 5', 'abc', 'def', 'ghi']); + await pollFor(page, () => page.evaluate(`window.term.buffer.length`), 5); + await pollFor(page, () => getLinesAsArray(5), [' 4', ' 5', 'abc', 'def', 'ghi']); }); it('IL: Insert Ps Line(s) (default = 1) - CSI Ps L', async function(): Promise { @@ -203,7 +201,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[2Lb') `); - assert.deepEqual(await getLinesAsArray(4), ['b', '', 'a', 'foo']); + await pollFor(page, () => getLinesAsArray(4), ['b', '', 'a', 'foo']); }); it('DL: Delete Ps Line(s) (default = 1) - CSI Ps M', async function(): Promise { @@ -213,7 +211,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\x1b[1Ed\\ne\\nf\x1b[2F\x1b[2M') `); - assert.deepEqual(await getLinesAsArray(5), [' b', ' f', '', '', '']); + await pollFor(page, () => getLinesAsArray(5), [' b', ' f', '', '', '']); }); it('DCH: Delete Ps Character(s) (default = 1) - CSI Ps P', async function(): Promise { @@ -223,7 +221,7 @@ describe('InputHandler Integration Tests', function(): void { // Explicit window.term.write('\\n\\rdef\x1b[2;1H\x1b[2P') `); - assert.deepEqual(await getLinesAsArray(2), ['bc', 'f']); + await pollFor(page, () => getLinesAsArray(2), ['bc', 'f']); }); describe('DSR: Device Status Report', () => { @@ -232,31 +230,31 @@ describe('InputHandler Integration Tests', function(): void { window.term.onData(e => window.result = e); window.term.write('\\x1b[5n'); `); - assert.equal(await page.evaluate(`window.result`), '\x1b[0n'); + await pollFor(page, () => page.evaluate(`window.result`), '\x1b[0n'); }); it('Report Cursor Position (CPR) - CSI 6 n', async function(): Promise { await page.evaluate(`window.term.write('\\n\\nfoo')`); - assert.deepEqual(await page.evaluate(` + await pollFor(page, () => page.evaluate(` [window.term.buffer.cursorY, window.term.buffer.cursorX] `), [2, 3]); await page.evaluate(` window.term.onData(e => window.result = e); window.term.write('\\x1b[6n'); `); - assert.equal(await page.evaluate(`window.result`), '\x1b[3;4R'); + await pollFor(page, () => page.evaluate(`window.result`), '\x1b[3;4R'); }); it('Report Cursor Position (DECXCPR) - CSI ? 6 n', async function(): Promise { await page.evaluate(`window.term.write('\\n\\nfoo')`); - assert.deepEqual(await page.evaluate(` + await pollFor(page, () => page.evaluate(` [window.term.buffer.cursorY, window.term.buffer.cursorX] `), [2, 3]); await page.evaluate(` window.term.onData(e => window.result = e); window.term.write('\\x1b[?6n'); `); - assert.equal(await page.evaluate(`window.result`), '\x1b[?3;4R'); + await pollFor(page, () => page.evaluate(`window.result`), '\x1b[?3;4R'); }); }); @@ -277,22 +275,23 @@ describe('InputHandler Integration Tests', function(): void { await page.mouse.up(); // Clear selection await page.mouse.click((coords.left + coords.right) / 2, (coords.top + coords.bottom) / 2); - assert.equal(await page.evaluate(`window.term.getSelection().length`), 0); + await pollFor(page, () => page.evaluate(`window.term.getSelection().length`), 0); // Enable mouse events await page.evaluate(`window.term.write('\x1b[?1003h')`); // Click and drag and ensure there is no selection await page.mouse.click((coords.left + coords.right) / 2, (coords.top + coords.bottom) / 2); await page.mouse.down(); await page.mouse.move((coords.left + coords.right) / 2, (coords.top + coords.bottom) / 4); - assert.equal(await page.evaluate(`window.term.getSelection().length`), 0, 'mouse events are on so there should be no selection'); + // mouse events are on so there should be no selection + await pollFor(page, () => page.evaluate(`window.term.getSelection().length`), 0); await page.mouse.up(); }); it('Pm = 2004, Set bracketed paste mode', async function(): Promise { - assert.equal(await simulatePaste('foo'), 'foo'); + await pollFor(page, () => simulatePaste('foo'), 'foo'); await page.evaluate(`window.term.write('\x1b[?2004h')`); - assert.equal(await simulatePaste('bar'), '\x1b[200~bar\x1b[201~'); + await pollFor(page, () => simulatePaste('bar'), '\x1b[200~bar\x1b[201~'); await page.evaluate(`window.term.write('\x1b[?2004l')`); - assert.equal(await simulatePaste('baz'), 'baz'); + await pollFor(page, () => simulatePaste('baz'), 'baz'); }); }); }); @@ -309,32 +308,32 @@ describe('InputHandler Integration Tests', function(): void { window.term.writeln(''); window.term.write('#\x1b[5b'); `); - assert.deepEqual(await getLinesAsArray(4), ['##', '##', '##', '######']); - assert.deepEqual(await getCursor(), {col: 6, row: 3}); + await pollFor(page, () => getLinesAsArray(4), ['##', '##', '##', '######']); + await pollFor(page, () => getCursor(), {col: 6, row: 3}); // should not repeat on fullwidth chars await page.evaluate(` window.term.reset(); window.term.write('¥\x1b[10b'); `); - assert.deepEqual(await getLinesAsArray(1), ['¥']); + await pollFor(page, () => getLinesAsArray(1), ['¥']); // should repeat only base char of combining await page.evaluate(` window.term.reset(); window.term.write('e\u0301\x1b[5b'); `); - assert.deepEqual(await getLinesAsArray(1), ['e\u0301eeeee']); + await pollFor(page, () => getLinesAsArray(1), ['e\u0301eeeee']); // should wrap correctly await page.evaluate(` window.term.reset(); window.term.write('#\x1b[15b'); `); - assert.deepEqual(await getLinesAsArray(2), ['##########', '######']); + await pollFor(page, () => getLinesAsArray(2), ['##########', '######']); await page.evaluate(` window.term.reset(); window.term.write('\x1b[?7l'); // disable wrap around window.term.write('#\x1b[15b'); `); - assert.deepEqual(await getLinesAsArray(2), ['##########', '']); + await pollFor(page, () => getLinesAsArray(2), ['##########', '']); // any successful sequence should reset REP await page.evaluate(` window.term.reset(); @@ -344,7 +343,45 @@ describe('InputHandler Integration Tests', function(): void { window.term.writeln(''); window.term.write('abcdefg\x1b[3D\x1b[10b#\x1b[3b'); `); - assert.deepEqual(await getLinesAsArray(3), ['#', ' #', 'abcd####']); + await pollFor(page, () => getLinesAsArray(3), ['#', ' #', 'abcd####']); + }); + + describe('Window Options - CSI Ps ; Ps ; Ps t', () => { + it('should be disabled by default', async function() { + await page.evaluate(`(() => { + window._stack = []; + const _h = window.term.onData(data => window._stack.push(data)); + window.term.write('\x1b[14t'); + window.term.write('\x1b[16t'); + window.term.write('\x1b[18t'); + window.term.write('\x1b[20t'); + window.term.write('\x1b[21t'); + return new Promise((r) => window.term.write('', () => { _h.dispose(); r(); })); + })()`); + await pollFor(page, async () => await page.evaluate(`(() => _stack)()`), []); + }); + it('14 - GetWinSizePixels', async function() { + await page.evaluate(`window.term.setOption('windowOptions', {getWinSizePixels: true});`); + await page.evaluate(`(() => { + window._stack = []; + const _h = window.term.onData(data => window._stack.push(data)); + window.term.write('\x1b[14t'); + return new Promise((r) => window.term.write('', () => { _h.dispose(); r(); })); + })()`); + const d = await getDimensions(); + await pollFor(page, async () => await page.evaluate(`(() => _stack)()`), [`\x1b[4;${d.height};${d.width}t`]); + }); + it('16 - GetCellSizePixels', async function() { + await page.evaluate(`window.term.setOption('windowOptions', {getCellSizePixels: true});`); + await page.evaluate(`(() => { + window._stack = []; + const _h = window.term.onData(data => window._stack.push(data)); + window.term.write('\x1b[16t'); + return new Promise((r) => window.term.write('', () => { _h.dispose(); r(); })); + })()`); + const d = await getDimensions(); + await pollFor(page, async () => await page.evaluate(`(() => _stack)()`), [`\x1b[6;${d.cellHeight};${d.cellWidth}t`]); + }); }); }); @@ -360,7 +397,7 @@ describe('InputHandler Integration Tests', function(): void { window.term.resize(10, 4); window.term.write('\\x1b[?47l\\x1b8'); `); - assert.deepEqual(await getCursor(), {col: 1, row: 3}); + await pollFor(page, () => getCursor(), {col: 1, row: 3}); }); }); }); @@ -404,3 +441,13 @@ async function getCursor(): Promise<{col: number, row: number}> { })(); `); } + +async function getDimensions(): Promise { + const dim = await page.evaluate(`term._core._renderService.dimensions`); + return { + cellWidth: dim.actualCellWidth.toFixed(0), + cellHeight: dim.actualCellHeight.toFixed(0), + width: dim.canvasWidth.toFixed(0), + height: dim.canvasHeight.toFixed(0) + }; +} diff --git a/test/api/MouseTracking.api.ts b/test/api/MouseTracking.api.ts index 37d46f74e..7cae660f5 100644 --- a/test/api/MouseTracking.api.ts +++ b/test/api/MouseTracking.api.ts @@ -4,8 +4,8 @@ */ import * as puppeteer from 'puppeteer'; -import { assert } from 'chai'; import { ITerminalOptions } from 'xterm'; +import { pollFor, writeSync } from './TestUtils'; const APP = 'http://127.0.0.1:3000/test'; @@ -198,22 +198,17 @@ function parseReport(encoding: string, msg: number[]): {state: any; row: number; /** * Mouse tracking tests. */ -describe('Mouse Tracking Tests', function(): void { - this.timeout(60000); - - before(async function(): Promise { +describe('Mouse Tracking Tests', () => { + before(async function(): Promise { browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; await page.setViewport({ width, height }); }); - after(() => { - browser.close(); - }); + after(async () => browser.close()); beforeEach(async () => { await page.goto(APP); @@ -225,6 +220,7 @@ describe('Mouse Tracking Tests', function(): void { await page.evaluate(` window.calls = []; window.term.onData(e => calls.push( Array.from(e).map(el => el.charCodeAt(0)) )); + window.term.onBinary(e => calls.push( Array.from(e).map(el => el.charCodeAt(0)) )); window.term.setOption('fontSize', ${fontSize}); window.term.resize(${cols}, ${rows}); `); @@ -242,30 +238,35 @@ describe('Mouse Tracking Tests', function(): void { const encoding = 'DEFAULT'; await resetMouseModes(); await mouseMove(0, 0); - await page.evaluate(`window.term.write('\x1b[?9h');`); + await writeSync(page, '\x1b[?9h'); // test at 0,0 await mouseDown('left'); - assert.deepEqual(await getReports(encoding), [{col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // mouseup should not report await mouseUp('left'); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); // mousemove should not report await mouseMove(50, 10); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [{col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // test at max rows/cols - // bug: we are capped at col 95 currently - // fix: allow values up to 223, any bigger should drop to 0 - await mouseMove(cols - 1, rows - 1); + // capped at 223 (1-based) + await mouseMove(223 - 1, rows - 1); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [{col: 95, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 223, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + + // higher than 223 should not report at all + await mouseMove(257, rows - 1); + await mouseDown('left'); + await mouseUp('left'); + await pollFor(page, () => getReports(encoding), []); // button press/move/release tests // left button @@ -274,7 +275,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('left'); await mouseMove(44, 24); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // middle button // bug: default action not cancelled (adds data to getReports from clipboard under X11) // await mouseMove(43, 24); @@ -282,7 +283,7 @@ describe('Mouse Tracking Tests', function(): void { // await mouseDown('middle'); // await mouseMove(44, 24); // await mouseUp('middle'); - // assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); + // await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); // right button // bug: default action not cancelled (popup shown) await mouseMove(43, 24); @@ -290,15 +291,15 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('right'); await mouseMove(44, 24); await mouseUp('right'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}]); // wheel await mouseMove(43, 24); await getReports(encoding); // clear reports await wheelUp(); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); await wheelDown(); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); // modifiers // CTRL @@ -310,7 +311,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Control'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // ALT @@ -322,7 +323,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Alt'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // SHIFT // note: caught by selection manager @@ -337,9 +338,9 @@ describe('Mouse Tracking Tests', function(): void { await wheelDown(); await page.keyboard.up('Shift'); if (noShift) { - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); } else { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); } @@ -358,34 +359,34 @@ describe('Mouse Tracking Tests', function(): void { await page.keyboard.up('Control'); await page.keyboard.up('Alt'); // await page.keyboard.up('Shift'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); }); it('SGR encoding', async () => { const encoding = 'SGR'; await resetMouseModes(); await mouseMove(0, 0); - await page.evaluate(`window.term.write('\x1b[?9h\x1b[?1006h');`); + await writeSync(page, '\x1b[?9h\x1b[?1006h'); // test at 0,0 await mouseDown('left'); - assert.deepEqual(await getReports(encoding), [{col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // mouseup should not report await mouseUp('left'); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); // mousemove should not report await mouseMove(50, 10); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [{col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // test at max rows/cols await mouseMove(cols - 1, rows - 1); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [{col: cols, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: cols, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // button press/move/release tests // left button @@ -394,7 +395,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('left'); await mouseMove(44, 24); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // middle button // bug: default action not cancelled (adds data to getReports from clipboard under X11) // await mouseMove(43, 24); @@ -402,7 +403,7 @@ describe('Mouse Tracking Tests', function(): void { // await mouseDown('middle'); // await mouseMove(44, 24); // await mouseUp('middle'); - // assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); + // await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); // right button // bug: default action not cancelled (popup shown) await mouseMove(43, 24); @@ -410,15 +411,15 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('right'); await mouseMove(44, 24); await mouseUp('right'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}]); // wheel await mouseMove(43, 24); await getReports(encoding); // clear reports await wheelUp(); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); await wheelDown(); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); // modifiers // CTRL @@ -430,7 +431,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Control'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // ALT @@ -442,7 +443,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Alt'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); // SHIFT // note: caught by selection manager @@ -455,9 +456,9 @@ describe('Mouse Tracking Tests', function(): void { await wheelDown(); await page.keyboard.up('Shift'); if (noShift) { - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); } else { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); } @@ -476,7 +477,7 @@ describe('Mouse Tracking Tests', function(): void { await page.keyboard.up('Control'); await page.keyboard.up('Alt'); // await page.keyboard.up('Shift'); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}]); }); }); describe('DECSET 1000 (VT200 mouse)', () => { @@ -491,39 +492,38 @@ describe('Mouse Tracking Tests', function(): void { const encoding = 'DEFAULT'; await resetMouseModes(); await mouseMove(0, 0); - await page.evaluate(`window.term.write('\x1b[?1000h');`); + await writeSync(page, '\x1b[?1000h'); // test at 0,0 await mouseDown('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mouseup should report, encoding cannot report released button await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // mousemove should not report await mouseMove(50, 10); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 51, row: 11, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // test at max rows/cols - // bug: we are capped at col 95 currently - // fix: allow values up to 223, any bigger should drop to 0 - await mouseMove(cols - 1, rows - 1); + // capped at 223 (1-based) + await mouseMove(223 - 1, rows - 1); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ - {col: 95, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, - {col: 95, row: rows, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} + await pollFor(page, () => getReports(encoding), [ + {col: 223, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, + {col: 223, row: rows, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // button press/move/release tests @@ -533,7 +533,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('left'); await mouseMove(44, 24); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); @@ -544,7 +544,7 @@ describe('Mouse Tracking Tests', function(): void { // await mouseDown('middle'); // await mouseMove(44, 24); // await mouseUp('middle'); - // assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); + // await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); // right button // bug: default action not cancelled (popup shown) await mouseMove(43, 24); @@ -552,7 +552,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('right'); await mouseMove(44, 24); await mouseUp('right'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); @@ -561,9 +561,9 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(43, 24); await getReports(encoding); // clear reports await wheelUp(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); await wheelDown(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); // modifiers // CTRL @@ -575,7 +575,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Control'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: true, shift: false, meta: false}}} @@ -590,7 +590,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Alt'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: true}}} @@ -608,11 +608,11 @@ describe('Mouse Tracking Tests', function(): void { await wheelDown(); await page.keyboard.up('Shift'); if (noShift) { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: true, meta: false}}} ]); } else { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: true, meta: false}}} @@ -633,7 +633,7 @@ describe('Mouse Tracking Tests', function(): void { await page.keyboard.up('Control'); await page.keyboard.up('Alt'); // await page.keyboard.up('Shift'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: true, shift: false, meta: true}}} @@ -643,26 +643,26 @@ describe('Mouse Tracking Tests', function(): void { const encoding = 'SGR'; await resetMouseModes(); await mouseMove(0, 0); - await page.evaluate(`window.term.write('\x1b[?1000h\x1b[?1006h');`); + await writeSync(page, '\x1b[?1000h\x1b[?1006h'); // test at 0,0 await mouseDown('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mouseup should report await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mousemove should not report await mouseMove(50, 10); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 51, row: 11, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); @@ -671,7 +671,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(cols - 1, rows - 1); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: cols, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: cols, row: rows, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); @@ -683,7 +683,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('left'); await mouseMove(44, 24); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); @@ -694,7 +694,7 @@ describe('Mouse Tracking Tests', function(): void { // await mouseDown('middle'); // await mouseMove(44, 24); // await mouseUp('middle'); - // assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); + // await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); // right button // bug: default action not cancelled (popup shown) await mouseMove(43, 24); @@ -702,7 +702,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('right'); await mouseMove(44, 24); await mouseUp('right'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: 'right', modifier: {control: false, shift: false, meta: false}}} ]); @@ -711,9 +711,9 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(43, 24); await getReports(encoding); // clear reports await wheelUp(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); await wheelDown(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); // modifiers // CTRL @@ -725,7 +725,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Control'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: true, shift: false, meta: false}}} @@ -740,7 +740,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Alt'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: true}}} @@ -757,11 +757,11 @@ describe('Mouse Tracking Tests', function(): void { await wheelDown(); await page.keyboard.up('Shift'); if (noShift) { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: true, meta: false}}} ]); } else { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: true, meta: false}}} @@ -782,7 +782,7 @@ describe('Mouse Tracking Tests', function(): void { await page.keyboard.up('Control'); await page.keyboard.up('Alt'); // await page.keyboard.up('Shift'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: true, shift: false, meta: true}}} @@ -801,39 +801,38 @@ describe('Mouse Tracking Tests', function(): void { const encoding = 'DEFAULT'; await resetMouseModes(); await mouseMove(0, 0); - await page.evaluate(`window.term.write('\x1b[?1002h');`); + await writeSync(page, '\x1b[?1002h'); // test at 0,0 await mouseDown('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mouseup should report, encoding cannot report released button await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // mousemove should not report await mouseMove(50, 10); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 51, row: 11, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // test at max rows/cols - // bug: we are capped at col 95 currently - // fix: allow values up to 223, any bigger should drop to 0 - await mouseMove(cols - 1, rows - 1); + // capped at 223 (1-based) + await mouseMove(223 - 1, rows - 1); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ - {col: 95, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, - {col: 95, row: rows, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} + await pollFor(page, () => getReports(encoding), [ + {col: 223, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, + {col: 223, row: rows, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // button press/move/release tests @@ -843,7 +842,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('left'); await mouseMove(44, 24); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} @@ -855,7 +854,7 @@ describe('Mouse Tracking Tests', function(): void { // await mouseDown('middle'); // await mouseMove(44, 24); // await mouseUp('middle'); - // assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); + // await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); // right button // bug: default action not cancelled (popup shown) await mouseMove(43, 24); @@ -863,7 +862,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('right'); await mouseMove(44, 24); await mouseUp('right'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'right', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} @@ -873,9 +872,9 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(43, 24); await getReports(encoding); // clear reports await wheelUp(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); await wheelDown(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); // modifiers // CTRL @@ -887,7 +886,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Control'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: true, shift: false, meta: false}}}, @@ -903,7 +902,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Alt'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: true}}}, @@ -921,11 +920,11 @@ describe('Mouse Tracking Tests', function(): void { await wheelDown(); await page.keyboard.up('Shift'); if (noShift) { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: true, meta: false}}} ]); } else { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: false, shift: true, meta: false}}}, @@ -947,7 +946,7 @@ describe('Mouse Tracking Tests', function(): void { await page.keyboard.up('Control'); await page.keyboard.up('Alt'); // await page.keyboard.up('Shift'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'release', button: '', modifier: {control: true, shift: false, meta: true}}}, @@ -958,27 +957,27 @@ describe('Mouse Tracking Tests', function(): void { const encoding = 'SGR'; await resetMouseModes(); await mouseMove(0, 0); - await page.evaluate(`window.term.write('\x1b[?1002h\x1b[?1006h');`); + await writeSync(page, '\x1b[?1002h\x1b[?1006h'); // test at 0,0 // bug: release is fired immediately await mouseDown('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mouseup should report, encoding cannot report released button await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mousemove should not report await mouseMove(50, 10); - assert.deepEqual(await getReports(encoding), []); + await pollFor(page, () => getReports(encoding), []); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 51, row: 11, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); @@ -987,7 +986,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(cols - 1, rows - 1); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: cols, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: cols, row: rows, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); @@ -999,7 +998,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('left'); await mouseMove(44, 24); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} @@ -1011,7 +1010,7 @@ describe('Mouse Tracking Tests', function(): void { // await mouseDown('middle'); // await mouseMove(44, 24); // await mouseUp('middle'); - // assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); + // await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); // right button // bug: default action not cancelled (popup shown) await mouseMove(43, 24); @@ -1019,7 +1018,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('right'); await mouseMove(44, 24); await mouseUp('right'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'right', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: 'right', modifier: {control: false, shift: false, meta: false}}} @@ -1029,9 +1028,9 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(43, 24); await getReports(encoding); // clear reports await wheelUp(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); await wheelDown(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); // modifiers // CTRL @@ -1043,7 +1042,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Control'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: true, shift: false, meta: false}}}, @@ -1059,7 +1058,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Alt'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: true}}}, @@ -1077,11 +1076,11 @@ describe('Mouse Tracking Tests', function(): void { await wheelDown(); await page.keyboard.up('Shift'); if (noShift) { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: true, meta: false}}} ]); } else { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: false, shift: true, meta: false}}}, @@ -1103,7 +1102,7 @@ describe('Mouse Tracking Tests', function(): void { await page.keyboard.up('Control'); await page.keyboard.up('Alt'); // await page.keyboard.up('Shift'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'release', button: 'left', modifier: {control: true, shift: false, meta: true}}}, @@ -1120,42 +1119,41 @@ describe('Mouse Tracking Tests', function(): void { const encoding = 'DEFAULT'; await resetMouseModes(); await mouseMove(0, 0); - await page.evaluate(`window.term.write('\x1b[?1003h');`); + await writeSync(page, '\x1b[?1003h'); // test at 0,0 await mouseDown('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mouseup should report, encoding cannot report released button await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // mousemove should report await mouseMove(50, 10); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 51, row: 11, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}} ]); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 51, row: 11, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // test at max rows/cols - // bug: we are capped at col 95 currently - // fix: allow values up to 223, any bigger should drop to 0 - await mouseMove(cols - 1, rows - 1); + // capped at 223 (1-based) + await mouseMove(223 - 1, rows - 1); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ - {col: 95, row: rows, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}}, - {col: 95, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, - {col: 95, row: rows, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} + await pollFor(page, () => getReports(encoding), [ + {col: 223, row: rows, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}}, + {col: 223, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, + {col: 223, row: rows, state: {action: 'release', button: '', modifier: {control: false, shift: false, meta: false}}} ]); // button press/move/release tests @@ -1164,7 +1162,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('left'); await mouseMove(44, 24); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: false, meta: false}}}, @@ -1177,14 +1175,14 @@ describe('Mouse Tracking Tests', function(): void { // await mouseDown('middle'); // await mouseMove(44, 24); // await mouseUp('middle'); - // assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); + // await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); // right button // bug: default action not cancelled (popup shown) await mouseMove(43, 24); await mouseDown('right'); await mouseMove(44, 24); await mouseUp('right'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}}, {col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'right', modifier: {control: false, shift: false, meta: false}}}, @@ -1195,9 +1193,9 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(43, 24); await getReports(encoding); // clear reports await wheelUp(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); await wheelDown(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); // modifiers // CTRL @@ -1208,7 +1206,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Control'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: true, shift: false, meta: false}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: true, shift: false, meta: false}}}, @@ -1224,7 +1222,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Alt'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: true}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: false, meta: true}}}, @@ -1242,12 +1240,12 @@ describe('Mouse Tracking Tests', function(): void { await wheelDown(); await page.keyboard.up('Shift'); if (noShift) { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: true, meta: false}}} ]); } else { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: true, meta: false}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: true, meta: false}}}, @@ -1269,7 +1267,7 @@ describe('Mouse Tracking Tests', function(): void { await page.keyboard.up('Control'); await page.keyboard.up('Alt'); // await page.keyboard.up('Shift'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: true, shift: false, meta: true}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: true, shift: false, meta: true}}}, @@ -1281,28 +1279,28 @@ describe('Mouse Tracking Tests', function(): void { const encoding = 'SGR'; await resetMouseModes(); await mouseMove(0, 0); - await page.evaluate(`window.term.write('\x1b[?1003h\x1b[?1006h');`); + await writeSync(page, '\x1b[?1003h\x1b[?1006h'); // test at 0,0 await mouseDown('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mouseup should report, encoding cannot report released button await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 1, row: 1, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); // mousemove should report await mouseMove(50, 10); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 51, row: 11, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}} ]); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 51, row: 11, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 51, row: 11, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} ]); @@ -1313,7 +1311,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(cols - 1, rows - 1); await mouseDown('left'); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: cols, row: rows, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}}, {col: cols, row: rows, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: cols, row: rows, state: {action: 'release', button: 'left', modifier: {control: false, shift: false, meta: false}}} @@ -1325,7 +1323,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseDown('left'); await mouseMove(44, 24); await mouseUp('left'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: false, meta: false}}}, @@ -1338,14 +1336,14 @@ describe('Mouse Tracking Tests', function(): void { // await mouseDown('middle'); // await mouseMove(44, 24); // await mouseUp('middle'); - // assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); + // await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'press', button: 'middle', modifier: {control: false, shift: false, meta: false}}}]); // right button // bug: default action not cancelled (popup shown) await mouseMove(43, 24); await mouseDown('right'); await mouseMove(44, 24); await mouseUp('right'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: false}}}, {col: 44, row: 25, state: {action: 'press', button: 'right', modifier: {control: false, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'right', modifier: {control: false, shift: false, meta: false}}}, @@ -1356,9 +1354,9 @@ describe('Mouse Tracking Tests', function(): void { await mouseMove(43, 24); await getReports(encoding); // clear reports await wheelUp(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'up', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); await wheelDown(); - assert.deepEqual(await getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); + await pollFor(page, () => getReports(encoding), [{col: 44, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: false, meta: false}}}]); // modifiers // CTRL @@ -1369,7 +1367,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Control'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: true, shift: false, meta: false}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: true, shift: false, meta: false}}}, @@ -1385,7 +1383,7 @@ describe('Mouse Tracking Tests', function(): void { await mouseUp('left'); await wheelDown(); await page.keyboard.up('Alt'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: false, meta: true}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: false, meta: true}}}, @@ -1403,12 +1401,12 @@ describe('Mouse Tracking Tests', function(): void { await wheelDown(); await page.keyboard.up('Shift'); if (noShift) { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'down', button: 'wheel', modifier: {control: false, shift: true, meta: false}}} ]); } else { - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: false, shift: true, meta: false}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: false, shift: true, meta: false}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: false, shift: true, meta: false}}}, @@ -1430,7 +1428,7 @@ describe('Mouse Tracking Tests', function(): void { await page.keyboard.up('Control'); await page.keyboard.up('Alt'); // await page.keyboard.up('Shift'); - assert.deepEqual(await getReports(encoding), [ + await pollFor(page, () => getReports(encoding), [ {col: 44, row: 25, state: {action: 'move', button: '', modifier: {control: true, shift: false, meta: true}}}, {col: 44, row: 25, state: {action: 'press', button: 'left', modifier: {control: true, shift: false, meta: true}}}, {col: 45, row: 25, state: {action: 'move', button: 'left', modifier: {control: true, shift: false, meta: true}}}, diff --git a/test/api/Parser.api.ts b/test/api/Parser.api.ts index e46242ab1..72f567994 100644 --- a/test/api/Parser.api.ts +++ b/test/api/Parser.api.ts @@ -6,6 +6,7 @@ import * as puppeteer from 'puppeteer'; import { assert } from 'chai'; import { ITerminalOptions } from 'xterm'; +import { writeSync } from './TestUtils'; const APP = 'http://127.0.0.1:3000/test'; @@ -15,12 +16,9 @@ const width = 800; const height = 600; describe('Parser Integration Tests', function(): void { - this.timeout(20000); - before(async function(): Promise { browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; @@ -29,9 +27,7 @@ describe('Parser Integration Tests', function(): void { await openTerminal(); }); - after(() => { - browser.close(); - }); + after(async () => browser.close()); describe('addCsiHandler', () => { it('should call custom CSI handler with js array params', async () => { @@ -43,9 +39,7 @@ describe('Parser Integration Tests', function(): void { return false; }, ''); `); - await page.evaluate(` - window.term.write('\x1b[38;5;123mparams\x1b[38:2::50:100:150msubparams'); - `); + await writeSync(page, '\x1b[38;5;123mparams\x1b[38:2::50:100:150msubparams'); assert.deepEqual(await page.evaluate(`(() => _customCsiHandlerParams)();`), [[38, 5, 123], [38, [2, -1, 50, 100, 150]]]); }); }); @@ -67,9 +61,7 @@ describe('Parser Integration Tests', function(): void { return false; }); `); - await page.evaluate(` - window.term.write('\x1bP1;2+psome data\x1b\\\\'); - `); + await writeSync(page, '\x1bP1;2+psome data\x1b\\\\'); assert.deepEqual(await page.evaluate(`(() => _customDcsHandlerCallStack)();`), [['C', [1, 2], 'some data'], ['B', [1, 2], 'some data']]); }); }); @@ -91,9 +83,7 @@ describe('Parser Integration Tests', function(): void { return false; }); `); - await page.evaluate(` - window.term.write('\x1b(B'); - `); + await writeSync(page, '\x1b(B'); assert.deepEqual(await page.evaluate(`(() => _customEscHandlerCallStack)();`), ['C', 'B']); }); }); @@ -115,9 +105,7 @@ describe('Parser Integration Tests', function(): void { return false; }); `); - await page.evaluate(` - window.term.write('\x1b]1234;some data\x07'); - `); + await writeSync(page, '\x1b]1234;some data\x07'); assert.deepEqual(await page.evaluate(`(() => _customOscHandlerCallStack)();`), [['C', 'some data'], ['B', 'some data']]); }); }); diff --git a/test/api/Terminal.api.ts b/test/api/Terminal.api.ts index 08963d9ca..cdfab61f3 100644 --- a/test/api/Terminal.api.ts +++ b/test/api/Terminal.api.ts @@ -6,6 +6,7 @@ import * as puppeteer from 'puppeteer'; import { assert } from 'chai'; import { ITerminalOptions } from 'xterm'; +import { pollFor, timeout, writeSync } from './TestUtils'; const APP = 'http://127.0.0.1:3000/test'; @@ -15,54 +16,46 @@ const width = 800; const height = 600; describe('API Integration Tests', function(): void { - this.timeout(20000); - - before(async function(): Promise { + before(async () => { browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, - slowMo: 80, args: [`--window-size=${width},${height}`, `--no-sandbox`] }); page = (await browser.pages())[0]; await page.setViewport({ width, height }); }); - after(() => { - browser.close(); - }); + after(async () => browser.close()); + beforeEach(async () => page.goto(APP)); - beforeEach(async () => { - await page.goto(APP); - }); - - it('Default options', async function(): Promise { + it('Default options', async () => { await openTerminal(); assert.equal(await page.evaluate(`window.term.cols`), 80); assert.equal(await page.evaluate(`window.term.rows`), 24); }); - it('write', async function(): Promise { + it('write', async () => { await openTerminal(); await page.evaluate(` window.term.write('foo'); window.term.write('bar'); window.term.write('文'); `); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foobar文'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foobar文'); }); - it('write with callback', async function(): Promise { + it('write with callback', async () => { await openTerminal(); await page.evaluate(` window.term.write('foo', () => { window.__x = 'a'; }); window.term.write('bar', () => { window.__x += 'b'; }); window.term.write('文', () => { window.__x += 'c'; }); `); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foobar文'); - assert.equal(await page.evaluate(`window.__x`), 'abc'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foobar文'); + await pollFor(page, `window.__x`, 'abc'); }); - it('write - bytes (UTF8)', async function(): Promise { + it('write - bytes (UTF8)', async () => { await openTerminal(); await page.evaluate(` // foo @@ -72,10 +65,10 @@ describe('API Integration Tests', function(): void { // 文 window.term.write(new Uint8Array([230, 150, 135])); `); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foobar文'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foobar文'); }); - it('write - bytes (UTF8) with callback', async function(): Promise { + it('write - bytes (UTF8) with callback', async () => { await openTerminal(); await page.evaluate(` // foo @@ -85,48 +78,48 @@ describe('API Integration Tests', function(): void { // 文 window.term.write(new Uint8Array([230, 150, 135]), () => { window.__x += 'C'; }); `); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foobar文'); - assert.equal(await page.evaluate(`window.__x`), 'ABC'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foobar文'); + await pollFor(page, `window.__x`, 'ABC'); }); - it('writeln', async function(): Promise { + it('writeln', async () => { await openTerminal(); await page.evaluate(` window.term.writeln('foo'); window.term.writeln('bar'); window.term.writeln('文'); `); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foo'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(1).translateToString(true)`), 'bar'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(2).translateToString(true)`), '文'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foo'); + await pollFor(page, `window.term.buffer.getLine(1).translateToString(true)`, 'bar'); + await pollFor(page, `window.term.buffer.getLine(2).translateToString(true)`, '文'); }); - it('writeln with callback', async function(): Promise { + it('writeln with callback', async () => { await openTerminal(); await page.evaluate(` window.term.writeln('foo', () => { window.__x = '1'; }); window.term.writeln('bar', () => { window.__x += '2'; }); window.term.writeln('文', () => { window.__x += '3'; }); `); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foo'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(1).translateToString(true)`), 'bar'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(2).translateToString(true)`), '文'); - assert.equal(await page.evaluate(`window.__x`), '123'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foo'); + await pollFor(page, `window.term.buffer.getLine(1).translateToString(true)`, 'bar'); + await pollFor(page, `window.term.buffer.getLine(2).translateToString(true)`, '文'); + await pollFor(page, `window.__x`, '123'); }); - it('writeln - bytes (UTF8)', async function(): Promise { + it('writeln - bytes (UTF8)', async () => { await openTerminal(); await page.evaluate(` window.term.writeln(new Uint8Array([102, 111, 111])); window.term.writeln(new Uint8Array([98, 97, 114])); window.term.writeln(new Uint8Array([230, 150, 135])); `); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foo'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(1).translateToString(true)`), 'bar'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(2).translateToString(true)`), '文'); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'foo'); + await pollFor(page, `window.term.buffer.getLine(1).translateToString(true)`, 'bar'); + await pollFor(page, `window.term.buffer.getLine(2).translateToString(true)`, '文'); }); - it('paste', async function(): Promise { + it('paste', async () => { await openTerminal(); await page.evaluate(` window.calls = []; @@ -137,26 +130,28 @@ describe('API Integration Tests', function(): void { window.term.paste('foo'); }); `); - assert.deepEqual(await page.evaluate(`window.calls`), ['foo', '\rfoo\rbar\r', '\x1b[200~foo\x1b[201~']); + await pollFor(page, `window.calls`, ['foo', '\rfoo\rbar\r', '\x1b[200~foo\x1b[201~']); }); - it('clear', async function(): Promise { + it('clear', async () => { await openTerminal({ rows: 5 }); await page.evaluate(` window.term.write('test0'); + window.parsed = 0; for (let i = 1; i < 10; i++) { - window.term.write('\\n\\rtest' + i); + window.term.write('\\n\\rtest' + i, () => window.parsed++); } `); + await pollFor(page, `window.parsed`, 9); await page.evaluate(`window.term.clear()`); - assert.equal(await page.evaluate(`window.term.buffer.length`), '5'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'test9'); + await pollFor(page, `window.term.buffer.length`, 5); + await pollFor(page, `window.term.buffer.getLine(0).translateToString(true)`, 'test9'); for (let i = 1; i < 5; i++) { - assert.equal(await page.evaluate(`window.term.buffer.getLine(${i}).translateToString(true)`), ''); + await pollFor(page, `window.term.buffer.getLine(${i}).translateToString(true)`, ''); } }); - it('getOption, setOption', async function(): Promise { + it('getOption, setOption', async () => { await openTerminal(); assert.equal(await page.evaluate(`window.term.getOption('rendererType')`), 'canvas'); await page.evaluate(`window.term.setOption('rendererType', 'dom')`); @@ -164,9 +159,10 @@ describe('API Integration Tests', function(): void { }); describe('renderer', () => { - it('foreground', async function(): Promise { + it('foreground', async () => { await openTerminal({ rendererType: 'dom' }); - await page.evaluate(`window.term.write('\\x1b[30m0\\x1b[31m1\\x1b[32m2\\x1b[33m3\\x1b[34m4\\x1b[35m5\\x1b[36m6\\x1b[37m7')`); + await writeSync(page, '\\x1b[30m0\\x1b[31m1\\x1b[32m2\\x1b[33m3\\x1b[34m4\\x1b[35m5\\x1b[36m6\\x1b[37m7'); + await pollFor(page, `document.querySelectorAll('.xterm-rows > :nth-child(1) > *').length`, 9); assert.deepEqual(await page.evaluate(` [ document.querySelector('.xterm-rows > :nth-child(1) > :nth-child(1)').className, @@ -188,9 +184,10 @@ describe('API Integration Tests', function(): void { ]); }); - it('background', async function(): Promise { + it('background', async () => { await openTerminal({ rendererType: 'dom' }); - await page.evaluate(`window.term.write('\\x1b[40m0\\x1b[41m1\\x1b[42m2\\x1b[43m3\\x1b[44m4\\x1b[45m5\\x1b[46m6\\x1b[47m7')`); + await writeSync(page, '\\x1b[40m0\\x1b[41m1\\x1b[42m2\\x1b[43m3\\x1b[44m4\\x1b[45m5\\x1b[46m6\\x1b[47m7'); + await pollFor(page, `document.querySelectorAll('.xterm-rows > :nth-child(1) > *').length`, 9); assert.deepEqual(await page.evaluate(` [ document.querySelector('.xterm-rows > :nth-child(1) > :nth-child(1)').className, @@ -213,7 +210,7 @@ describe('API Integration Tests', function(): void { }); }); - it('selection', async function(): Promise { + it('selection', async () => { await openTerminal({ rows: 5, cols: 5 }); await page.evaluate(`window.term.write('\\n\\nfoo\\n\\n\\rbar\\n\\n\\rbaz')`); assert.equal(await page.evaluate(`window.term.hasSelection()`), false); @@ -233,7 +230,7 @@ describe('API Integration Tests', function(): void { assert.deepEqual(await page.evaluate(`window.term.getSelectionPosition()`), { startColumn: 1, startRow: 2, endColumn: 3, endRow: 2 }); }); - it('focus, blur', async function(): Promise { + it('focus, blur', async () => { await openTerminal(); assert.equal(await page.evaluate(`document.activeElement.className`), ''); await page.evaluate(`window.term.focus()`); @@ -243,7 +240,7 @@ describe('API Integration Tests', function(): void { }); describe('loadAddon', () => { - it('constructor', async function(): Promise { + it('constructor', async () => { await openTerminal({ cols: 5 }); await page.evaluate(` window.cols = 0; @@ -255,7 +252,7 @@ describe('API Integration Tests', function(): void { assert.equal(await page.evaluate(`window.cols`), 5); }); - it('dispose (addon)', async function(): Promise { + it('dispose (addon)', async () => { await openTerminal(); await page.evaluate(` window.disposeCalled = false @@ -270,7 +267,7 @@ describe('API Integration Tests', function(): void { assert.equal(await page.evaluate(`window.disposeCalled`), true); }); - it('dispose (terminal)', async function(): Promise { + it('dispose (terminal)', async () => { await openTerminal(); await page.evaluate(` window.disposeCalled = false @@ -286,19 +283,19 @@ describe('API Integration Tests', function(): void { }); describe('Events', () => { - it('onCursorMove', async function(): Promise { + it('onCursorMove', async () => { await openTerminal(); await page.evaluate(` window.callCount = 0; window.term.onCursorMove(e => window.callCount++); window.term.write('foo'); `); - assert.equal(await page.evaluate(`window.callCount`), 1); + await pollFor(page, `window.callCount`, 1); await page.evaluate(`window.term.write('bar')`); - assert.equal(await page.evaluate(`window.callCount`), 2); + await pollFor(page, `window.callCount`, 2); }); - it('onData', async function(): Promise { + it('onData', async () => { await openTerminal(); await page.evaluate(` window.calls = []; @@ -308,7 +305,7 @@ describe('API Integration Tests', function(): void { assert.deepEqual(await page.evaluate(`window.calls`), ['f', 'o', 'o']); }); - it('onKey', async function(): Promise { + it('onKey', async () => { await openTerminal(); await page.evaluate(` window.calls = []; @@ -318,19 +315,19 @@ describe('API Integration Tests', function(): void { assert.deepEqual(await page.evaluate(`window.calls`), ['f', 'o', 'o']); }); - it('onLineFeed', async function(): Promise { + it('onLineFeed', async () => { await openTerminal(); await page.evaluate(` window.callCount = 0; window.term.onLineFeed(() => callCount++); window.term.writeln('foo'); `); - assert.equal(await page.evaluate(`window.callCount`), 1); + await pollFor(page, `window.callCount`, 1); await page.evaluate(`window.term.writeln('bar')`); - assert.equal(await page.evaluate(`window.callCount`), 2); + await pollFor(page, `window.callCount`, 2); }); - it('onScroll', async function(): Promise { + it('onScroll', async () => { await openTerminal({ rows: 5 }); await page.evaluate(` window.calls = []; @@ -339,94 +336,97 @@ describe('API Integration Tests', function(): void { window.term.writeln('foo'); } `); - assert.deepEqual(await page.evaluate(`window.calls`), []); + await pollFor(page, `window.calls`, []); await page.evaluate(`window.term.writeln('bar')`); - assert.deepEqual(await page.evaluate(`window.calls`), [1]); + await pollFor(page, `window.calls`, [1]); await page.evaluate(`window.term.writeln('baz')`); - assert.deepEqual(await page.evaluate(`window.calls`), [1, 2]); + await pollFor(page, `window.calls`, [1, 2]); }); - it('onSelectionChange', async function(): Promise { + it('onSelectionChange', async () => { await openTerminal(); await page.evaluate(` window.callCount = 0; window.term.onSelectionChange(() => window.callCount++); `); - assert.equal(await page.evaluate(`window.callCount`), 0); + await pollFor(page, `window.callCount`, 0); await page.evaluate(`window.term.selectAll()`); - assert.equal(await page.evaluate(`window.callCount`), 1); + await pollFor(page, `window.callCount`, 1); await page.evaluate(`window.term.clearSelection()`); - assert.equal(await page.evaluate(`window.callCount`), 2); + await pollFor(page, `window.callCount`, 2); }); - it('onRender', async function(): Promise { + it('onRender', async function (): Promise { + this.retries(3); await openTerminal(); + await timeout(20); // Ensure all init events are fired await page.evaluate(` window.calls = []; window.term.onRender(e => window.calls.push([e.start, e.end])); `); - assert.deepEqual(await page.evaluate(`window.calls`), []); + await pollFor(page, `window.calls`, []); await page.evaluate(`window.term.write('foo')`); - assert.deepEqual(await page.evaluate(`window.calls`), [[0, 0]]); + await pollFor(page, `window.calls`, [[0, 0]]); await page.evaluate(`window.term.write('bar\\n\\nbaz')`); - assert.deepEqual(await page.evaluate(`window.calls`), [[0, 0], [0, 2]]); + await pollFor(page, `window.calls`, [[0, 0], [0, 2]]); }); - it('onResize', async function(): Promise { + it('onResize', async () => { await openTerminal(); + await timeout(20); // Ensure all init events are fired await page.evaluate(` window.calls = []; window.term.onResize(e => window.calls.push([e.cols, e.rows])); `); - assert.deepEqual(await page.evaluate(`window.calls`), []); + await pollFor(page, `window.calls`, []); await page.evaluate(`window.term.resize(10, 5)`); - assert.deepEqual(await page.evaluate(`window.calls`), [[10, 5]]); + await pollFor(page, `window.calls`, [[10, 5]]); await page.evaluate(`window.term.resize(20, 15)`); - assert.deepEqual(await page.evaluate(`window.calls`), [[10, 5], [20, 15]]); + await pollFor(page, `window.calls`, [[10, 5], [20, 15]]); }); - it('onTitleChange', async function(): Promise { + it('onTitleChange', async () => { await openTerminal(); await page.evaluate(` window.calls = []; window.term.onTitleChange(e => window.calls.push(e)); `); - assert.deepEqual(await page.evaluate(`window.calls`), []); + await pollFor(page, `window.calls`, []); await page.evaluate(`window.term.write('\\x1b]2;foo\\x9c')`); - assert.deepEqual(await page.evaluate(`window.calls`), ['foo']); + await pollFor(page, `window.calls`, ['foo']); }); }); describe('buffer', () => { - it('cursorX, cursorY', async function(): Promise { + it('cursorX, cursorY', async () => { await openTerminal({ rows: 5, cols: 5 }); assert.equal(await page.evaluate(`window.term.buffer.cursorX`), 0); assert.equal(await page.evaluate(`window.term.buffer.cursorY`), 0); - await page.evaluate(`window.term.write('foo')`); + await writeSync(page, 'foo'); assert.equal(await page.evaluate(`window.term.buffer.cursorX`), 3); assert.equal(await page.evaluate(`window.term.buffer.cursorY`), 0); - await page.evaluate(`window.term.write('\\n')`); + await writeSync(page, '\\n'); assert.equal(await page.evaluate(`window.term.buffer.cursorX`), 3); assert.equal(await page.evaluate(`window.term.buffer.cursorY`), 1); - await page.evaluate(`window.term.write('\\r')`); + await writeSync(page, '\\r'); assert.equal(await page.evaluate(`window.term.buffer.cursorX`), 0); assert.equal(await page.evaluate(`window.term.buffer.cursorY`), 1); - await page.evaluate(`window.term.write('abcde')`); + await writeSync(page, 'abcde'); assert.equal(await page.evaluate(`window.term.buffer.cursorX`), 5); assert.equal(await page.evaluate(`window.term.buffer.cursorY`), 1); - await page.evaluate(`window.term.write('\\n\\r\\n\\n\\n\\n\\n')`); + await writeSync(page, '\\n\\r\\n\\n\\n\\n\\n'); assert.equal(await page.evaluate(`window.term.buffer.cursorX`), 0); assert.equal(await page.evaluate(`window.term.buffer.cursorY`), 4); }); - it('viewportY', async function(): Promise { + it('viewportY', async () => { await openTerminal({ rows: 5 }); assert.equal(await page.evaluate(`window.term.buffer.viewportY`), 0); - await page.evaluate(`window.term.write('\\n\\n\\n\\n')`); + await writeSync(page, '\\n\\n\\n\\n'); assert.equal(await page.evaluate(`window.term.buffer.viewportY`), 0); - await page.evaluate(`window.term.write('\\n')`); + await writeSync(page, '\\n'); assert.equal(await page.evaluate(`window.term.buffer.viewportY`), 1); - await page.evaluate(`window.term.write('\\n\\n\\n\\n')`); + await writeSync(page, '\\n\\n\\n\\n'); assert.equal(await page.evaluate(`window.term.buffer.viewportY`), 5); await page.evaluate(`window.term.scrollLines(-1)`); assert.equal(await page.evaluate(`window.term.buffer.viewportY`), 4); @@ -434,14 +434,14 @@ describe('API Integration Tests', function(): void { assert.equal(await page.evaluate(`window.term.buffer.viewportY`), 0); }); - it('baseY', async function(): Promise { + it('baseY', async () => { await openTerminal({ rows: 5 }); assert.equal(await page.evaluate(`window.term.buffer.baseY`), 0); - await page.evaluate(`window.term.write('\\n\\n\\n\\n')`); + await writeSync(page, '\\n\\n\\n\\n'); assert.equal(await page.evaluate(`window.term.buffer.baseY`), 0); - await page.evaluate(`window.term.write('\\n')`); + await writeSync(page, '\\n'); assert.equal(await page.evaluate(`window.term.buffer.baseY`), 1); - await page.evaluate(`window.term.write('\\n\\n\\n\\n')`); + await writeSync(page, '\\n\\n\\n\\n'); assert.equal(await page.evaluate(`window.term.buffer.baseY`), 5); await page.evaluate(`window.term.scrollLines(-1)`); assert.equal(await page.evaluate(`window.term.buffer.baseY`), 5); @@ -449,44 +449,44 @@ describe('API Integration Tests', function(): void { assert.equal(await page.evaluate(`window.term.buffer.baseY`), 5); }); - it('length', async function(): Promise { + it('length', async () => { await openTerminal({ rows: 5 }); assert.equal(await page.evaluate(`window.term.buffer.length`), 5); - await page.evaluate(`window.term.write('\\n\\n\\n\\n')`); + await writeSync(page, '\\n\\n\\n\\n'); assert.equal(await page.evaluate(`window.term.buffer.length`), 5); - await page.evaluate(`window.term.write('\\n')`); + await writeSync(page, '\\n'); assert.equal(await page.evaluate(`window.term.buffer.length`), 6); - await page.evaluate(`window.term.write('\\n\\n\\n\\n')`); + await writeSync(page, '\\n\\n\\n\\n'); assert.equal(await page.evaluate(`window.term.buffer.length`), 10); }); describe('getLine', () => { - it('invalid index', async function(): Promise { + it('invalid index', async () => { await openTerminal({ rows: 5 }); assert.equal(await page.evaluate(`window.term.buffer.getLine(-1)`), undefined); assert.equal(await page.evaluate(`window.term.buffer.getLine(5)`), undefined); }); - it('isWrapped', async function(): Promise { + it('isWrapped', async () => { await openTerminal({ cols: 5 }); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).isWrapped`), false); assert.equal(await page.evaluate(`window.term.buffer.getLine(1).isWrapped`), false); - await page.evaluate(`window.term.write('abcde')`); + await writeSync(page, 'abcde'); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).isWrapped`), false); assert.equal(await page.evaluate(`window.term.buffer.getLine(1).isWrapped`), false); - await page.evaluate(`window.term.write('f')`); + await writeSync(page, 'f'); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).isWrapped`), false); assert.equal(await page.evaluate(`window.term.buffer.getLine(1).isWrapped`), true); }); - it('translateToString', async function(): Promise { + it('translateToString', async () => { await openTerminal({ cols: 5 }); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString()`), ' '); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), ''); - await page.evaluate(`window.term.write('foo')`); + await writeSync(page, 'foo'); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString()`), 'foo '); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'foo'); - await page.evaluate(`window.term.write('bar')`); + await writeSync(page, 'bar'); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString()`), 'fooba'); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(true)`), 'fooba'); assert.equal(await page.evaluate(`window.term.buffer.getLine(1).translateToString(true)`), 'r'); @@ -494,24 +494,24 @@ describe('API Integration Tests', function(): void { assert.equal(await page.evaluate(`window.term.buffer.getLine(0).translateToString(false, 1, 3)`), 'oo'); }); - it('getCell', async function(): Promise { + it('getCell', async () => { await openTerminal({ cols: 5 }); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(-1)`), undefined); assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(5)`), undefined); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(0).char`), ''); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(0).width`), 1); - await page.evaluate(`window.term.write('a文')`); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(0).char`), 'a'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(0).width`), 1); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(1).char`), '文'); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(1).width`), 2); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(2).char`), ''); - assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(2).width`), 0); + assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(0).getChars()`), ''); + assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(0).getWidth()`), 1); + await writeSync(page, 'a文'); + assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(0).getChars()`), 'a'); + assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(0).getWidth()`), 1); + assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(1).getChars()`), '文'); + assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(1).getWidth()`), 2); + assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(2).getChars()`), ''); + assert.equal(await page.evaluate(`window.term.buffer.getLine(0).getCell(2).getWidth()`), 0); }); }); }); - it('dispose', async function(): Promise { + it('dispose', async () => { await page.evaluate(` window.term = new Terminal(); window.term.dispose(); @@ -519,7 +519,7 @@ describe('API Integration Tests', function(): void { assert.equal(await page.evaluate(`window.term._core._isDisposed`), true); }); - it('dispose (opened)', async function(): Promise { + it('dispose (opened)', async () => { await openTerminal(); await page.evaluate(`window.term.dispose()`); assert.equal(await page.evaluate(`window.term._core._isDisposed`), true); diff --git a/test/api/TestUtils.ts b/test/api/TestUtils.ts new file mode 100644 index 000000000..d36748122 --- /dev/null +++ b/test/api/TestUtils.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import * as puppeteer from 'puppeteer'; +import deepEqual = require('deep-equal'); + +export async function pollFor(page: puppeteer.Page, evalOrFn: string | (() => Promise), val: T, preFn?: () => Promise): Promise { + if (preFn) { + await preFn(); + } + const result = typeof evalOrFn === 'string' ? await page.evaluate(evalOrFn) : await evalOrFn(); + if (!deepEqual(result, val)) { + return new Promise(r => { + setTimeout(() => r(pollFor(page, evalOrFn, val, preFn)), 1); + }); + } +} + +export 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); +} + +export async function timeout(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} diff --git a/test/benchmark/tsconfig.json b/test/benchmark/tsconfig.json index 8b93dcb13..cac99d90c 100644 --- a/test/benchmark/tsconfig.json +++ b/test/benchmark/tsconfig.json @@ -16,7 +16,7 @@ "paths": { "common/*": [ "../../src/common/*" ], "browser/*": [ "../../src/browser/*" ], - "Terminal": [ "../../src/Terminal" ] + "Terminal": ["../../src/Terminal"] }, }, "include": [ @@ -31,4 +31,4 @@ { "path": "../../src/common" }, { "path": "../../src/browser" }, ] -} \ No newline at end of file +} diff --git a/tsconfig.all.json b/tsconfig.all.json index 7ddf33d51..8143ea91b 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -8,7 +8,10 @@ { "path": "./addons/xterm-addon-attach/src" }, { "path": "./addons/xterm-addon-fit/src" }, { "path": "./addons/xterm-addon-search/src" }, + { "path": "./addons/xterm-addon-unicode11/src" }, { "path": "./addons/xterm-addon-web-links/src" }, - { "path": "./addons/xterm-addon-webgl/src" } + { "path": "./addons/xterm-addon-webgl/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 931dfd3b9..f1d856005 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -72,6 +72,11 @@ declare module 'xterm' { */ cursorStyle?: 'block' | 'underline' | 'bar'; + /** + * The width of the cursor in CSS pixels when `cursorStyle` is set to 'bar'. + */ + cursorWidth?: number; + /** * Whether input should be disabled. */ @@ -148,6 +153,18 @@ declare module 'xterm' { */ macOptionClickForcesSelection?: boolean; + /** + * The minimum contrast ratio for text in the terminal, setting this will + * change the foreground color dynamically depending on whether the contrast + * ratio is met. Example values: + * + * - 1: The default, do nothing. + * - 4.5: Minimum for WCAG AA compliance. + * - 7: Minimum for WCAG AAA compliance. + * - 21: White on black or black on white. + */ + minimumContrastRatio?: number; + /** * The type of renderer to use, this allows using the fallback DOM renderer * when canvas is too slow for the environment. The following features do @@ -215,6 +232,12 @@ declare module 'xterm' { * double click to select work logic. */ wordSeparator?: string; + + /** + * Enable various window manipulation and report features. + * All features are disabled by default for security reasons. + */ + windowOptions?: IWindowOptions; } /** @@ -319,8 +342,8 @@ declare module 'xterm' { * An event that can be listened to. * @returns an `IDisposable` to stop listening. */ - export interface IEvent { - (listener: (e: T) => any): IDisposable; + export interface IEvent { + (listener: (arg1: T, arg2: U) => any): IDisposable; } /** @@ -362,6 +385,162 @@ declare module 'xterm' { tooMuchOutput: string; } + /** + * Enable various window manipulation and report features (CSI Ps ; Ps ; Ps t). + * + * Most settings have no default implementation, as they heavily rely on + * the embedding environment. + * + * To implement a feature, create a custom CSI hook like this: + * ```ts + * term.parser.addCsiHandler({final: 't'}, params => { + * const ps = params[0]; + * switch (ps) { + * case XY: + * ... // your implementation for option XY + * return true; // signal Ps=XY was handled + * } + * return false; // any Ps that was not handled + * }); + * ``` + * + * Note on security: + * Most features are meant to deal with some information of the host machine + * where the terminal runs on. This is seen as a security risk possibly leaking + * sensitive data of the host to the program in the terminal. Therefore all options + * (even those without a default implementation) are guarded by the boolean flag + * and disabled by default. + */ + export interface IWindowOptions { + /** + * Ps=1 De-iconify window. + * No default implementation. + */ + restoreWin?: boolean; + /** + * Ps=2 Iconify window. + * No default implementation. + */ + minimizeWin?: boolean; + /** + * Ps=3 ; x ; y + * Move window to [x, y]. + * No default implementation. + */ + setWinPosition?: boolean; + /** + * Ps = 4 ; height ; width + * Resize the window to given `height` and `width` in pixels. + * Omitted parameters should reuse the current height or width. + * Zero parameters should use the display's height or width. + * No default implementation. + */ + setWinSizePixels?: boolean; + /** + * Ps=5 Raise the window to the front of the stacking order. + * No default implementation. + */ + raiseWin?: boolean; + /** + * Ps=6 Lower the xterm window to the bottom of the stacking order. + * No default implementation. + */ + lowerWin?: boolean; + /** Ps=7 Refresh the window. */ + refreshWin?: boolean; + /** + * Ps = 8 ; height ; width + * Resize the text area to given height and width in characters. + * Omitted parameters should reuse the current height or width. + * Zero parameters use the display's height or width. + * No default implementation. + */ + setWinSizeChars?: boolean; + /** + * Ps=9 ; 0 Restore maximized window. + * Ps=9 ; 1 Maximize window (i.e., resize to screen size). + * Ps=9 ; 2 Maximize window vertically. + * Ps=9 ; 3 Maximize window horizontally. + * No default implementation. + */ + maximizeWin?: boolean; + /** + * Ps=10 ; 0 Undo full-screen mode. + * Ps=10 ; 1 Change to full-screen. + * Ps=10 ; 2 Toggle full-screen. + * No default implementation. + */ + fullscreenWin?: boolean; + /** Ps=11 Report xterm window state. + * If the xterm window is non-iconified, it returns "CSI 1 t". + * If the xterm window is iconified, it returns "CSI 2 t". + * No default implementation. + */ + getWinState?: boolean; + /** + * Ps=13 Report xterm window position. Result is "CSI 3 ; x ; y t". + * Ps=13 ; 2 Report xterm text-area position. Result is "CSI 3 ; x ; y t". + * No default implementation. + */ + getWinPosition?: boolean; + /** + * Ps=14 Report xterm text area size in pixels. Result is "CSI 4 ; height ; width t". + * Ps=14 ; 2 Report xterm window size in pixels. Result is "CSI 4 ; height ; width t". + * Has a default implementation. + */ + getWinSizePixels?: boolean; + /** + * Ps=15 Report size of the screen in pixels. Result is "CSI 5 ; height ; width t". + * No default implementation. + */ + getScreenSizePixels?: boolean; + /** + * Ps=16 Report xterm character cell size in pixels. Result is "CSI 6 ; height ; width t". + * Has a default implementation. + */ + getCellSizePixels?: boolean; + /** + * Ps=18 Report the size of the text area in characters. Result is "CSI 8 ; height ; width t". + * Has a default implementation. + */ + getWinSizeChars?: boolean; + /** + * Ps=19 Report the size of the screen in characters. Result is "CSI 9 ; height ; width t". + * No default implementation. + */ + getScreenSizeChars?: boolean; + /** + * Ps=20 Report xterm window's icon label. Result is "OSC L label ST". + * No default implementation. + */ + getIconTitle?: boolean; + /** + * Ps=21 Report xterm window's title. Result is "OSC l label ST". + * No default implementation. + */ + getWinTitle?: boolean; + /** + * Ps=22 ; 0 Save xterm icon and window title on stack. + * Ps=22 ; 1 Save xterm icon title on stack. + * Ps=22 ; 2 Save xterm window title on stack. + * All variants have a default implementation. + */ + pushTitle?: boolean; + /** + * Ps=23 ; 0 Restore xterm icon and window title from stack. + * Ps=23 ; 1 Restore xterm icon title from stack. + * Ps=23 ; 2 Restore xterm window title from stack. + * All variants have a default implementation. + */ + popTitle?: boolean; + /** + * Ps>=24 Resize to Ps lines (DECSLPP). + * DECSLPP is not implemented. This settings is also used to + * enable / disable DECCOLM (earlier variant of DECSLPP). + */ + setWinLines?: boolean; + } + /** * The class that represents an xterm.js terminal. */ @@ -409,6 +588,12 @@ declare module 'xterm' { */ readonly parser: IParser; + /** + * (EXPERIMENTAL) Get the Unicode handling interface + * to register and switch Unicode version. + */ + readonly unicode: IUnicodeHandling; + /** * Natural language strings that can be localized. */ @@ -421,6 +606,17 @@ declare module 'xterm' { */ constructor(options?: ITerminalOptions); + /** + * Adds an event listener for when a binary event fires. This is used to + * enable non UTF-8 conformant binary messages to be sent to the backend. + * Currently this is only used for a certain type of mouse reports that + * happen to be not UTF-8 compatible. + * The event value is a JS string, pass it to the underlying pty as + * binary data, e.g. `pty.write(Buffer.from(data, 'binary'))`. + * @returns an `IDisposable` to stop listening. + */ + onBinary: IEvent; + /** * Adds an event listener for the cursor moves. * @returns an `IDisposable` to stop listening. @@ -591,6 +787,11 @@ declare module 'xterm' { * alt buffer is active, undefined is returned. * @param cursorYOffset The y position offset of the marker from the cursor. */ + registerMarker(cursorYOffset: number): IMarker; + + /** + * @deprecated use `registerMarker` instead. + */ addMarker(cursorYOffset: number): IMarker; /** @@ -716,22 +917,12 @@ declare module 'xterm' { * Retrieves an option's value from the terminal. * @param key The option key. */ - getOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'disableStdin' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'screenKeys' | 'useFlowControl' | 'visualBell' | 'windowsMode'): boolean; - /** - * Retrieves an option's value from the terminal. - * @param key The option key. - */ - getOption(key: 'colors'): string[]; + getOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'disableStdin' | 'macOptionIsMeta' | 'rightClickSelectsWord' | 'popOnBell' | 'visualBell' | 'windowsMode'): boolean; /** * Retrieves an option's value from the terminal. * @param key The option key. */ getOption(key: 'cols' | 'fontSize' | 'letterSpacing' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback'): number; - /** - * Retrieves an option's value from the terminal. - * @param key The option key. - */ - getOption(key: 'handler'): (data: string) => void; /** * Retrieves an option's value from the terminal. * @param key The option key. @@ -773,25 +964,13 @@ declare module 'xterm' { * @param key The option key. * @param value The option value. */ - setOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'disableStdin' | 'macOptionIsMeta' | 'popOnBell' | 'rightClickSelectsWord' | 'screenKeys' | 'useFlowControl' | 'visualBell' | 'windowsMode', value: boolean): void; - /** - * Sets an option on the terminal. - * @param key The option key. - * @param value The option value. - */ - setOption(key: 'colors', value: string[]): void; + setOption(key: 'allowTransparency' | 'cancelEvents' | 'convertEol' | 'cursorBlink' | 'disableStdin' | 'macOptionIsMeta' | 'popOnBell' | 'rightClickSelectsWord' | 'visualBell' | 'windowsMode', value: boolean): void; /** * Sets an option on the terminal. * @param key The option key. * @param value The option value. */ setOption(key: 'fontSize' | 'letterSpacing' | 'lineHeight' | 'tabStopWidth' | 'scrollback', value: number): void; - /** - * Sets an option on the terminal. - * @param key The option key. - * @param value The option value. - */ - setOption(key: 'handler', value: (data: string) => void): void; /** * Sets an option on the terminal. * @param key The option key. @@ -1023,6 +1202,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; } /** @@ -1034,6 +1220,12 @@ declare module 'xterm' { */ readonly isWrapped: boolean; + /** + * The length of the line, all call to getCell beyond the length will result + * in `undefined`. + */ + readonly length: number; + /** * Gets a cell from the line, or undefined if the line index does not exist. * @@ -1042,8 +1234,11 @@ declare module 'xterm' { * behavior. * * @param x The character index to get. + * @param cell Optional cell object to load data into for performance + * reasons. This is mainly useful when every cell in the buffer is being + * looped over to avoid creating new objects for every cell. */ - 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 @@ -1060,23 +1255,106 @@ declare module 'xterm' { * Represents a single cell in the terminal's buffer. */ interface IBufferCell { - /** - * The character within the cell. - */ - readonly char: string; - /** * 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`. + * - `1` for most cells. + * - `2` for wide character like CJK glyphs. + * - `0` for cells immediately following cells with a width of `2`. */ - readonly width: number; + getWidth(): number; + + /** + * The character(s) within the cell. Examples of what this can contain: + * + * - A normal width character + * - A wide character (eg. CJK) + * - An emoji + */ + getChars(): string; + + /** + * Gets the UTF32 codepoint of single characters, if content is a combined + * string it returns the codepoint of the last character in the string. + */ + getCode(): number; + + /** + * Gets the number representation of the foreground color mode, this can be + * used to perform quick comparisons of 2 cells to see if they're the same. + * Use `isFgRGB`, `isFgPalette` and `isFgDefault` to check what color mode + * a cell is. + */ + getFgColorMode(): number; + + /** + * Gets the number representation of the background color mode, this can be + * used to perform quick comparisons of 2 cells to see if they're the same. + * Use `isBgRGB`, `isBgPalette` and `isBgDefault` to check what color mode + * a cell is. + */ + getBgColorMode(): number; + + /** + * Gets a cell's foreground color number, this differs depending on what the + * color mode of the cell is: + * + * - Default: This should be 0, representing the default foreground color + * (CSI 39 m). + * - Palette: This is a number from 0 to 255 of ANSI colors (CSI 3(0-7) m, + * CSI 9(0-7) m, CSI 38 ; 5 ; 0-255 m). + * - RGB: A hex value representing a 'true color': 0xRRGGBB. + * (CSI 3 8 ; 2 ; Pi ; Pr ; Pg ; Pb) + */ + getFgColor(): number; + + /** + * Gets a cell's background color number, this differs depending on what the + * color mode of the cell is: + * + * - Default: This should be 0, representing the default background color + * (CSI 49 m). + * - Palette: This is a number from 0 to 255 of ANSI colors + * (CSI 4(0-7) m, CSI 10(0-7) m, CSI 48 ; 5 ; 0-255 m). + * - RGB: A hex value representing a 'true color': 0xRRGGBB + * (CSI 4 8 ; 2 ; Pi ; Pr ; Pg ; Pb) + */ + getBgColor(): number; + + /** Whether the cell has the bold attribute (CSI 1 m). */ + isBold(): number; + /** Whether the cell has the inverse attribute (CSI 3 m). */ + isItalic(): number; + /** Whether the cell has the inverse attribute (CSI 2 m). */ + isDim(): number; + /** Whether the cell has the underline attribute (CSI 4 m). */ + isUnderline(): number; + /** Whether the cell has the inverse attribute (CSI 5 m). */ + isBlink(): number; + /** Whether the cell has the inverse attribute (CSI 7 m). */ + isInverse(): number; + /** Whether the cell has the inverse attribute (CSI 8 m). */ + isInvisible(): number; + + /** Whether the cell is using the RGB foreground color mode. */ + isFgRGB(): boolean; + /** Whether the cell is using the RGB background color mode. */ + isBgRGB(): boolean; + /** Whether the cell is using the palette foreground color mode. */ + isFgPalette(): boolean; + /** Whether the cell is using the palette background color mode. */ + isBgPalette(): boolean; + /** Whether the cell is using the default foreground color mode. */ + isFgDefault(): boolean; + /** Whether the cell is using the default background color mode. */ + isBgDefault(): boolean; + + /** Whether the cell has the default attribute (no color or style). */ + isAttributeDefault(): boolean; } /** - * (EXPERIMENTAL) Data type to register a CSI, DCS or ESC callback in the parser + * Data type to register a CSI, DCS or ESC callback in the parser * in the form: * ESC I..I F * CSI Prefix P..P I..I F @@ -1120,7 +1398,7 @@ declare module 'xterm' { } /** - * (EXPERIMENTAL) Parser interface. + * Allows hooking into the parser for custom handling of escape sequences. */ export interface IParser { /** @@ -1135,7 +1413,7 @@ declare module 'xterm' { * The most recently added handler is tried first. * @return An IDisposable you can call to remove this handler. */ - addCsiHandler(id: IFunctionIdentifier, callback: (params: (number | number[])[]) => boolean): IDisposable; + registerCsiHandler(id: IFunctionIdentifier, callback: (params: (number | number[])[]) => boolean): IDisposable; /** * Adds a handler for DCS escape sequences. @@ -1154,7 +1432,7 @@ declare module 'xterm' { * The most recently added handler is tried first. * @return An IDisposable you can call to remove this handler. */ - addDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: (number | number[])[]) => boolean): IDisposable; + registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: (number | number[])[]) => boolean): IDisposable; /** * Adds a handler for ESC escape sequences. @@ -1167,7 +1445,7 @@ declare module 'xterm' { * The most recently added handler is tried first. * @return An IDisposable you can call to remove this handler. */ - addEscHandler(id: IFunctionIdentifier, handler: () => boolean): IDisposable; + registerEscHandler(id: IFunctionIdentifier, handler: () => boolean): IDisposable; /** * Adds a handler for OSC escape sequences. @@ -1185,6 +1463,42 @@ declare module 'xterm' { * The most recently added handler is tried first. * @return An IDisposable you can call to remove this handler. */ - addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable; + registerOscHandler(ident: number, callback: (data: string) => boolean): IDisposable; + } + + /** + * (EXPERIMENTAL) Unicode version provider. + * Used to register custom Unicode versions with `Terminal.unicode.register`. + */ + export interface IUnicodeVersionProvider { + /** + * String indicating the Unicode version provided. + */ + readonly version: string; + + /** + * Unicode version dependent wcwidth implementation. + */ + wcwidth(codepoint: number): 0 | 1 | 2; + } + + /** + * (EXPERIMENTAL) Unicode handling interface. + */ + export interface IUnicodeHandling { + /** + * Register a custom Unicode version provider. + */ + register(provider: IUnicodeVersionProvider): void; + + /** + * Registered Unicode versions. + */ + readonly versions: ReadonlyArray; + + /** + * Getter/setter for active Unicode version. + */ + activeVersion: string; } } diff --git a/yarn.lock b/yarn.lock index cb02cf91c..22e3acc6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -130,6 +130,11 @@ resolved "https://registry.yarnpkg.com/@types/cli-table/-/cli-table-0.3.0.tgz#f1857156bf5fd115c6a2db260ba0be1f8fc5671c" integrity sha512-QnZUISJJXyhyD6L1e5QwXDV/A5i2W1/gl6D6YMc8u0ncPepbv/B4w3S+izVvtAg60m6h+JP09+Y/0zF2mojlFQ== +"@types/deep-equal@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/deep-equal/-/deep-equal-1.0.1.tgz#71cfabb247c22bcc16d536111f50c0ed12476b03" + integrity sha512-mMUu4nWHLBlHtxXY17Fg6+ucS/MnndyOWyOe7MmwkoMYxvfQU2ajtRaEvqSUv+aVkMqH/C0NCI8UoVfRNQ10yg== + "@types/events@*": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86" @@ -1454,6 +1459,18 @@ deep-eql@^0.1.3: dependencies: type-detect "0.1.1" +deep-equal@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.0.tgz#3103cdf8ab6d32cf4a8df7865458f2b8d33f3745" + integrity sha512-ZbfWJq/wN1Z273o7mUSjILYqehAktR2NVoSrOukDkU9kg2v/Uv89yU4Cvz8seJeAmtN5oqiefKq8FPuXOboqLw== + dependencies: + is-arguments "^1.0.4" + is-date-object "^1.0.1" + is-regex "^1.0.4" + object-is "^1.0.1" + object-keys "^1.1.1" + regexp.prototype.flags "^1.2.0" + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -2567,6 +2584,11 @@ is-accessor-descriptor@^1.0.0: dependencies: kind-of "^6.0.0" +is-arguments@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3" + integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA== + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -3399,17 +3421,17 @@ ms@2.1.1, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== +mustache@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-3.0.1.tgz#873855f23aa8a95b150fb96d9836edbc5a1d248a" + integrity sha512-jFI/4UVRsRYdUbuDTKT7KzfOp7FiD5WzYmmwNwXyUVypC0xjoTL78Fqc0jHUPIvvGD+6DQSPHIt1NE7D1ArsqA== + mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= -nan@2.10.0: - version "2.10.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" - integrity sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA== - -nan@^2.12.1: +nan@^2.12.1, nan@^2.14.0: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -3508,12 +3530,12 @@ node-pre-gyp@^0.12.0: semver "^5.3.0" tar "^4" -node-pty@0.7.6: - version "0.7.6" - resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.7.6.tgz#bff6148c9c5836ca7e73c7aaaec067dcbdac2f7b" - integrity sha512-ECzKUB7KkAFZ0cjyjMXp5WLJ+7YIZ1xnNmiiegOI6WdDaKABUNV5NbB1Dw9MXD4KrZipWII0wQ7RGZ6StU/7jA== +node-pty@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.9.0.tgz#8f9bcc0d1c5b970a3184ffd533d862c7eb6590a6" + integrity sha512-MBnCQl83FTYOu7B4xWw10AW77AAh7ThCE1VXEv+JeWj8mSpGo+0bwgsV+b23ljBFwEM9OmsOv3kM27iUPPm84g== dependencies: - nan "2.10.0" + nan "^2.14.0" nopt@^4.0.1: version "4.0.1" @@ -3634,7 +3656,12 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-keys@^1.0.11, object-keys@^1.0.12: +object-is@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.1.tgz#0aa60ec9989a0b3ed795cf4d06f62cf1ad6539b6" + integrity sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY= + +object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== @@ -4238,6 +4265,13 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" +regexp.prototype.flags@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.2.0.tgz#6b30724e306a27833eeb171b66ac8890ba37e41c" + integrity sha512-ztaw4M1VqgMwl9HlPpOuiYgItcHlunW0He2fE6eNfT6E/CF2FtYi9ofOYe4mKntstYk0Fyh/rDRBdS3AnxjlrA== + dependencies: + define-properties "^1.1.2" + release-zalgo@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/release-zalgo/-/release-zalgo-1.0.0.tgz#09700b7e5074329739330e535c5a90fb67851730"