Merge branch 'master' into typedarray_BufferLine

This commit is contained in:
Jörg Breitbart
2018-09-14 19:19:41 +02:00
14 changed files with 662 additions and 128 deletions
+2 -2
View File
@@ -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);
+1
View File
@@ -18,6 +18,7 @@
<label>Find previous <input id="find-previous"/></label>
<label>Use regex<input type="checkbox" id="regex"/></label>
<label>Case sensitive<input type="checkbox" id="case-sensitive"/></label>
<label>Whole word<input type="checkbox" id="whole-word"/></label>
</p>
</div>
<div>
+2 -1
View File
@@ -63,6 +63,7 @@
"prepublish": "npm run build",
"coveralls": "nyc report --reporter=text-lcov | coveralls",
"webpack": "gulp webpack",
"watch": "concurrently --kill-others-on-fail --names \"lib,attach,fit,fullscreen,search,terminado,webLinks,winptyCompat,zmodem,css\" \"tsc -w\" \"tsc -w -p ./src/addons/attach\" \"tsc -w -p ./src/addons/fit\" \"tsc -w -p ./src/addons/fullscreen\" \"tsc -w -p ./src/addons/search\" \"tsc -w -p ./src/addons/terminado\" \"tsc -w -p ./src/addons/webLinks\" \"tsc -w -p ./src/addons/winptyCompat\" \"tsc -w -p ./src/addons/zmodem\" \"gulp watch-css\""
"watch": "concurrently --kill-others-on-fail --names \"lib,css\" \"tsc -w\" \"gulp watch-css\"",
"watch-addons": "concurrently --kill-others-on-fail --names \"attach,fit,fullscreen,search,terminado,webLinks,winptyCompat,zmodem\" \"tsc -w -p ./src/addons/attach\" \"tsc -w -p ./src/addons/fit\" \"tsc -w -p ./src/addons/fullscreen\" \"tsc -w -p ./src/addons/search\" \"tsc -w -p ./src/addons/terminado\" \"tsc -w -p ./src/addons/webLinks\" \"tsc -w -p ./src/addons/winptyCompat\" \"tsc -w -p ./src/addons/zmodem\""
}
}
+169 -2
View File
@@ -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;
@@ -351,4 +351,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);
}
});
});
});
+63 -1
View File
@@ -4,7 +4,7 @@
*/
import { CircularList } from './common/CircularList';
import { CharData, ITerminal, IBuffer, IBufferLine, IBufferLineConstructor } from './Types';
import { CharData, ITerminal, IBuffer, IBufferLine, BufferIndex, IBufferStringIterator, IBufferStringIteratorResult, IBufferLineConstructor } from './Types';
import { EventEmitter } from './common/EventEmitter';
import { IMarker } from 'xterm';
import { BufferLine, BufferLineTypedArray } from './BufferLine';
@@ -226,6 +226,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
@@ -372,6 +402,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 {
@@ -398,3 +432,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};
}
}
+79
View File
@@ -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
});
+22
View File
@@ -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;
}
+95 -1
View File
@@ -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 ab', /ab/, [{x1: 5, x2: 9, y1: 0, y2: 0}], done);
});
it('fullwidth - match over two lines', function(done: () => void): void {
assertLinkifiesInTerminal('testtest ab', /ab/, [{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);
});
});
});
});
+80 -92
View File
@@ -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];
// Get index, match.index is for the outer match which includes negated chars
const index = text.indexOf(uri);
// 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;
}
// 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;
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 ((<any>this._terminal).debug) {
console.log({match, matcher});
throw new Error('match found without corresponding matchIndex');
}
if (isValid) {
this._addLink(offset + index, rowIndex, uri, matcher, fg);
}
});
} else {
this._addLink(offset + index, rowIndex, uri, matcher, fg);
}
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);
// 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 the buffer index as [absolute row, col] for the match
const bufferIndex = this._terminal.buffer.stringIndexToBufferIndex(rowIndex, stringIndex);
// 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;
}
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--;
+15
View File
@@ -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<IBufferLine>;
ydisp: number;
@@ -283,6 +296,8 @@ export interface IBuffer {
nextStop(x?: number): number;
prevStop(x?: number): number;
getBlankLine(attr: number, isWrapped?: boolean): IBufferLine;
stringIndexToBufferIndex(lineIndex: number, stringIndex: number): number[];
iterator(trimRight: boolean, startIndex?: number, endIndex?: number): IBufferStringIterator;
}
export interface IBufferSet extends IEventEmitter {
+16
View File
@@ -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++) {
+95 -13
View File
@@ -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(<any>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(<any>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(<any>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(<any>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);
});
});
});
+19 -7
View File
@@ -4,12 +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, CharData } 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, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from '../Buffer';
import { Buffer } from '../Buffer';
import * as Browser from '../shared/utils/Browser';
import { ITheme, IDisposable, IMarker } from 'xterm';
import { BufferLine } from '../BufferLine';
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[];
@@ -301,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.');
@@ -312,9 +319,14 @@ export class MockBuffer implements IBuffer {
setLines(lines: ICircularList<IBufferLine>): void {
this.lines = lines;
}
getBlankLine(attr: number, isWrapped: boolean = false): IBufferLine {
const fillCharData: CharData = [attr, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE];
return new BufferLine(80, fillCharData, isWrapped);
getBlankLine(attr: number, isWrapped?: boolean): IBufferLine {
return Buffer.prototype.getBlankLine.apply(this, arguments);
}
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);
}
}
+4 -9
View File
@@ -155,19 +155,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;