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 @@
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;