diff --git a/demo/client.ts b/demo/client.ts index 5c477ae7ec..91cebf07cd 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -102,7 +102,7 @@ function createTerminal(): void { e.preventDefault(); const searchOptions = { regex: (document.getElementById('regex') as HTMLInputElement).checked, - wholeWord: false, + wholeWord: (document.getElementById('whole-word') as HTMLInputElement).checked, caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked }; term.findNext(actionElements.findNext.value, searchOptions); @@ -113,7 +113,7 @@ function createTerminal(): void { e.preventDefault(); const searchOptions = { regex: (document.getElementById('regex') as HTMLInputElement).checked, - wholeWord: false, + wholeWord: (document.getElementById('whole-word') as HTMLInputElement).checked, caseSensitive: (document.getElementById('case-sensitive') as HTMLInputElement).checked }; term.findPrevious(actionElements.findPrevious.value, searchOptions); diff --git a/demo/index.html b/demo/index.html index 553b9a8b22..12f7cb9835 100644 --- a/demo/index.html +++ b/demo/index.html @@ -18,6 +18,7 @@

Actions

+

diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index 6f417f8eb5..32492ee0a5 100644 --- a/src/Buffer.test.ts +++ b/src/Buffer.test.ts @@ -5,9 +5,9 @@ import { assert } from 'chai'; import { ITerminal } from './Types'; -import { Buffer, DEFAULT_ATTR } from './Buffer'; +import { Buffer, DEFAULT_ATTR, CHAR_DATA_CHAR_INDEX } from './Buffer'; import { CircularList } from './common/CircularList'; -import { MockTerminal } from './utils/TestUtils.test'; +import { MockTerminal, TestTerminal } from './utils/TestUtils.test'; import { BufferLine } from './BufferLine'; const INIT_COLS = 80; @@ -347,4 +347,171 @@ describe('Buffer', () => { assert.equal(str3, '😁a'); }); }); + describe('stringIndexToBufferIndex', () => { + let terminal: TestTerminal; + + beforeEach(() => { + terminal = new TestTerminal({rows: 5, cols: 10}); + }); + + it('multiline ascii', () => { + const input = 'This is ASCII text spanning multiple lines.'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + for (let i = 0; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([(i / terminal.cols) | 0, i % terminal.cols], bufferIndex); + } + }); + + it('combining e\u0301 in a sentence', () => { + const input = 'Sitting in the cafe\u0301 drinking coffee.'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + for (let i = 0; i < 19; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([(i / terminal.cols) | 0, i % terminal.cols], bufferIndex); + } + // string index 18 & 19 point to combining char e\u0301 ---> same buffer Index + assert.deepEqual( + terminal.buffer.stringIndexToBufferIndex(0, 18), + terminal.buffer.stringIndexToBufferIndex(0, 19)); + // after the combining char every string index has an offset of -1 + for (let i = 19; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([((i - 1) / terminal.cols) | 0, (i - 1) % terminal.cols], bufferIndex); + } + }); + + it('multiline combining e\u0301', () => { + const input = 'e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + // every buffer cell index contains 2 string indices + for (let i = 0; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([((i >> 1) / terminal.cols) | 0, (i >> 1) % terminal.cols], bufferIndex); + } + }); + + it('surrogate char in a sentence', () => { + const input = 'The 𝄞 is a clef widely used in modern notation.'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + for (let i = 0; i < 5; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([(i / terminal.cols) | 0, i % terminal.cols], bufferIndex); + } + // string index 4 & 5 point to surrogate char 𝄞 ---> same buffer Index + assert.deepEqual( + terminal.buffer.stringIndexToBufferIndex(0, 4), + terminal.buffer.stringIndexToBufferIndex(0, 5)); + // after the combining char every string index has an offset of -1 + for (let i = 5; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([((i - 1) / terminal.cols) | 0, (i - 1) % terminal.cols], bufferIndex); + } + }); + + it('multiline surrogate char', () => { + const input = '𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + // every buffer cell index contains 2 string indices + for (let i = 0; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([((i >> 1) / terminal.cols) | 0, (i >> 1) % terminal.cols], bufferIndex); + } + }); + + it('surrogate char with combining', () => { + // eye of Ra with acute accent - string length of 3 + const input = '𓂀\u0301 - the eye hiroglyph with an acute accent.'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + // index 0..2 should map to 0 + assert.deepEqual([0, 0], terminal.buffer.stringIndexToBufferIndex(0, 1)); + assert.deepEqual([0, 0], terminal.buffer.stringIndexToBufferIndex(0, 2)); + for (let i = 2; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([((i - 2) / terminal.cols) | 0, (i - 2) % terminal.cols], bufferIndex); + } + }); + + it('multiline surrogate with combining', () => { + const input = '𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + // every buffer cell index contains 3 string indices + for (let i = 0; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([(((i / 3) | 0) / terminal.cols) | 0, ((i / 3) | 0) % terminal.cols], bufferIndex); + } + }); + + it('fullwidth chars', () => { + const input = 'These 123 are some fat numbers.'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + for (let i = 0; i < 6; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([(i / terminal.cols) | 0, i % terminal.cols], bufferIndex); + } + // string index 6, 7, 8 take 2 cells + assert.deepEqual([0, 8], terminal.buffer.stringIndexToBufferIndex(0, 7)); + assert.deepEqual([1, 0], terminal.buffer.stringIndexToBufferIndex(0, 8)); + // rest of the string has offset of +3 + for (let i = 9; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([((i + 3) / terminal.cols) | 0, (i + 3) % terminal.cols], bufferIndex); + } + }); + + it('multiline fullwidth chars', () => { + const input = '12345678901234567890'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + for (let i = 9; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i); + assert.deepEqual([((i << 1) / terminal.cols) | 0, (i << 1) % terminal.cols], bufferIndex); + } + }); + + it('fullwidth combining with emoji - match emoji cell', () => { + const input = 'Lots of ¥\u0301 make me 😃.'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + const stringIndex = s.match(/😃/).index; + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, stringIndex); + assert(terminal.buffer.lines.get(bufferIndex[0]).get(bufferIndex[1])[CHAR_DATA_CHAR_INDEX], '😃'); + }); + + it('multiline fullwidth chars with offset 1 (currently tests for broken behavior)', () => { + const input = 'a12345678901234567890'; + // the 'a' at the beginning moves all fullwidth chars one to the right + // now the end of the line contains a dangling empty cell since + // the next fullwidth char has to wrap early + // the dangling last cell is wrongly added in the string + // --> fixable after resolving #1685 + terminal.writeSync(input); + // TODO: reenable after fix + // const s = terminal.buffer.contents(true).toArray()[0]; + // assert.equal(input, s); + for (let i = 10; i < input.length; ++i) { + const bufferIndex = terminal.buffer.stringIndexToBufferIndex(0, i + 1); // TODO: remove +1 after fix + const j = (i - 0) << 1; + assert.deepEqual([(j / terminal.cols) | 0, j % terminal.cols], bufferIndex); + } + }); + }); }); diff --git a/src/Buffer.ts b/src/Buffer.ts index 9487be4a67..29b034ac6c 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -4,7 +4,7 @@ */ import { CircularList } from './common/CircularList'; -import { CharData, ITerminal, IBuffer, IBufferLine } from './Types'; +import { CharData, ITerminal, IBuffer, IBufferLine, BufferIndex, IBufferStringIterator, IBufferStringIteratorResult } from './Types'; import { EventEmitter } from './common/EventEmitter'; import { IMarker } from 'xterm'; import { BufferLine } from './BufferLine'; @@ -194,6 +194,36 @@ export class Buffer implements IBuffer { this.scrollBottom = newRows - 1; } + /** + * Translates a string index back to a BufferIndex. + * To get the correct buffer position the string must start at `startCol` 0 + * (default in translateBufferLineToString). + * The method also works on wrapped line strings given rows were not trimmed. + * The method operates on the CharData string length, there are no + * additional content or boundary checks. Therefore the string and the buffer + * should not be altered in between. + * TODO: respect trim flag after fixing #1685 + * @param lineIndex line index the string was retrieved from + * @param stringIndex index within the string + * @param startCol column offset the string was retrieved from + */ + public stringIndexToBufferIndex(lineIndex: number, stringIndex: number): BufferIndex { + while (stringIndex) { + const line = this.lines.get(lineIndex); + if (!line) { + [-1, -1]; + } + for (let i = 0; i < line.length; ++i) { + stringIndex -= line.get(i)[CHAR_DATA_CHAR_INDEX].length; + if (stringIndex < 0) { + return [lineIndex, i]; + } + } + lineIndex++; + } + return [lineIndex, 0]; + } + /** * Translates a buffer line to a string, with optional start and end columns. * Wide characters will count as two columns in the resulting string. This @@ -340,6 +370,10 @@ export class Buffer implements IBuffer { // TODO: This could probably be optimized by relying on sort order and trimming the array using .length this.markers.splice(this.markers.indexOf(marker), 1); } + + public iterator(trimRight: boolean, startIndex?: number, endIndex?: number): IBufferStringIterator { + return new BufferStringIterator(this, trimRight, startIndex, endIndex); + } } export class Marker extends EventEmitter implements IMarker { @@ -366,3 +400,31 @@ export class Marker extends EventEmitter implements IMarker { super.dispose(); } } + +export class BufferStringIterator implements IBufferStringIterator { + private _current: number; + + constructor ( + private _buffer: IBuffer, + private _trimRight: boolean, + private _startIndex: number = 0, + private _endIndex: number = _buffer.lines.length + ) { + this._current = this._startIndex; + } + + public hasNext(): boolean { + return this._current < this._endIndex; + } + + public next(): IBufferStringIteratorResult { + const range = this._buffer.getWrappedRangeForLine(this._current); + let result = ''; + for (let i = range.first; i <= range.last; ++i) { + // TODO: always apply trimRight after fixing #1685 + result += this._buffer.translateBufferLineToString(i, (this._trimRight) ? i === range.last : false); + } + this._current = range.last + 1; + return {range: range, content: result}; + } +} diff --git a/src/CharWidth.test.ts b/src/CharWidth.test.ts new file mode 100644 index 0000000000..3242dd7732 --- /dev/null +++ b/src/CharWidth.test.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { TestTerminal } from './utils/TestUtils.test'; +import { assert } from 'chai'; +import { getStringCellWidth } from './CharWidth'; +import { IBuffer } from './Types'; +import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from './Buffer'; + + +describe('getStringCellWidth', function(): void { + let terminal: TestTerminal; + + beforeEach(() => { + terminal = new TestTerminal({rows: 5, cols: 30}); + }); + + function sumWidths(buffer: IBuffer, start: number, end: number, sentinel: string): number { + let result = 0; + for (let i = start; i < end; ++i) { + const line = buffer.lines.get(i); + for (let j = 0; j < line.length; ++j) { // TODO: change to trimBorder with multiline + const ch = line.get(j); + result += ch[CHAR_DATA_WIDTH_INDEX]; + // return on sentinel + if (ch[CHAR_DATA_CHAR_INDEX] === sentinel) { + return result; + } + } + } + return result; + } + + it('ASCII chars', function(): void { + const input = 'This is just ASCII text.#'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + assert.equal(getStringCellWidth(s), sumWidths(terminal.buffer, 0, 1, '#')); + }); + it('combining chars', function(): void { + const input = 'e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301e\u0301#'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + assert.equal(getStringCellWidth(s), sumWidths(terminal.buffer, 0, 1, '#')); + }); + it('surrogate chars', function(): void { + const input = '𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞𝄞#'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + assert.equal(getStringCellWidth(s), sumWidths(terminal.buffer, 0, 1, '#')); + }); + it('surrogate combining chars', function(): void { + const input = '𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301𓂀\u0301#'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + assert.equal(getStringCellWidth(s), sumWidths(terminal.buffer, 0, 1, '#')); + }); + it('fullwidth chars', function(): void { + const input = '1234567890#'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + assert.equal(getStringCellWidth(s), sumWidths(terminal.buffer, 0, 1, '#')); + }); + it('fullwidth chars offset 1', function(): void { + const input = 'a1234567890#'; + terminal.writeSync(input); + const s = terminal.buffer.iterator(true).next().content; + assert.equal(input, s); + assert.equal(getStringCellWidth(s), sumWidths(terminal.buffer, 0, 1, '#')); + }); + // TODO: multiline tests once #1685 is resolved +}); diff --git a/src/CharWidth.ts b/src/CharWidth.ts index 6e4aa37fbd..d54e939242 100644 --- a/src/CharWidth.ts +++ b/src/CharWidth.ts @@ -169,3 +169,25 @@ export const wcwidth = (function(opts: {nul: number, control: number}): (ucs: nu 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; + for (let i = 0; i < s.length; ++i) { + let code = s.charCodeAt(i); + if (0xD800 <= code && code <= 0xDBFF) { + const low = s.charCodeAt(i + 1); + if (isNaN(low)) { + return result; + } + code = ((code - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000; + } + if (0xDC00 <= code && code <= 0xDFFF) { + continue; + } + result += wcwidth(code); + } + return result; +} diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index eeaaeedcc1..35610acb01 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -7,7 +7,7 @@ import { assert } from 'chai'; import { IMouseZoneManager, IMouseZone } from './ui/Types'; import { ILinkMatcher, ITerminal, IBufferLine } from './Types'; import { Linkifier } from './Linkifier'; -import { MockBuffer, MockTerminal } from './utils/TestUtils.test'; +import { MockBuffer, MockTerminal, TestTerminal } from './utils/TestUtils.test'; import { CircularList } from './common/CircularList'; import { BufferLine } from './BufferLine'; @@ -238,4 +238,98 @@ describe('Linkifier', () => { }); }); }); + describe('unicode handling', () => { + let terminal: TestTerminal; + + // other than the tests above unicode testing needs the full terminal instance + // 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); + mouseZoneManager = new TestMouseZoneManager(); + linkifier.attachToDom(mouseZoneManager); + }); + + function assertLinkifiesInTerminal(rowText: string, linkMatcherRegex: RegExp, links: {x1: number, y1: number, x2: number, y2: number}[], done: MochaDone): void { + terminal.writeSync(rowText); + linkifier.registerLinkMatcher(linkMatcherRegex, () => {}); + linkifier.linkifyRows(); + // Allow linkify to happen + setTimeout(() => { + assert.equal(mouseZoneManager.zones.length, links.length); + links.forEach((l, i) => { + assert.equal(mouseZoneManager.zones[i].x1, l.x1 + 1); + assert.equal(mouseZoneManager.zones[i].x2, l.x2 + 1); + assert.equal(mouseZoneManager.zones[i].y1, l.y1 + 1); + assert.equal(mouseZoneManager.zones[i].y2, l.y2 + 1); + }); + done(); + }, 0); + } + + describe('unicode before the match', () => { + it('combining - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('e\u0301e\u0301e\u0301 foo', /foo/, [{x1: 4, x2: 7, y1: 0, y2: 0}], done); + }); + it('combining - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('e\u0301e\u0301e\u0301 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}], done); + }); + it('surrogate - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('𝄞𝄞𝄞 foo', /foo/, [{x1: 4, x2: 7, y1: 0, y2: 0}], done); + }); + it('surrogate - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('𝄞𝄞𝄞 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}], done); + }); + it('combining surrogate - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('𓂀\u0301𓂀\u0301𓂀\u0301 foo', /foo/, [{x1: 4, x2: 7, y1: 0, y2: 0}], done); + }); + it('combining surrogate - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('𓂀\u0301𓂀\u0301𓂀\u0301 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}], done); + }); + it('fullwidth - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('12 foo', /foo/, [{x1: 5, x2: 8, y1: 0, y2: 0}], done); + }); + it('fullwidth - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('12 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}], done); + }); + it('combining fullwidth - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('¥\u0301¥\u0301 foo', /foo/, [{x1: 5, x2: 8, y1: 0, y2: 0}], done); + }); + it('combining fullwidth - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('¥\u0301¥\u0301 foo', /foo/, [{x1: 8, x2: 1, y1: 0, y2: 1}], done); + }); + }); + describe('unicode within the match', () => { + it('combining - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('test cafe\u0301', /cafe\u0301/, [{x1: 5, x2: 9, y1: 0, y2: 0}], done); + }); + it('combining - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('testtest cafe\u0301', /cafe\u0301/, [{x1: 9, x2: 3, y1: 0, y2: 1}], done); + }); + it('surrogate - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('test a𝄞b', /a𝄞b/, [{x1: 5, x2: 8, y1: 0, y2: 0}], done); + }); + it('surrogate - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('testtest a𝄞b', /a𝄞b/, [{x1: 9, x2: 2, y1: 0, y2: 1}], done); + }); + it('combining surrogate - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('test a𓂀\u0301b', /a𓂀\u0301b/, [{x1: 5, x2: 8, y1: 0, y2: 0}], done); + }); + it('combining surrogate - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('testtest a𓂀\u0301b', /a𓂀\u0301b/, [{x1: 9, x2: 2, y1: 0, y2: 1}], done); + }); + it('fullwidth - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('test a1b', /a1b/, [{x1: 5, x2: 9, y1: 0, y2: 0}], done); + }); + it('fullwidth - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('testtest a1b', /a1b/, [{x1: 9, x2: 3, y1: 0, y2: 1}], done); + }); + it('combining fullwidth - match within one line', function(done: () => void): void { + assertLinkifiesInTerminal('test a¥\u0301b', /a¥\u0301b/, [{x1: 5, x2: 9, y1: 0, y2: 0}], done); + }); + it('combining fullwidth - match over two lines', function(done: () => void): void { + assertLinkifiesInTerminal('testtest a¥\u0301b', /a¥\u0301b/, [{x1: 9, x2: 3, y1: 0, y2: 1}], done); + }); + }); + }); }); diff --git a/src/Linkifier.ts b/src/Linkifier.ts index dbee6c4520..4d84b1a793 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -4,10 +4,11 @@ */ import { IMouseZoneManager } from './ui/Types'; -import { ILinkHoverEvent, ILinkMatcher, LinkMatcherHandler, LinkHoverEventTypes, ILinkMatcherOptions, ILinkifier, ITerminal, IBufferLine } from './Types'; +import { ILinkHoverEvent, ILinkMatcher, LinkMatcherHandler, LinkHoverEventTypes, ILinkMatcherOptions, ILinkifier, ITerminal, IBufferStringIteratorResult } from './Types'; import { MouseZone } from './ui/MouseZoneManager'; import { EventEmitter } from './common/EventEmitter'; import { CHAR_DATA_ATTR_INDEX } from './Buffer'; +import { getStringCellWidth } from './CharWidth'; /** * The Linkifier applies links to rows shortly after they have been refreshed. @@ -80,9 +81,25 @@ export class Linkifier extends EventEmitter implements ILinkifier { */ private _linkifyRows(): void { this._rowsTimeoutId = null; - for (let i = this._rowsToLinkify.start; i <= this._rowsToLinkify.end; i++) { - this._linkifyRow(i); + + // Ensure the row exists + const absoluteRowIndexStart = this._terminal.buffer.ydisp + this._rowsToLinkify.start; + if (absoluteRowIndexStart >= this._terminal.buffer.lines.length) { + return; + } + + // iterate over the range of unwrapped content strings within start..end (excluding) + // _doLinkifyRow gets full unwrapped lines with the start row as buffer offset for every matcher + // for wrapped content over several rows the iterator might return rows outside the viewport + // we skip those later in _doLinkifyRow + const iterator = this._terminal.buffer.iterator(false, absoluteRowIndexStart, this._terminal.buffer.ydisp + this._rowsToLinkify.end + 1); + while (iterator.hasNext()) { + const lineData: IBufferStringIteratorResult = iterator.next(); + for (let i = 0; i < this._linkMatchers.length; i++) { + this._doLinkifyRow(lineData.range.first, lineData.content, this._linkMatchers[i]); + } } + this._rowsToLinkify.start = null; this._rowsToLinkify.end = null; } @@ -153,99 +170,69 @@ export class Linkifier extends EventEmitter implements ILinkifier { return false; } - /** - * Linkifies a row. - * @param rowIndex The index of the row to linkify. - */ - private _linkifyRow(rowIndex: number): void { - // Ensure the row exists - let absoluteRowIndex = this._terminal.buffer.ydisp + rowIndex; - if (absoluteRowIndex >= this._terminal.buffer.lines.length) { - return; - } - - if (this._terminal.buffer.lines.get(absoluteRowIndex).isWrapped) { - // Only attempt to linkify rows that start in the viewport - if (rowIndex !== 0) { - return; - } - // If the first row is wrapped, backtrack to find the origin row and linkify that - let line: IBufferLine; - - do { - rowIndex--; - absoluteRowIndex--; - line = this._terminal.buffer.lines.get(absoluteRowIndex); - - if (!line) { - break; - } - - } while (line.isWrapped); - } - - // Construct full unwrapped line text - let text = this._terminal.buffer.translateBufferLineToString(absoluteRowIndex, false); - let currentIndex = absoluteRowIndex + 1; - while (currentIndex < this._terminal.buffer.lines.length && - this._terminal.buffer.lines.get(currentIndex).isWrapped) { - text += this._terminal.buffer.translateBufferLineToString(currentIndex++, false); - } - - for (let i = 0; i < this._linkMatchers.length; i++) { - this._doLinkifyRow(rowIndex, text, this._linkMatchers[i]); - } - } - /** * Linkifies a row given a specific handler. - * @param rowIndex The row index to linkify. - * @param text The text of the row (excludes text in the row that's already - * linkified). + * @param rowIndex The row index to linkify (absolute index). + * @param text string content of the unwrapped row. * @param matcher The link matcher for this line. - * @param offset The how much of the row has already been linkified. - * @return The link element(s) that were added. */ - private _doLinkifyRow(rowIndex: number, text: string, matcher: ILinkMatcher, offset: number = 0): void { - // Find the first match - const match = text.match(matcher.regex); - if (!match || match.length === 0) { - return; - } - const uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex]; + private _doLinkifyRow(rowIndex: number, text: string, matcher: ILinkMatcher): void { + // clone regex to do a global search on text + const rex = new RegExp(matcher.regex.source, matcher.regex.flags + 'g'); + let match; + let stringIndex = -1; + while ((match = rex.exec(text)) !== null) { + const uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex]; + if (!uri) { + // something matched but does not comply with the given matchIndex + // since this is most likely a bug the regex itself we simply do nothing here + // DEBUG: print match and throw + if ((this._terminal).debug) { + console.log({match, matcher}); + throw new Error('match found without corresponding matchIndex'); + } + break; + } - // Get index, match.index is for the outer match which includes negated chars - const index = text.indexOf(uri); + // Get index, match.index is for the outer match which includes negated chars + // therefore we cannot use match.index directly, instead we search the position + // of the match group in text again + // also correct regex and string search offsets for the next loop run + stringIndex = text.indexOf(uri, stringIndex + 1); + rex.lastIndex = stringIndex + uri.length; - // Get cell color - const line = this._terminal.buffer.lines.get(this._terminal.buffer.ydisp + rowIndex); - const char = line.get(index); - let fg: number | undefined; - if (char) { - const attr: number = char[CHAR_DATA_ATTR_INDEX]; - fg = (attr >> 9) & 0x1ff; - } + // get the buffer index as [absolute row, col] for the match + const bufferIndex = this._terminal.buffer.stringIndexToBufferIndex(rowIndex, stringIndex); - // Ensure the link is valid before registering - if (matcher.validationCallback) { - matcher.validationCallback(uri, isValid => { - // Discard link if the line has already changed - if (this._rowsTimeoutId) { - return; - } - if (isValid) { - this._addLink(offset + index, rowIndex, uri, matcher, fg); - } - }); - } else { - this._addLink(offset + index, rowIndex, uri, matcher, fg); - } + // skip rows outside of the viewport + if (bufferIndex[0] - this._terminal.buffer.ydisp < 0) { + continue; + } + if (bufferIndex[0] - this._terminal.buffer.ydisp > this._terminal.rows) { + break; + } - // Recursively check for links in the rest of the text - const remainingStartIndex = index + uri.length; - const remainingText = text.substr(remainingStartIndex); - if (remainingText.length > 0) { - this._doLinkifyRow(rowIndex, remainingText, matcher, offset + remainingStartIndex); + const line = this._terminal.buffer.lines.get(bufferIndex[0]); + const char = line.get(bufferIndex[1]); + let fg: number | undefined; + if (char) { + const attr: number = char[CHAR_DATA_ATTR_INDEX]; + fg = (attr >> 9) & 0x1ff; + } + + if (matcher.validationCallback) { + matcher.validationCallback(uri, isValid => { + // Discard link if the line has already changed + if (this._rowsTimeoutId) { + return; + } + if (isValid) { + this._addLink(bufferIndex[1], bufferIndex[0] - this._terminal.buffer.ydisp, uri, matcher, fg); + } + }); + } else { + this._addLink(bufferIndex[1], bufferIndex[0] - this._terminal.buffer.ydisp, uri, matcher, fg); + } } } @@ -258,10 +245,11 @@ export class Linkifier extends EventEmitter implements ILinkifier { * @param fg The link color for hover event. */ private _addLink(x: number, y: number, uri: string, matcher: ILinkMatcher, fg: number): void { + const width = getStringCellWidth(uri); const x1 = x % this._terminal.cols; const y1 = y + Math.floor(x / this._terminal.cols); - let x2 = (x1 + uri.length) % this._terminal.cols; - let y2 = y1 + Math.floor((x1 + uri.length) / this._terminal.cols); + let x2 = (x1 + width) % this._terminal.cols; + let y2 = y1 + Math.floor((x1 + width) / this._terminal.cols); if (x2 === 0) { x2 = this._terminal.cols; y2--; diff --git a/src/Types.ts b/src/Types.ts index df3f1a7984..69c4015143 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -19,6 +19,9 @@ export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: bo export type CharacterJoinerHandler = (text: string) => [number, number][]; +// BufferIndex denotes a position in the buffer: [rowIndex, colIndex] +export type BufferIndex = [number, number]; + export const enum LinkHoverEventTypes { HOVER = 'linkhover', TOOLTIP = 'linktooltip', @@ -265,6 +268,16 @@ export interface ITerminalOptions extends IPublicTerminalOptions { useFlowControl?: boolean; } +export interface IBufferStringIteratorResult { + range: {first: number, last: number}; + content: string; +} + +export interface IBufferStringIterator { + hasNext(): boolean; + next(): IBufferStringIteratorResult; +} + export interface IBuffer { readonly lines: ICircularList; ydisp: number; @@ -282,6 +295,8 @@ export interface IBuffer { getWrappedRangeForLine(y: number): { first: number, last: number }; nextStop(x?: number): number; prevStop(x?: number): number; + stringIndexToBufferIndex(lineIndex: number, stringIndex: number): number[]; + iterator(trimRight: boolean, startIndex?: number, endIndex?: number): IBufferStringIterator; } export interface IBufferSet extends IEventEmitter { diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts index f89eb77e6f..d07c6b2ca4 100644 --- a/src/addons/search/SearchHelper.ts +++ b/src/addons/search/SearchHelper.ts @@ -4,6 +4,7 @@ */ import { ISearchHelper, ISearchAddonTerminal, ISearchOptions, ISearchResult } from './Interfaces'; +const nonWordCharacters = ' ~!@#$%^&*()_+`-=[]{}|\;:"\',./<>?'; /** * A class that knows how to search the terminal and how to display the results. @@ -99,6 +100,17 @@ export class SearchHelper implements ISearchHelper { return this._selectResult(result); } + /** + * A found substring is a whole word if it doesn't have an alphanumeric character directly adjacent to it. + * @param searchIndex starting indext of the potential whole word substring + * @param line entire string in which the potential whole word was found + * @param term the substring that starts at searchIndex + */ + private _isWholeWord(searchIndex: number, line: string, term: string): boolean { + return (((searchIndex === 0) || (nonWordCharacters.indexOf(line[searchIndex - 1]) !== -1)) && + (((searchIndex + term.length) === line.length) || (nonWordCharacters.indexOf(line[searchIndex + term.length]) !== -1))); + } + /** * Searches a line for a search term. Takes the provided terminal line and searches the text line, which may contain * subsequent terminal lines if the text is wrapped. If the provided line number is part of a wrapped text line that @@ -136,6 +148,10 @@ export class SearchHelper implements ISearchHelper { y += Math.floor(searchIndex / this._terminal.cols); searchIndex = searchIndex % this._terminal.cols; } + if (searchOptions.wholeWord && !this._isWholeWord(searchIndex, searchStringLine, term)) { + return; + } + const line = this._terminal._core.buffer.lines.get(y); for (let i = 0; i < searchIndex; i++) { diff --git a/src/addons/search/search.test.ts b/src/addons/search/search.test.ts index 38a04df503..ccdebaf34b 100644 --- a/src/addons/search/search.test.ts +++ b/src/addons/search/search.test.ts @@ -70,12 +70,12 @@ describe('search addon', () => { goodbye */ - const hello0 = (term.searchHelper as any)._findInLine('Hello', 0); - const hello1 = (term.searchHelper as any)._findInLine('Hello', 1); - const hello2 = (term.searchHelper as any)._findInLine('Hello', 2); - const hello3 = (term.searchHelper as any)._findInLine('Hello', 3); - const llo = (term.searchHelper as any)._findInLine('llo', 1); - const goodbye = (term.searchHelper as any)._findInLine('goodbye', 2); + const hello0 = term.searchHelper.findInLine('Hello', 0); + const hello1 = term.searchHelper.findInLine('Hello', 1); + const hello2 = term.searchHelper.findInLine('Hello', 2); + const hello3 = term.searchHelper.findInLine('Hello', 3); + const llo = term.searchHelper.findInLine('llo', 1); + const goodbye = term.searchHelper.findInLine('goodbye', 2); expect(hello0).eql({col: 8, row: 0, term: 'Hello'}); expect(hello1).eql(undefined); expect(hello2).eql({col: 2, row: 3, term: 'Hello'}); @@ -130,9 +130,9 @@ describe('search addon', () => { wholeWord: false, caseSensitive: true }; - const hello0 = (term.searchHelper as any)._findInLine('Hello', 0, searchOptions); - const hello1 = (term.searchHelper as any)._findInLine('Hello', 1, searchOptions); - const hello2 = (term.searchHelper as any)._findInLine('Hello', 2, searchOptions); + const hello0 = term.searchHelper.findInLine('Hello', 0, searchOptions); + const hello1 = term.searchHelper.findInLine('Hello', 1, searchOptions); + const hello2 = term.searchHelper.findInLine('Hello', 2, searchOptions); expect(hello0).eql({col: 0, row: 0, term: 'Hello'}); expect(hello1).eql(undefined); expect(hello2).eql({col: 8, row: 2, term: 'Hello'}); @@ -153,14 +153,96 @@ describe('search addon', () => { wholeWord: false, caseSensitive: true }; - const hello0 = (term.searchHelper as any)._findInLine('Hello', 0, searchOptions); - const hello1 = (term.searchHelper as any)._findInLine('Hello$', 0, searchOptions); - const hello2 = (term.searchHelper as any)._findInLine('Hello', 1, searchOptions); - const hello3 = (term.searchHelper as any)._findInLine('Hello$', 1, searchOptions); + const hello0 = term.searchHelper.findInLine('Hello', 0, searchOptions); + const hello1 = term.searchHelper.findInLine('Hello$', 0, searchOptions); + const hello2 = term.searchHelper.findInLine('Hello', 1, searchOptions); + const hello3 = term.searchHelper.findInLine('Hello$', 1, searchOptions); expect(hello0).eql(undefined); expect(hello1).eql(undefined); expect(hello2).eql({col: 0, row: 1, term: 'Hello'}); expect(hello3).eql({col: 5, row: 1, term: 'Hello'}); }); + it('should respect whole-word search option', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 5}); + term.core.write('Hello World\r\nWorld Hello\r\nWorldHelloWorld\r\nHelloWorld\r\nWorldHello'); + term.pushWriteData(); + const searchOptions = { + regex: false, + wholeWord: true, + caseSensitive: false + }; + const hello0 = term.searchHelper.findInLine('Hello', 0, searchOptions); + const hello1 = term.searchHelper.findInLine('Hello', 1, searchOptions); + const hello2 = term.searchHelper.findInLine('Hello', 2, searchOptions); + const hello3 = term.searchHelper.findInLine('Hello', 3, searchOptions); + const hello4 = term.searchHelper.findInLine('Hello', 4, searchOptions); + expect(hello0).eql({col: 0, row: 0, term: 'Hello'}); + expect(hello1).eql({col: 6, row: 1, term: 'Hello'}); + expect(hello2).eql(undefined); + expect(hello3).eql(undefined); + expect(hello4).eql(undefined); + }); + it('should respect whole-word + case sensitive search options', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 5}); + term.core.write('Hello World\r\nHelloWorld'); + term.pushWriteData(); + const searchOptions = { + regex: false, + wholeWord: true, + caseSensitive: true + }; + const hello0 = term.searchHelper.findInLine('Hello', 0, searchOptions); + const hello1 = term.searchHelper.findInLine('hello', 0, searchOptions); + const hello2 = term.searchHelper.findInLine('Hello', 1, searchOptions); + const hello3 = term.searchHelper.findInLine('hello', 1, searchOptions); + expect(hello0).eql({col: 0, row: 0, term: 'Hello'}); + expect(hello1).eql(undefined); + expect(hello2).eql(undefined); + expect(hello3).eql(undefined); + }); + it('should respect whole-word + regex search options', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 5}); + term.core.write('Hello World Hello\r\nHelloWorldHello'); + term.pushWriteData(); + const searchOptions = { + regex: true, + wholeWord: true, + caseSensitive: false + }; + const hello0 = term.searchHelper.findInLine('Hello', 0, searchOptions); + const hello1 = term.searchHelper.findInLine('Hello$', 0, searchOptions); + const hello2 = term.searchHelper.findInLine('Hello', 1, searchOptions); + const hello3 = term.searchHelper.findInLine('Hello$', 1, searchOptions); + expect(hello0).eql({col: 0, row: 0, term: 'hello'}); + expect(hello1).eql({col: 12, row: 0, term: 'hello'}); + expect(hello2).eql(undefined); + expect(hello3).eql(undefined); + }); + it('should respect all search options', function(): void { + search.apply(MockTerminal); + const term = new MockTerminal({cols: 20, rows: 5}); + term.core.write('Hello World Hello\r\nHelloWorldHello'); + term.pushWriteData(); + const searchOptions = { + regex: true, + wholeWord: true, + caseSensitive: true + }; + const hello0 = term.searchHelper.findInLine('Hello', 0, searchOptions); + const hello1 = term.searchHelper.findInLine('Hello$', 0, searchOptions); + const hello2 = term.searchHelper.findInLine('hello', 0, searchOptions); + const hello3 = term.searchHelper.findInLine('hello$', 0, searchOptions); + const hello4 = term.searchHelper.findInLine('hello', 1, searchOptions); + const hello5 = term.searchHelper.findInLine('hello$', 1, searchOptions); + expect(hello0).eql({col: 0, row: 0, term: 'Hello'}); + expect(hello1).eql({col: 12, row: 0, term: 'Hello'}); + expect(hello2).eql(undefined); + expect(hello3).eql(undefined); + expect(hello4).eql(undefined); + expect(hello5).eql(undefined); + }); }); }); diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 32a7e9bc03..b38f4b858f 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -4,11 +4,19 @@ */ import { IColorSet, IRenderer, IRenderDimensions, IColorManager } from '../renderer/Types'; -import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, ILinkifier, IMouseHelper, ILinkMatcherOptions, CharacterJoinerHandler, IBufferLine } from '../Types'; +import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, ILinkifier, IMouseHelper, ILinkMatcherOptions, CharacterJoinerHandler, IBufferLine, IBufferStringIterator } from '../Types'; import { ICircularList, XtermListener } from '../common/Types'; import { Buffer } from '../Buffer'; import * as Browser from '../shared/utils/Browser'; import { ITheme, IDisposable, IMarker } from 'xterm'; +import { Terminal } from '../Terminal'; + +export class TestTerminal extends Terminal { + writeSync(data: string): void { + this.writeBuffer.push(data); + this._innerWrite(); + } +} export class MockTerminal implements ITerminal { markers: IMarker[]; @@ -300,7 +308,7 @@ export class MockBuffer implements IBuffer { return Buffer.prototype.translateBufferLineToString.apply(this, arguments); } getWrappedRangeForLine(y: number): { first: number; last: number; } { - throw new Error('Method not implemented.'); + return Buffer.prototype.getWrappedRangeForLine.apply(this, arguments); } nextStop(x?: number): number { throw new Error('Method not implemented.'); @@ -311,6 +319,12 @@ export class MockBuffer implements IBuffer { setLines(lines: ICircularList): void { this.lines = lines; } + stringIndexToBufferIndex(lineIndex: number, stringIndex: number): number[] { + return Buffer.prototype.stringIndexToBufferIndex.apply(this, arguments); + } + iterator(trimRight: boolean, startIndex?: number, endIndex?: number): IBufferStringIterator { + return Buffer.prototype.iterator.apply(this, arguments); + } } export class MockRenderer implements IRenderer { diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 80b41db5c3..cc1ebcd9e3 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -144,19 +144,14 @@ declare module 'xterm' { macOptionClickForcesSelection?: boolean; /** - * (EXPERIMENTAL) The type of renderer to use, this allows using the - * fallback DOM renderer when canvas is too slow for the environment. The - * following features do not work when the DOM renderer is used: + * The type of renderer to use, this allows using the fallback DOM renderer + * when canvas is too slow for the environment. The following features do + * not work when the DOM renderer is used: * - * - Links + * - Link underlines * - Line height * - Letter spacing * - Cursor blink - * - Cursor style - * - * This option is marked as experiemental because it will eventually be - * moved to an addon. You can only set this option in the constructor (not - * setOption). */ rendererType?: RendererType;