diff --git a/README.md b/README.md index 1551c864..e7a80bfb 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Since xterm.js is typically implemented as a developer tool, only modern browser - Edge latest - Firefox latest - Safari latest +- IE11 Xterm.js works seamlessly in Electron apps and may even work on earlier versions of the browsers but these are the browsers we strive to keep working. @@ -129,7 +130,7 @@ Xterm.js is used in several world-class applications to provide great terminal e - [**JupyterLab**](https://github.com/jupyterlab/jupyterlab): An extensible computational environment for Jupyter, supporting interactive data science and scientific computing across all programming languages. - [**Theia**](https://github.com/theia-ide/theia): Theia is a cloud & desktop IDE framework implemented in TypeScript. -- [**DevOps Helper**](https://github.com/ricktbaker/devops_helper) DevOps Helper tool to make life easier working with AWS instances across multiple organizations. +- [**Opshell**](https://github.com/ricktbaker/opshell) Ops Helper tool to make life easier working with AWS instances across multiple organizations. Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it in our list. diff --git a/demo/main.js b/demo/main.js index 192c4f15..0522e0b3 100644 --- a/demo/main.js +++ b/demo/main.js @@ -95,7 +95,7 @@ function createTerminal() { term.fit(); // fit is called within a setTimeout, cols and rows need this. - setTimeout(() => { + setTimeout(function () { colsElement.value = term.cols; rowsElement.value = term.rows; diff --git a/fixtures/typings-test/typings-test.ts b/fixtures/typings-test/typings-test.ts index 928ca238..bc8f1bd6 100644 --- a/fixtures/typings-test/typings-test.ts +++ b/fixtures/typings-test/typings-test.ts @@ -140,6 +140,7 @@ namespace methods_core { const r18: (data: string) => void = t.getOption('handler'); const r19: string = t.getOption('bellSound'); const r20: string = t.getOption('bellStyle'); + const r22: number = t.getOption('letterSpacing'); } { const t: Terminal = new Terminal(); @@ -157,6 +158,7 @@ namespace methods_core { t.setOption('useFlowControl', true); t.setOption('visualBell', true); t.setOption('colors', ['a', 'b']); + t.setOption('letterSpacing', 1); t.setOption('cols', 1); t.setOption('rows', 1); t.setOption('tabStopWidth', 1); diff --git a/gulpfile.js b/gulpfile.js index 6a20c729..cf001da3 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -25,8 +25,8 @@ let outDir = tsProject.config.compilerOptions.outDir; // Under some environments like TravisCI, this comes out at absolute which can // break the build. This ensures that the outDir is absolute. -if (outDir.indexOf(__dirname) !== 0) { - outDir = `${__dirname}/${outDir}`; +if (path.normalize(outDir).indexOf(__dirname) !== 0) { + outDir = `${__dirname}/${path.normalize(outDir)}`; } /** diff --git a/src/Buffer.ts b/src/Buffer.ts index 548fba71..8922c3d6 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -208,12 +208,19 @@ export class Buffer implements IBuffer { public translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol: number = 0, endCol: number = null): string { // Get full line let lineString = ''; - let widthAdjustedStartCol = startCol; - let widthAdjustedEndCol = endCol; const line = this.lines.get(lineIndex); if (!line) { return ''; } + + // Initialize column and index values. Column values represent the actual + // cell column, indexes represent the index in the string. Indexes are + // needed here because some chars are 0 characters long (eg. after wide + // chars) and some chars are longer than 1 characters long (eg. emojis). + let startIndex = startCol; + endCol = endCol || line.length; + let endIndex = endCol; + for (let i = 0; i < line.length; i++) { const char = line[i]; lineString += char[CHAR_DATA_CHAR_INDEX]; @@ -221,29 +228,39 @@ export class Buffer implements IBuffer { // column indexes if (char[CHAR_DATA_WIDTH_INDEX] === 0) { if (startCol >= i) { - widthAdjustedStartCol--; + startIndex--; } if (endCol >= i) { - widthAdjustedEndCol--; + endIndex--; + } + } else { + // Adjust the columns to take glyphs that are represented by multiple + // code points into account. + if (char[CHAR_DATA_CHAR_INDEX].length > 1) { + if (startCol > i) { + startIndex += char[CHAR_DATA_CHAR_INDEX].length - 1; + } + if (endCol > i) { + endIndex += char[CHAR_DATA_CHAR_INDEX].length - 1; + } } } } // Calculate the final end col by trimming whitespace on the right of the // line if needed. - let finalEndCol = widthAdjustedEndCol || line.length; if (trimRight) { const rightWhitespaceIndex = lineString.search(/\s+$/); if (rightWhitespaceIndex !== -1) { - finalEndCol = Math.min(finalEndCol, rightWhitespaceIndex); + endIndex = Math.min(endIndex, rightWhitespaceIndex); } // Return the empty string if only trimmed whitespace is selected - if (finalEndCol <= widthAdjustedStartCol) { + if (endIndex <= startIndex) { return ''; } } - return lineString.substring(widthAdjustedStartCol, finalEndCol); + return lineString.substring(startIndex, endIndex); } /** diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index a8b2ff12..caf74624 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -67,142 +67,3 @@ describe('InputHandler', () => { }); }); }); - -const old_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 = [ - [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], - [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): boolean { - let min = 0; - let max = COMBINING.length - 1; - let mid; - if (ucs < COMBINING[0][0] || ucs > COMBINING[max][1]) - return false; - while (max >= min) { - mid = Math.floor((min + max) / 2); - if (ucs > COMBINING[mid][1]) - min = mid + 1; - else if (ucs < COMBINING[mid][0]) - max = mid - 1; - else - return true; - } - return false; - } - function wcwidth(ucs: number): number { - // test for 8-bit control characters - if (ucs === 0) { - return opts.nul; - } - if (ucs < 32 || (ucs >= 0x7f && ucs < 0xa0)) { - return opts.control; - } - // binary search in table of non-spacing characters - if (bisearch(ucs)) { - return 0; - } - // if we arrive here, ucs is not a combining or C0/C1 control character - if (isWide(ucs)) { - return 2; - } - return 1; - } - function isWide(ucs: number): boolean { - return ( - ucs >= 0x1100 && ( - ucs <= 0x115f || // Hangul Jamo init. consonants - ucs === 0x2329 || - ucs === 0x232a || - (ucs >= 0x2e80 && ucs <= 0xa4cf && ucs !== 0x303f) || // CJK..Yi - (ucs >= 0xac00 && ucs <= 0xd7a3) || // Hangul Syllables - (ucs >= 0xf900 && ucs <= 0xfaff) || // CJK Compat Ideographs - (ucs >= 0xfe10 && ucs <= 0xfe19) || // Vertical forms - (ucs >= 0xfe30 && ucs <= 0xfe6f) || // CJK Compat Forms - (ucs >= 0xff00 && ucs <= 0xff60) || // Fullwidth Forms - (ucs >= 0xffe0 && ucs <= 0xffe6) || - (ucs >= 0x20000 && ucs <= 0x2fffd) || - (ucs >= 0x30000 && ucs <= 0x3fffd))); - } - return wcwidth; -})({nul: 0, control: 0}); // configurable options - -describe('wcwidth', () => { - it('same as old implementation for BMP and individual higher', (done) => { - for (let i = 0; i < 65536; ++i) - assert.equal(wcwidth(i), old_wcwidth(i)); - // test some individual higher to fullfill branching - assert.equal(wcwidth(0x10A01), old_wcwidth(0x10A01)); - assert.equal(wcwidth(0x30000), old_wcwidth(0x30000)); - assert.equal(wcwidth(0x3fffe), old_wcwidth(0x3fffe)); - done(); - }).timeout(3000); - /* - it('new is at least 5 times faster', () => { - let start_new = new Date().getTime(); - let x = 0; - for (let runs = 0; runs < 1; ++runs) - for (let i = 0; i < 65536; ++i) - x = wcwidth(i); - let end_new = new Date().getTime(); - let start_old = new Date().getTime(); - let y = 0; - for (let runs = 0; runs < 1; ++runs) - for (let i = 0; i < 65536; ++i) - y = old_wcwidth(i); - let end_old = new Date().getTime(); - // console.log((end_new - start_new)); - // console.log((end_old - start_old)); - assert.equal(((end_new - start_new) * 5 < (end_old - start_old)), true); - }); - */ -}); diff --git a/src/InputHandler.ts b/src/InputHandler.ts index 000e58ea..c953a250 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -9,6 +9,7 @@ import { C0 } from './EscapeSequences'; import { DEFAULT_CHARSET } from './Charsets'; import { CharData } from './Types'; import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX } from './Buffer'; +import { FLAGS } from './renderer/Types'; /** * The terminal's standard implementation of IInputHandler, this handles all @@ -1107,6 +1108,7 @@ export class InputHandler 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. @@ -1206,35 +1208,39 @@ export class InputHandler implements IInputHandler { // bg = 0x1ff; } else if (p === 1) { // bold text - flags |= 1; + flags |= FLAGS.BOLD; } else if (p === 4) { // underlined text - flags |= 2; + flags |= FLAGS.UNDERLINE; } else if (p === 5) { // blink - flags |= 4; + flags |= FLAGS.BLINK; } else if (p === 7) { // inverse and positive // test with: echo -e '\e[31m\e[42mhello\e[7mworld\e[27mhi\e[m' - flags |= 8; + flags |= FLAGS.INVERSE; } else if (p === 8) { // invisible - flags |= 16; + flags |= FLAGS.INVISIBLE; + } else if (p === 2) { + // dimmed text + flags |= FLAGS.DIM; } else if (p === 22) { - // not bold - flags &= ~1; + // not bold nor faint + flags &= ~FLAGS.BOLD; + flags &= ~FLAGS.DIM; } else if (p === 24) { // not underlined - flags &= ~2; + flags &= ~FLAGS.UNDERLINE; } else if (p === 25) { // not blink - flags &= ~4; + flags &= ~FLAGS.BLINK; } else if (p === 27) { // not inverse - flags &= ~8; + flags &= ~FLAGS.INVERSE; } else if (p === 28) { // not invisible - flags &= ~16; + flags &= ~FLAGS.INVISIBLE; } else if (p === 39) { // reset fg fg = (this._terminal.defAttr >> 9) & 0x1ff; diff --git a/src/Interfaces.ts b/src/Interfaces.ts index 2c77e8d3..0ab0af5a 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -139,6 +139,7 @@ export interface ITerminalOptions { fontFamily?: string; geometry?: [number, number]; handler?: (data: string) => void; + letterSpacing?: number; lineHeight?: number; rows?: number; screenKeys?: boolean; diff --git a/src/Linkifier.ts b/src/Linkifier.ts index 42d20f9c..e6a63b5c 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -87,7 +87,7 @@ export class Linkifier extends EventEmitter implements ILinkifier { this._rowsToLinkify.end = end; } else { this._rowsToLinkify.start = this._rowsToLinkify.start < start ? this._rowsToLinkify.start : start; - this._rowsToLinkify.end = this._rowsToLinkify.end < end ? this._rowsToLinkify.end : end; + this._rowsToLinkify.end = this._rowsToLinkify.end > end ? this._rowsToLinkify.end : end; } // Clear out any existing links on this row range diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index e5bdc117..8c6ea3e1 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -12,7 +12,7 @@ import { SelectionManager } from './SelectionManager'; import { SelectionModel } from './SelectionModel'; import { BufferSet } from './BufferSet'; import { MockTerminal } from './utils/TestUtils.test'; -import { LineData } from './Types'; +import { LineData, CharData } from './Types'; class TestSelectionManager extends SelectionManager { constructor( @@ -66,6 +66,10 @@ describe('SelectionManager', () => { return result; } + function stringArrayToRow(chars: string[]): LineData { + return chars.map(c => [0, c, 1, c.charCodeAt(0)]); + } + describe('_selectWordAt', () => { it('should expand selection for normal width chars', () => { buffer.lines.set(0, stringToRow('foo bar')); @@ -185,6 +189,113 @@ describe('SelectionManager', () => { selectionManager.selectWordAt([15, 0]); assert.equal(selectionManager.selectionText, 'ij"'); }); + describe('emoji', () => { + it('should treat a single emoji as a word when wrapped in spaces', () => { + buffer.lines.set(0, stringToRow(' ⚽ a')); // The a is here to prevent the space being trimmed in selectionText + selectionManager.selectWordAt([0, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([1, 0]); + assert.equal(selectionManager.selectionText, '⚽'); + selectionManager.selectWordAt([2, 0]); + assert.equal(selectionManager.selectionText, ' '); + }); + it('should treat multiple emojis as a word when wrapped in spaces', () => { + buffer.lines.set(0, stringToRow(' ⚽⚽ a')); // The a is here to prevent the space being trimmed in selectionText + selectionManager.selectWordAt([0, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([1, 0]); + assert.equal(selectionManager.selectionText, '⚽⚽'); + selectionManager.selectWordAt([2, 0]); + assert.equal(selectionManager.selectionText, '⚽⚽'); + selectionManager.selectWordAt([3, 0]); + assert.equal(selectionManager.selectionText, ' '); + }); + it('should treat emojis using the zero-width-joiner as a single word', () => { + // Note that the first 3 emojis include the invisible ZWJ char + buffer.lines.set(0, stringArrayToRow([ + ' ', '👨‍', '👩‍', '👧‍', '👦', ' ', 'a' + ])); // The a is here to prevent the space being trimmed in selectionText + selectionManager.selectWordAt([0, 0]); + assert.equal(selectionManager.selectionText, ' '); + // ZWJ emojis do not combine in the terminal so the family emoji used here consumed 4 cells + // The selection text should retain ZWJ chars despite not combining on the terminal + selectionManager.selectWordAt([1, 0]); + assert.equal(selectionManager.selectionText, '👨‍👩‍👧‍👦'); + selectionManager.selectWordAt([2, 0]); + assert.equal(selectionManager.selectionText, '👨‍👩‍👧‍👦'); + selectionManager.selectWordAt([3, 0]); + assert.equal(selectionManager.selectionText, '👨‍👩‍👧‍👦'); + selectionManager.selectWordAt([4, 0]); + assert.equal(selectionManager.selectionText, '👨‍👩‍👧‍👦'); + selectionManager.selectWordAt([5, 0]); + assert.equal(selectionManager.selectionText, ' '); + }); + it('should treat emojis and characters joined together as a word', () => { + buffer.lines.set(0, stringToRow(' ⚽ab cd⚽ ef⚽gh')); // The a is here to prevent the space being trimmed in selectionText + selectionManager.selectWordAt([0, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([1, 0]); + assert.equal(selectionManager.selectionText, '⚽ab'); + selectionManager.selectWordAt([2, 0]); + assert.equal(selectionManager.selectionText, '⚽ab'); + selectionManager.selectWordAt([3, 0]); + assert.equal(selectionManager.selectionText, '⚽ab'); + selectionManager.selectWordAt([4, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([5, 0]); + assert.equal(selectionManager.selectionText, 'cd⚽'); + selectionManager.selectWordAt([6, 0]); + assert.equal(selectionManager.selectionText, 'cd⚽'); + selectionManager.selectWordAt([7, 0]); + assert.equal(selectionManager.selectionText, 'cd⚽'); + selectionManager.selectWordAt([8, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([9, 0]); + assert.equal(selectionManager.selectionText, 'ef⚽gh'); + selectionManager.selectWordAt([10, 0]); + assert.equal(selectionManager.selectionText, 'ef⚽gh'); + selectionManager.selectWordAt([11, 0]); + assert.equal(selectionManager.selectionText, 'ef⚽gh'); + selectionManager.selectWordAt([12, 0]); + assert.equal(selectionManager.selectionText, 'ef⚽gh'); + selectionManager.selectWordAt([13, 0]); + assert.equal(selectionManager.selectionText, 'ef⚽gh'); + }); + it('should treat complex emojis and characters joined together as a word', () => { + // This emoji is the flag for England and is made up of: 1F3F4 E0067 E0062 E0065 E006E E0067 E007F + buffer.lines.set(0, stringArrayToRow([ + ' ', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'a', 'b', ' ', 'c', 'd', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', ' ', 'e', 'f', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'g', 'h', ' ', 'a' + ])); // The a is here to prevent the space being trimmed in selectionText + selectionManager.selectWordAt([0, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([1, 0]); + assert.equal(selectionManager.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); + selectionManager.selectWordAt([2, 0]); + assert.equal(selectionManager.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); + selectionManager.selectWordAt([3, 0]); + assert.equal(selectionManager.selectionText, '🏴󠁧󠁢󠁥󠁮󠁧󠁿ab'); + selectionManager.selectWordAt([4, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([5, 0]); + assert.equal(selectionManager.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); + selectionManager.selectWordAt([6, 0]); + assert.equal(selectionManager.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); + selectionManager.selectWordAt([7, 0]); + assert.equal(selectionManager.selectionText, 'cd🏴󠁧󠁢󠁥󠁮󠁧󠁿'); + selectionManager.selectWordAt([8, 0]); + assert.equal(selectionManager.selectionText, ' '); + selectionManager.selectWordAt([9, 0]); + assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + selectionManager.selectWordAt([10, 0]); + assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + selectionManager.selectWordAt([11, 0]); + assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + selectionManager.selectWordAt([12, 0]); + assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + selectionManager.selectWordAt([13, 0]); + assert.equal(selectionManager.selectionText, 'ef🏴󠁧󠁢󠁥󠁮󠁧󠁿gh'); + }); + }); }); describe('_selectLineAt', () => { diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 23fe729d..5dd652e0 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -10,8 +10,8 @@ import { CircularList } from './utils/CircularList'; import { EventEmitter } from './EventEmitter'; import { ITerminal, ICircularList, ISelectionManager, IBuffer } from './Interfaces'; import { SelectionModel } from './SelectionModel'; -import { LineData } from './Types'; -import { CHAR_DATA_WIDTH_INDEX } from './Buffer'; +import { LineData, CharData } from './Types'; +import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from './Buffer'; /** * The number of pixels the mouse needs to be above or below the viewport in @@ -281,6 +281,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager // Convert to 0-based coords[0]--; coords[1]--; + // Convert viewport coords to buffer coords coords[1] += this._terminal.buffer.ydisp; return coords; @@ -545,7 +546,14 @@ export class SelectionManager extends EventEmitter implements ISelectionManager for (let i = 0; coords[0] >= i; i++) { const char = bufferLine[i]; if (char[CHAR_DATA_WIDTH_INDEX] === 0) { + // Wide characters aren't included in the line string so decrement the + // index so the index is back on the wide character. charIndex--; + } else if (char[CHAR_DATA_CHAR_INDEX].length > 1 && coords[0] !== i) { + // Emojis take up multiple characters, so adjust accordingly. For these + // we don't want ot include the character at the column as we're + // returning the start index in the string, not the end index. + charIndex += char[CHAR_DATA_CHAR_INDEX].length - 1; } } return charIndex; @@ -572,13 +580,15 @@ export class SelectionManager extends EventEmitter implements ISelectionManager const line = this._buffer.translateBufferLineToString(coords[1], false); // Get actual index, taking into consideration wide characters - let endIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); - let startIndex = endIndex; + let startIndex = this._convertViewportColToCharacterIndex(bufferLine, coords); + let endIndex = startIndex; // Record offset to be used later const charOffset = coords[0] - startIndex; let leftWideCharCount = 0; let rightWideCharCount = 0; + let leftLongCharOffset = 0; + let rightLongCharOffset = 0; if (line.charAt(startIndex) === ' ') { // Expand until non-whitespace is hit @@ -595,6 +605,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager // character is hit, it is recorded and the column index is adjusted. let startCol = coords[0]; let endCol = coords[0]; + // Consider the initial position, skip it and increment the wide char // variable if (bufferLine[startCol][CHAR_DATA_WIDTH_INDEX] === 0) { @@ -605,29 +616,67 @@ export class SelectionManager extends EventEmitter implements ISelectionManager rightWideCharCount++; endCol++; } + + // Adjust the end index for characters whose length are > 1 (emojis) + if (bufferLine[endCol][CHAR_DATA_CHAR_INDEX].length > 1) { + rightLongCharOffset += bufferLine[endCol][CHAR_DATA_CHAR_INDEX].length - 1; + endIndex += bufferLine[endCol][CHAR_DATA_CHAR_INDEX].length - 1; + } + // Expand the string in both directions until a space is hit - while (startIndex > 0 && !this._isCharWordSeparator(line.charAt(startIndex - 1))) { - if (bufferLine[startCol - 1][CHAR_DATA_WIDTH_INDEX] === 0) { + while (startCol > 0 && startIndex > 0 && !this._isCharWordSeparator(bufferLine[startCol - 1])) { + const char = bufferLine[startCol - 1]; + if (char[CHAR_DATA_WIDTH_INDEX] === 0) { // If the next character is a wide char, record it and skip the column leftWideCharCount++; startCol--; + } else if (char[CHAR_DATA_CHAR_INDEX].length > 1) { + // If the next character's string is longer than 1 char (eg. emoji), + // adjust the index + leftLongCharOffset += char[CHAR_DATA_CHAR_INDEX].length - 1; + startIndex -= char[CHAR_DATA_CHAR_INDEX].length - 1; } startIndex--; startCol--; } - while (endIndex + 1 < line.length && !this._isCharWordSeparator(line.charAt(endIndex + 1))) { - if (bufferLine[endCol + 1][CHAR_DATA_WIDTH_INDEX] === 2) { + while (endCol < bufferLine.length && endIndex + 1 < line.length && !this._isCharWordSeparator(bufferLine[endCol + 1])) { + const char = bufferLine[endCol + 1]; + if (char[CHAR_DATA_WIDTH_INDEX] === 2) { // If the next character is a wide char, record it and skip the column rightWideCharCount++; endCol++; + } else if (char[CHAR_DATA_CHAR_INDEX].length > 1) { + // If the next character's string is longer than 1 char (eg. emoji), + // adjust the index + rightLongCharOffset += char[CHAR_DATA_CHAR_INDEX].length - 1; + endIndex += char[CHAR_DATA_CHAR_INDEX].length - 1; } endIndex++; endCol++; } } - const start = startIndex + charOffset - leftWideCharCount; - const length = Math.min(endIndex - startIndex + leftWideCharCount + rightWideCharCount + 1/*include endIndex char*/, this._terminal.cols); + // Incremenet the end index so it is at the start of the next character + endIndex++; + + // Calculate the start _column_, converting the the string indexes back to + // column coordinates. + const start = + startIndex // The index of the selection's start char in the line string + + charOffset // The difference between the initial char's column and index + - leftWideCharCount // The number of wide chars left of the initial char + + leftLongCharOffset; // The number of additional chars left of the initial char added by columns with strings longer than 1 (emojis) + + // Calculate the length in _columns_, converting the the string indexes back + // to column coordinates. + const length = Math.min(this._terminal.cols, // Disallow lengths larger than the terminal cols + endIndex // The index of the selection's end char in the line string + - startIndex // The index of the selection's start char in the line string + + leftWideCharCount // The number of wide chars left of the initial char + + rightWideCharCount // The number of wide chars right of the initial char (inclusive) + - leftLongCharOffset // The number of additional chars left of the initial char added by columns with strings longer than 1 (emojis) + - rightLongCharOffset); // The number of additional chars right of the initial char (inclusive) added by columns with strings longer than 1 (emojis) + return { start, length }; } @@ -659,8 +708,13 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * word logic. * @param char The character to check. */ - private _isCharWordSeparator(char: string): boolean { - return WORD_SEPARATORS.indexOf(char) >= 0; + private _isCharWordSeparator(charData: CharData): boolean { + // Zero width characters are never separators as they are always to the + // right of wide characters + if (charData[CHAR_DATA_WIDTH_INDEX] === 0) { + return false; + } + return WORD_SEPARATORS.indexOf(charData[CHAR_DATA_CHAR_INDEX]) >= 0; } /** diff --git a/src/SelectionModel.ts b/src/SelectionModel.ts index 1fbf40fe..5982b1e9 100644 --- a/src/SelectionModel.ts +++ b/src/SelectionModel.ts @@ -97,6 +97,9 @@ export class SelectionModel { public areSelectionValuesReversed(): boolean { const start = this.selectionStart; const end = this.selectionEnd; + if (!start || !end) { + return false; + } return start[1] > end[1] || (start[1] === end[1] && start[0] > end[0]); } diff --git a/src/Terminal.ts b/src/Terminal.ts index 8486f265..5ea01092 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -80,6 +80,7 @@ const DEFAULT_OPTIONS: ITerminalOptions = { fontFamily: 'courier-new, courier, monospace', fontSize: 15, lineHeight: 1.0, + letterSpacing: 0, scrollback: 1000, screenKeys: false, debug: false, @@ -410,6 +411,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.renderer.clear(); this.charMeasure.measure(this.options); break; + case 'letterSpacing': case 'lineHeight': // When the font changes the size of the cells may change which requires a renderer clear this.renderer.clear(); diff --git a/src/Viewport.ts b/src/Viewport.ts index 064076a2..d20749c3 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -47,7 +47,7 @@ export class Viewport implements IViewport { */ private refresh(): void { if (this.charMeasure.height > 0) { - this.currentRowHeight = this.terminal.renderer.dimensions.scaledLineHeight / window.devicePixelRatio; + this.currentRowHeight = this.terminal.renderer.dimensions.scaledCellHeight / window.devicePixelRatio; if (this.lastRecordedViewportHeight !== this.terminal.renderer.dimensions.canvasHeight) { this.lastRecordedViewportHeight = this.terminal.renderer.dimensions.canvasHeight; @@ -75,7 +75,7 @@ export class Viewport implements IViewport { this.refresh(); } else { // If size has changed, refresh viewport - if (this.terminal.renderer.dimensions.scaledLineHeight / window.devicePixelRatio !== this.currentRowHeight) { + if (this.terminal.renderer.dimensions.scaledCellHeight / window.devicePixelRatio !== this.currentRowHeight) { this.refresh(); } } diff --git a/src/addons/fit/fit.js b/src/addons/fit/fit.js index 3e61a7ef..c82039ab 100644 --- a/src/addons/fit/fit.js +++ b/src/addons/fit/fit.js @@ -56,7 +56,7 @@ exports.fit = function (term) { // Wrap fit in a setTimeout as charMeasure needs time to get initialized // after calling Terminal.open - setTimeout(() => { + setTimeout(function () { var geometry = exports.proposeGeometry(term); if (geometry) { diff --git a/src/input/MouseZoneManager.ts b/src/input/MouseZoneManager.ts index 8329d0b6..ebb91699 100644 --- a/src/input/MouseZoneManager.ts +++ b/src/input/MouseZoneManager.ts @@ -21,7 +21,6 @@ export class MouseZoneManager implements IMouseZoneManager { private _areZonesActive: boolean = false; private _mouseMoveListener: (e: MouseEvent) => any; - private _mouseDownListener: (e: MouseEvent) => any; private _clickListener: (e: MouseEvent) => any; private _tooltipTimeout: number = null; @@ -31,6 +30,8 @@ export class MouseZoneManager implements IMouseZoneManager { constructor( private _terminal: ITerminal ) { + this._terminal.element.addEventListener('mousedown', e => this._onMouseDown(e)); + // These events are expensive, only listen to it when mouse zones are active this._mouseMoveListener = e => this._onMouseMove(e); this._clickListener = e => this._onClick(e); @@ -140,11 +141,30 @@ export class MouseZoneManager implements IMouseZoneManager { } } + private _onMouseDown(e: MouseEvent): void { + // Ignore the event if there are no zones active + if (!this._areZonesActive) { + return; + } + + // Find the active zone, prevent event propagation if found to prevent other + // components from handling the mouse event. + const zone = this._findZoneEventAt(e); + if (zone) { + // TODO: When link modifier support is added, the event should only be + // cancelled when the modifier is held (see #1021) + e.preventDefault(); + e.stopImmediatePropagation(); + } + } + private _onClick(e: MouseEvent): void { + // Find the active zone and click it if found const zone = this._findZoneEventAt(e); if (zone) { zone.clickCallback(e); e.preventDefault(); + e.stopImmediatePropagation(); } } diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index d2a704a3..b2597eb7 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -10,14 +10,17 @@ import { CharData } from '../Types'; import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from '../Buffer'; export const INVERTED_DEFAULT_COLOR = -1; +const DIM_OPACITY = 0.5; export abstract class BaseRenderLayer implements IRenderLayer { private _canvas: HTMLCanvasElement; protected _ctx: CanvasRenderingContext2D; private _scaledCharWidth: number; private _scaledCharHeight: number; - private _scaledLineHeight: number; - private _scaledLineDrawY: number; + private _scaledCellWidth: number; + private _scaledCellHeight: number; + private _scaledCharLeft: number; + private _scaledCharTop: number; private _charAtlas: HTMLCanvasElement | ImageBitmap; @@ -31,7 +34,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { this._canvas = document.createElement('canvas'); this._canvas.id = `xterm-${id}-layer`; this._canvas.style.zIndex = zIndex.toString(); - this._ctx = this._canvas.getContext('2d', {_alpha}); + this._ctx = this._canvas.getContext('2d', {alpha: _alpha}); this._ctx.scale(window.devicePixelRatio, window.devicePixelRatio); // Draw the background if this is an opaque layer if (!_alpha) { @@ -70,10 +73,12 @@ export abstract class BaseRenderLayer implements IRenderLayer { } public resize(terminal: ITerminal, dim: IRenderDimensions, charSizeChanged: boolean): void { + this._scaledCellWidth = dim.scaledCellWidth; + this._scaledCellHeight = dim.scaledCellHeight; this._scaledCharWidth = dim.scaledCharWidth; this._scaledCharHeight = dim.scaledCharHeight; - this._scaledLineHeight = dim.scaledLineHeight; - this._scaledLineDrawY = dim.scaledLineDrawY; + this._scaledCharLeft = dim.scaledCharLeft; + this._scaledCharTop = dim.scaledCharTop; this._canvas.width = dim.scaledCanvasWidth; this._canvas.height = dim.scaledCanvasHeight; this._canvas.style.width = `${dim.canvasWidth}px`; @@ -100,10 +105,10 @@ export abstract class BaseRenderLayer implements IRenderLayer { */ protected fillCells(x: number, y: number, width: number, height: number): void { this._ctx.fillRect( - x * this._scaledCharWidth, - y * this._scaledLineHeight, - width * this._scaledCharWidth, - height * this._scaledLineHeight); + x * this._scaledCellWidth, + y * this._scaledCellHeight, + width * this._scaledCellWidth, + height * this._scaledCellHeight); } /** @@ -114,9 +119,9 @@ export abstract class BaseRenderLayer implements IRenderLayer { */ protected fillBottomLineAtCells(x: number, y: number, width: number = 1): void { this._ctx.fillRect( - x * this._scaledCharWidth, - (y + 1) * this._scaledLineHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, - width * this._scaledCharWidth, + x * this._scaledCellWidth, + (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, + width * this._scaledCellWidth, window.devicePixelRatio); } @@ -128,10 +133,10 @@ export abstract class BaseRenderLayer implements IRenderLayer { */ protected fillLeftLineAtCell(x: number, y: number): void { this._ctx.fillRect( - x * this._scaledCharWidth, - y * this._scaledLineHeight, + x * this._scaledCellWidth, + y * this._scaledCellHeight, window.devicePixelRatio, - this._scaledLineHeight); + this._scaledCellHeight); } /** @@ -143,10 +148,10 @@ export abstract class BaseRenderLayer implements IRenderLayer { protected strokeRectAtCell(x: number, y: number, width: number, height: number): void { this._ctx.lineWidth = window.devicePixelRatio; this._ctx.strokeRect( - x * this._scaledCharWidth + window.devicePixelRatio / 2, - y * this._scaledLineHeight + (window.devicePixelRatio / 2), - width * this._scaledCharWidth - window.devicePixelRatio, - (height * this._scaledLineHeight) - window.devicePixelRatio); + x * this._scaledCellWidth + window.devicePixelRatio / 2, + y * this._scaledCellHeight + (window.devicePixelRatio / 2), + width * this._scaledCellWidth - window.devicePixelRatio, + (height * this._scaledCellHeight) - window.devicePixelRatio); } /** @@ -171,17 +176,17 @@ export abstract class BaseRenderLayer implements IRenderLayer { protected clearCells(x: number, y: number, width: number, height: number): void { if (this._alpha) { this._ctx.clearRect( - x * this._scaledCharWidth, - y * this._scaledLineHeight, - width * this._scaledCharWidth, - height * this._scaledLineHeight); + x * this._scaledCellWidth, + y * this._scaledCellHeight, + width * this._scaledCellWidth, + height * this._scaledCellHeight); } else { this._ctx.fillStyle = this._colors.background; this._ctx.fillRect( - x * this._scaledCharWidth, - y * this._scaledLineHeight, - width * this._scaledCharWidth, - height * this._scaledLineHeight); + x * this._scaledCellWidth, + y * this._scaledCellHeight, + width * this._scaledCellWidth, + height * this._scaledCellHeight); } } @@ -198,15 +203,11 @@ export abstract class BaseRenderLayer implements IRenderLayer { protected fillCharTrueColor(terminal: ITerminal, charData: CharData, x: number, y: number): void { this._ctx.font = `${terminal.options.fontSize * window.devicePixelRatio}px ${terminal.options.fontFamily}`; this._ctx.textBaseline = 'top'; - - // Since uncached characters are not coming off the char atlas with source - // coordinates, it means that text drawn to the canvas (particularly '_') - // can bleed into other cells. This code will clip the following fillText, - // ensuring that its contents don't go beyond the cell bounds. - this._ctx.beginPath(); - this._ctx.rect(x * this._scaledCharWidth, y * this._scaledLineHeight + this._scaledLineDrawY, charData[CHAR_DATA_WIDTH_INDEX] * this._scaledCharWidth, this._scaledCharHeight); - this._ctx.clip(); - this._ctx.fillText(charData[CHAR_DATA_CHAR_INDEX], x * this._scaledCharWidth, y * this._scaledCharHeight); + this._clipRow(terminal, y); + this._ctx.fillText( + charData[CHAR_DATA_CHAR_INDEX], + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop); } /** @@ -223,12 +224,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { * This is used to validate whether a cached image can be used. * @param bold Whether the text is bold. */ - protected drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bg: number, bold: boolean): void { - // Clear the cell next to this character if it's wide - if (width === 2) { - this.clearCells(x + 1, y, 1, 1); - } - + protected drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bg: number, bold: boolean, dim: boolean): void { let colorIndex = 0; if (fg < 256) { colorIndex = fg + 2; @@ -239,18 +235,31 @@ export abstract class BaseRenderLayer implements IRenderLayer { } } const isAscii = code < 256; - const isBasicColor = (colorIndex > 1 && fg < 16); + // A color is basic if it is one of the standard normal or bold weight + // colors of the characters held in the char atlas. Note that this excludes + // the normal weight light color characters + const isBasicColor = (colorIndex > 1 && fg < 16) && (fg < 8 || bold); const isDefaultColor = fg >= 256; const isDefaultBackground = bg >= 256; if (this._charAtlas && isAscii && (isBasicColor || isDefaultColor) && isDefaultBackground) { // ImageBitmap's draw about twice as fast as from a canvas const charAtlasCellWidth = this._scaledCharWidth + CHAR_ATLAS_CELL_SPACING; const charAtlasCellHeight = this._scaledCharHeight + CHAR_ATLAS_CELL_SPACING; + // Apply alpha to dim the character + if (dim) { + this._ctx.globalAlpha = DIM_OPACITY; + } this._ctx.drawImage(this._charAtlas, - code * charAtlasCellWidth, colorIndex * charAtlasCellHeight, this._scaledCharWidth, this._scaledCharHeight, - x * this._scaledCharWidth, y * this._scaledLineHeight + this._scaledLineDrawY, this._scaledCharWidth, this._scaledCharHeight); + code * charAtlasCellWidth, + colorIndex * charAtlasCellHeight, + charAtlasCellWidth, + this._scaledCharHeight, + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop, + charAtlasCellWidth, + this._scaledCharHeight); } else { - this._drawUncachedChar(terminal, char, width, fg, x, y, bold); + this._drawUncachedChar(terminal, char, width, fg, x, y, bold, dim); } // This draws the atlas (for debugging purposes) // this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); @@ -268,7 +277,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param x The column to draw at. * @param y The row to draw at. */ - private _drawUncachedChar(terminal: ITerminal, char: string, width: number, fg: number, x: number, y: number, bold: boolean): void { + private _drawUncachedChar(terminal: ITerminal, char: string, width: number, fg: number, x: number, y: number, bold: boolean, dim: boolean): void { this._ctx.save(); this._ctx.font = `${terminal.options.fontSize * window.devicePixelRatio}px ${terminal.options.fontFamily}`; if (bold) { @@ -285,17 +294,33 @@ export abstract class BaseRenderLayer implements IRenderLayer { this._ctx.fillStyle = this._colors.foreground; } - // Since uncached characters are not coming off the char atlas with source - // coordinates, it means that text drawn to the canvas (particularly '_') - // can bleed into other cells. This code will clip the following fillText, - // ensuring that its contents don't go beyond the cell bounds. - this._ctx.beginPath(); - this._ctx.rect(x * this._scaledCharWidth, y * this._scaledLineHeight + this._scaledLineDrawY, width * this._scaledCharWidth, this._scaledCharHeight); - this._ctx.clip(); + this._clipRow(terminal, y); + // Apply alpha to dim the character + if (dim) { + this._ctx.globalAlpha = DIM_OPACITY; + } // Draw the character - this._ctx.fillText(char, x * this._scaledCharWidth, y * this._scaledLineHeight + this._scaledLineDrawY); + this._ctx.fillText( + char, + x * this._scaledCellWidth + this._scaledCharLeft, + y * this._scaledCellHeight + this._scaledCharTop); this._ctx.restore(); } + + /** + * Clips a row to ensure no pixels will be drawn outside the cells in the row. + * @param terminal The terminal. + * @param y The row to clip. + */ + private _clipRow(terminal: ITerminal, y: number): void { + this._ctx.beginPath(); + this._ctx.rect( + 0, + y * this._scaledCellHeight, + terminal.cols * this._scaledCellWidth, + this._scaledCellHeight); + this._ctx.clip(); + } } diff --git a/src/renderer/CharAtlas.ts b/src/renderer/CharAtlas.ts index 6808f85e..9ac1e161 100644 --- a/src/renderer/CharAtlas.ts +++ b/src/renderer/CharAtlas.ts @@ -142,13 +142,23 @@ class CharAtlasGenerator { // Default color for (let i = 0; i < 256; i++) { + this._ctx.save(); + this._ctx.beginPath(); + this._ctx.rect(i * cellWidth, 0, cellWidth, cellHeight); + this._ctx.clip(); this._ctx.fillText(String.fromCharCode(i), i * cellWidth, 0); + this._ctx.restore(); } // Default color bold this._ctx.save(); this._ctx.font = `bold ${this._ctx.font}`; for (let i = 0; i < 256; i++) { + this._ctx.save(); + this._ctx.beginPath(); + this._ctx.rect(i * cellWidth, cellHeight, cellWidth, cellHeight); + this._ctx.clip(); this._ctx.fillText(String.fromCharCode(i), i * cellWidth, cellHeight); + this._ctx.restore(); } this._ctx.restore(); @@ -162,14 +172,17 @@ class CharAtlasGenerator { const y = (colorIndex + 2) * cellHeight; // Draw ascii characters for (let i = 0; i < 256; i++) { + this._ctx.save(); + this._ctx.beginPath(); + this._ctx.rect(i * cellWidth, y, cellWidth, cellHeight); + this._ctx.clip(); this._ctx.fillStyle = ansiColors[colorIndex]; this._ctx.fillText(String.fromCharCode(i), i * cellWidth, y); + this._ctx.restore(); } } this._ctx.restore(); - const charAtlasImageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height); - // Support is patchy for createImageBitmap at the moment, pass a canvas back // if support is lacking as drawImage works there too. Firefox is also // included here as ImageBitmap appears both buggy and has horrible @@ -183,9 +196,27 @@ class CharAtlasGenerator { return result; } + const charAtlasImageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height); + + // Remove the background color from the image so characters may overlap + const r = parseInt(background.substr(1, 2), 16); + const g = parseInt(background.substr(3, 2), 16); + const b = parseInt(background.substr(5, 2), 16); + this._clearColor(charAtlasImageData, r, g, b); + const promise = window.createImageBitmap(charAtlasImageData); // Clear the rect while the promise is in progress this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); return promise; } + + private _clearColor(imageData: ImageData, r: number, g: number, b: number): void { + for (let offset = 0; offset < imageData.data.length; offset += 4) { + if (imageData.data[offset] === r && + imageData.data[offset + 1] === g && + imageData.data[offset + 2] === b) { + imageData.data[offset + 3] = 0; + } + } + } } diff --git a/src/renderer/ColorManager.ts b/src/renderer/ColorManager.ts index ea5f1cb0..80daf31f 100644 --- a/src/renderer/ColorManager.ts +++ b/src/renderer/ColorManager.ts @@ -86,7 +86,7 @@ export class ColorManager implements IColorManager { */ public setTheme(theme: ITheme): void { this.colors.foreground = theme.foreground || DEFAULT_FOREGROUND; - this.colors.background = theme.background || DEFAULT_BACKGROUND; + this.colors.background = this._validateColor(theme.background, DEFAULT_BACKGROUND); this.colors.cursor = theme.cursor || DEFAULT_CURSOR; this.colors.cursorAccent = theme.cursorAccent || DEFAULT_CURSOR_ACCENT; this.colors.selection = theme.selection || DEFAULT_SELECTION; @@ -107,4 +107,17 @@ export class ColorManager implements IColorManager { this.colors.ansi[14] = theme.brightCyan || DEFAULT_ANSI_COLORS[14]; this.colors.ansi[15] = theme.brightWhite || DEFAULT_ANSI_COLORS[15]; } + + private _validateColor(color: string, fallback: string): string { + if (color.length === 7 && color.charAt(0) === '#') { + return color; + } + if (color.length === 4 && color.charAt(0) === '#') { + const r = color.charAt(1); + const g = color.charAt(2); + const b = color.charAt(3); + return `#${r}${r}${g}${g}${b}${b}`; + } + return fallback; + } } diff --git a/src/renderer/CursorRenderLayer.ts b/src/renderer/CursorRenderLayer.ts index e57c326d..689d6914 100644 --- a/src/renderer/CursorRenderLayer.ts +++ b/src/renderer/CursorRenderLayer.ts @@ -131,6 +131,9 @@ export class CursorRenderLayer extends BaseRenderLayer { } const charData = terminal.buffer.lines.get(cursorY)[terminal.buffer.x]; + if (!charData) { + return; + } if (!terminal.isFocused) { this._clearCursor(); diff --git a/src/renderer/Interfaces.ts b/src/renderer/Interfaces.ts index 74400b8c..be1b39dd 100644 --- a/src/renderer/Interfaces.ts +++ b/src/renderer/Interfaces.ts @@ -86,8 +86,10 @@ export interface IColorSet { export interface IRenderDimensions { scaledCharWidth: number; scaledCharHeight: number; - scaledLineHeight: number; - scaledLineDrawY: number; + scaledCellWidth: number; + scaledCellHeight: number; + scaledCharLeft: number; + scaledCharTop: number; scaledCanvasWidth: number; scaledCanvasHeight: number; canvasWidth: number; diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index 168d6dc8..ea373e5e 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -40,8 +40,10 @@ export class Renderer extends EventEmitter implements IRenderer { this.dimensions = { scaledCharWidth: null, scaledCharHeight: null, - scaledLineHeight: null, - scaledLineDrawY: null, + scaledCellWidth: null, + scaledCellHeight: null, + scaledCharLeft: null, + scaledCharTop: null, scaledCanvasWidth: null, scaledCanvasHeight: null, canvasWidth: null, @@ -91,20 +93,27 @@ export class Renderer extends EventEmitter implements IRenderer { // enough space to draw the character to the cell. this.dimensions.scaledCharHeight = Math.ceil(this._terminal.charMeasure.height * window.devicePixelRatio); - // Calculate the scaled line height, if lineHeight is not 1 then the value + // Calculate the scaled cell height, if lineHeight is not 1 then the value // will be floored because since lineHeight can never be lower then 1, there // is a guarentee that the scaled line height will always be larger than // scaled char height. - this.dimensions.scaledLineHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.options.lineHeight); + this.dimensions.scaledCellHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.options.lineHeight); // Calculate the y coordinate within a cell that text should draw from in // order to draw in the center of a cell. - this.dimensions.scaledLineDrawY = this._terminal.options.lineHeight === 1 ? 0 : Math.round((this.dimensions.scaledLineHeight - this.dimensions.scaledCharHeight) / 2); + this.dimensions.scaledCharTop = this._terminal.options.lineHeight === 1 ? 0 : Math.round((this.dimensions.scaledCellHeight - this.dimensions.scaledCharHeight) / 2); + + // Calculate the scaled cell width, taking the letterSpacing into account. + this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth + Math.round(this._terminal.options.letterSpacing); + + // Calculate the x coordinate with a cell that text should draw from in + // order to draw in the center of a cell. + this.dimensions.scaledCharLeft = Math.floor(this._terminal.options.letterSpacing / 2); // Recalculate the canvas dimensions; scaled* define the actual number of // pixel in the canvas - this.dimensions.scaledCanvasHeight = this._terminal.rows * this.dimensions.scaledLineHeight; - this.dimensions.scaledCanvasWidth = this._terminal.cols * this.dimensions.scaledCharWidth; + this.dimensions.scaledCanvasHeight = this._terminal.rows * this.dimensions.scaledCellHeight; + this.dimensions.scaledCanvasWidth = this._terminal.cols * this.dimensions.scaledCellWidth; // The the size of the canvas on the page. It's very important that this // rounds to nearest integer and not ceils as browsers often set diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index e319efe7..2ecb6ec6 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -59,6 +59,11 @@ export class TextRenderLayer extends BaseRenderLayer { const row = y + terminal.buffer.ydisp; const line = terminal.buffer.lines.get(row); + this.clearCells(0, y, terminal.cols, 1); + // for (let x = 0; x < terminal.cols; x++) { + // this._state.cache[x][y] = null; + // } + for (let x = 0; x < terminal.cols; x++) { const charData = line[x]; const code: number = charData[CHAR_DATA_CODE_INDEX]; @@ -69,7 +74,7 @@ export class TextRenderLayer extends BaseRenderLayer { // The character to the left is a wide character, drawing is owned by // the char at x-1 if (width === 0) { - this._state.cache[x][y] = null; + // this._state.cache[x][y] = null; continue; } @@ -86,19 +91,19 @@ export class TextRenderLayer extends BaseRenderLayer { } // Skip rendering if the character is identical - const state = this._state.cache[x][y]; - if (state && state[CHAR_DATA_CHAR_INDEX] === char && state[CHAR_DATA_ATTR_INDEX] === attr) { - // Skip render, contents are identical - this._state.cache[x][y] = charData; - continue; - } + // const state = this._state.cache[x][y]; + // if (state && state[CHAR_DATA_CHAR_INDEX] === char && state[CHAR_DATA_ATTR_INDEX] === attr) { + // // Skip render, contents are identical + // this._state.cache[x][y] = charData; + // continue; + // } // Clear the old character was not a space with the default background - const wasInverted = !!(state && state[CHAR_DATA_ATTR_INDEX] && state[CHAR_DATA_ATTR_INDEX] >> 18 & FLAGS.INVERSE); - if (state && !(state[CHAR_DATA_CODE_INDEX] === 32 /*' '*/ && (state[CHAR_DATA_ATTR_INDEX] & 0x1ff) >= 256 && !wasInverted)) { - this._clearChar(x, y); - } - this._state.cache[x][y] = charData; + // const wasInverted = !!(state && state[CHAR_DATA_ATTR_INDEX] && state[CHAR_DATA_ATTR_INDEX] >> 18 & FLAGS.INVERSE); + // if (state && !(state[CHAR_DATA_CODE_INDEX] === 32 /*' '*/ && (state[CHAR_DATA_ATTR_INDEX] & 0x1ff) >= 256 && !wasInverted)) { + // this._clearChar(x, y); + // } + // this._state.cache[x][y] = charData; const flags = attr >> 18; let bg = attr & 0x1ff; @@ -120,14 +125,14 @@ export class TextRenderLayer extends BaseRenderLayer { // space is added. Without this, the first half of `b` would never // get removed, and `a` would not re-render because it thinks it's // already in the correct state. - this._state.cache[x][y] = OVERLAP_OWNED_CHAR_DATA; - if (x < line.length && line[x + 1][CHAR_DATA_CODE_INDEX] === 32 /*' '*/) { + // this._state.cache[x][y] = OVERLAP_OWNED_CHAR_DATA; + if (x < line.length - 1 && line[x + 1][CHAR_DATA_CODE_INDEX] === 32 /*' '*/) { width = 2; - this._clearChar(x + 1, y); + // this._clearChar(x + 1, y); // The overlapping char's char data will force a clear and render when the // overlapping char is no longer to the left of the character and also when // the space changes to another character. - this._state.cache[x + 1][y] = OVERLAP_OWNED_CHAR_DATA; + // this._state.cache[x + 1][y] = OVERLAP_OWNED_CHAR_DATA; } } @@ -146,6 +151,11 @@ export class TextRenderLayer extends BaseRenderLayer { } } + // Clear the cell next to this character if it's wide + if (width === 2) { + // this.clearCells(x + 1, y, 1, 1); + } + // Draw background if (bg < 256) { this._ctx.save(); @@ -175,7 +185,7 @@ export class TextRenderLayer extends BaseRenderLayer { this.fillBottomLineAtCells(x, y); } - this.drawChar(terminal, char, code, width, x, y, fg, bg, !!(flags & FLAGS.BOLD)); + this.drawChar(terminal, char, code, width, x, y, fg, bg, !!(flags & FLAGS.BOLD), !!(flags & FLAGS.DIM)); this._ctx.restore(); } @@ -183,10 +193,15 @@ export class TextRenderLayer extends BaseRenderLayer { } /** - * Whether a character is overlapping to the - * next cell. + * Whether a character is overlapping to the next cell. */ private _isOverlapping(charData: CharData): boolean { + // Only single cell characters can be overlapping, rendering issues can + // occur without this check + if (charData[CHAR_DATA_WIDTH_INDEX] !== 1) { + return false; + } + // We assume that any ascii character will not overlap const code = charData[CHAR_DATA_CODE_INDEX]; if (code < 256) { diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts index b1a930f9..31705a3f 100644 --- a/src/renderer/Types.ts +++ b/src/renderer/Types.ts @@ -11,5 +11,6 @@ export enum FLAGS { UNDERLINE = 2, BLINK = 4, INVERSE = 8, - INVISIBLE = 16 + INVISIBLE = 16, + DIM = 32 }; diff --git a/src/utils/CharMeasure.ts b/src/utils/CharMeasure.ts index e5b03f87..779ecbe8 100644 --- a/src/utils/CharMeasure.ts +++ b/src/utils/CharMeasure.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { EventEmitter } from '../EventEmitter.js'; +import { EventEmitter } from '../EventEmitter'; import { ICharMeasure, ITerminal, ITerminalOptions } from '../Interfaces'; /** diff --git a/src/utils/MouseHelper.ts b/src/utils/MouseHelper.ts index 75ea675c..b7ef09ad 100644 --- a/src/utils/MouseHelper.ts +++ b/src/utils/MouseHelper.ts @@ -15,6 +15,7 @@ export class MouseHelper { return null; } + const originalElement = element; let x = event.pageX; let y = event.pageY; @@ -25,6 +26,12 @@ export class MouseHelper { y -= element.offsetTop; element = 'offsetParent' in element ? element.offsetParent : element.parentElement; } + element = originalElement; + while (element && element !== element.ownerDocument.body) { + x += element.scrollLeft; + y += element.scrollTop; + element = element.parentElement; + } return [x, y]; } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index de2a261b..36176f8b 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -51,6 +51,11 @@ interface ITerminalOptions { */ fontFamily?: string; + /** + * The spacing in whole pixels between characters.. + */ + letterSpacing?: number; + /** * The line height used to render text. */ @@ -407,7 +412,7 @@ declare module 'xterm' { * Retrieves an option's value from the terminal. * @param key The option key. */ - getOption(key: 'cols' | 'fontSize' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback'): number; + getOption(key: 'cols' | 'fontSize' | 'letterSpacing' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback'): number; /** * Retrieves an option's value from the terminal. * @param key The option key. @@ -459,7 +464,7 @@ declare module 'xterm' { * @param key The option key. * @param value The option value. */ - setOption(key: 'cols' | 'fontSize' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback', value: number): void; + setOption(key: 'cols' | 'fontSize' | 'letterSpacing' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback', value: number): void; /** * Sets an option on the terminal. * @param key The option key.