diff --git a/src/Buffer.ts b/src/Buffer.ts index 6931eea4..65b7d58e 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -114,7 +114,7 @@ export class Buffer implements IBuffer { if (this._lines.length > 0) { // Deal with columns increasing (we don't do anything when columns reduce) if (this._terminal.cols < newCols) { - const ch: CharData = [this._terminal.defAttr, ' ', 1]; // does xterm use the default attr? + const ch: CharData = [this._terminal.defAttr, ' ', 1, 32]; // does xterm use the default attr? for (let i = 0; i < this._lines.length; i++) { // TODO: This should be removed, with tests setup for the case that was // causing the underlying bug, see https://github.com/sourcelair/xterm.js/issues/824 diff --git a/src/InputHandler.ts b/src/InputHandler.ts index e9346c1a..3bf16a1c 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -189,7 +189,7 @@ export class InputHandler implements IInputHandler { const row = this._terminal.buffer.y + this._terminal.buffer.ybase; let j = this._terminal.buffer.x; - const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param-- && j < this._terminal.cols) { this._terminal.buffer.lines.get(row).splice(j++, 0, ch); @@ -488,7 +488,7 @@ export class InputHandler implements IInputHandler { } const row = this._terminal.buffer.y + this._terminal.buffer.ybase; - const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param--) { this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 1); @@ -536,7 +536,7 @@ export class InputHandler implements IInputHandler { const row = this._terminal.buffer.y + this._terminal.buffer.ybase; let j = this._terminal.buffer.x; - const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param-- && j < this._terminal.cols) { this._terminal.buffer.lines.get(row)[j++] = ch; @@ -590,7 +590,7 @@ export class InputHandler implements IInputHandler { public repeatPrecedingCharacter(params: number[]): void { let param = params[0] || 1; const line = this._terminal.buffer.lines.get(this._terminal.buffer.ybase + this._terminal.buffer.y); - const ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1]; + const ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1, 32]; while (param--) { line[this._terminal.buffer.x++] = ch; diff --git a/src/Interfaces.ts b/src/Interfaces.ts index ace4012e..9ce8ffd4 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -18,7 +18,11 @@ export interface IBrowser { isMSWindows: boolean; } -export interface ITerminal extends IEventEmitter { +export interface IBufferAccessor { + buffer: IBuffer; +} + +export interface ITerminal extends IBufferAccessor, IEventEmitter { element: HTMLElement; selectionManager: ISelectionManager; charMeasure: ICharMeasure; @@ -32,7 +36,6 @@ export interface ITerminal extends IEventEmitter { defAttr: number; options: ITerminalOptions; buffers: IBufferSet; - buffer: IBuffer; isFocused: boolean; /** diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index a4ed70db..6b28acb9 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -2,42 +2,84 @@ * @license MIT */ -import jsdom = require('jsdom'); import { assert } from 'chai'; -import { ITerminal, ILinkifier } from './Interfaces'; +import { ITerminal, ILinkifier, IBuffer, IBufferAccessor } from './Interfaces'; import { Linkifier } from './Linkifier'; -import { LinkMatcher } from './Types'; +import { LinkMatcher, LineData } from './Types'; +import { IMouseZoneManager, IMouseZone } from './input/Interfaces'; +import { MockBuffer } from './utils/TestUtils.test'; +import { CircularList } from './utils/CircularList'; class TestLinkifier extends Linkifier { - constructor() { + constructor(bufferAccessor: IBufferAccessor) { Linkifier.TIME_BEFORE_LINKIFY = 0; - super(); + super(bufferAccessor); } public get linkMatchers(): LinkMatcher[] { return this._linkMatchers; } } -describe('Linkifier', () => { - let dom: jsdom.JSDOM; - let window: Window; - let document: Document; +class TestMouseZoneManager implements IMouseZoneManager { + public clears: number = 0; + public zones: IMouseZone[] = []; + add(zone: IMouseZone): void { + this.zones.push(zone); + } + clearAll(): void { + this.clears++; + } +} - let container: HTMLElement; - let rows: HTMLElement[]; +describe('Linkifier', () => { + let bufferAccessor: IBufferAccessor; let linkifier: TestLinkifier; + let mouseZoneManager: TestMouseZoneManager; beforeEach(() => { - dom = new jsdom.JSDOM(''); - window = dom.window; - document = window.document; - linkifier = new TestLinkifier(); + bufferAccessor = { buffer: new MockBuffer() }; + bufferAccessor.buffer.lines = new CircularList(20); + bufferAccessor.buffer.ydisp = 0; + linkifier = new TestLinkifier(bufferAccessor); + mouseZoneManager = new TestMouseZoneManager(); }); - function addRow(html: string): void { - const element = document.createElement('div'); - element.innerHTML = html; - container.appendChild(element); - rows.push(element); + function stringToRow(text: string): LineData { + let result: LineData = []; + for (let i = 0; i < text.length; i++) { + result.push([0, text.charAt(i), 1, text.charCodeAt(i)]); + } + return result; + } + + function addRow(text: string): void { + bufferAccessor.buffer.lines.push(stringToRow(text)); + } + + function assertLinkifiesEntireRow(uri: string, done: MochaDone): void { + addRow(uri); + linkifier.linkifyRow(0); + setTimeout(() => { + assert.equal(mouseZoneManager.zones[0].x1, 1); + assert.equal(mouseZoneManager.zones[0].x2, uri.length + 1); + assert.equal(mouseZoneManager.zones[0].y, bufferAccessor.buffer.lines.length); + done(); + }, 0); + } + + function assertLinkifiesRow(rowText: string, linkMatcherRegex: RegExp, links: {x: number, length: number}[], done: MochaDone): void { + addRow(rowText); + linkifier.registerLinkMatcher(linkMatcherRegex, () => {}); + linkifier.linkifyRow(0); + // Allow linkify to happen + setTimeout(() => { + assert.equal(mouseZoneManager.zones.length, links.length); + links.forEach((l, i) => { + assert.equal(mouseZoneManager.zones[i].x1, l.x + 1); + assert.equal(mouseZoneManager.zones[i].x2, l.x + l.length + 1); + assert.equal(mouseZoneManager.zones[i].y, bufferAccessor.buffer.lines.length); + }); + done(); + }, 0); } describe('before attachToDom', () => { @@ -52,78 +94,42 @@ describe('Linkifier', () => { describe('after attachToDom', () => { beforeEach(() => { - rows = []; - linkifier.attachToDom(document, rows); - container = document.createElement('div'); - document.body.appendChild(container); + linkifier.attachToDom(mouseZoneManager); }); - function clickElement(element: Node): void { - const event = document.createEvent('MouseEvent'); - event.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); - element.dispatchEvent(event); - } - - function assertLinkifiesEntireRow(uri: string, done: MochaDone): void { - addRow(uri); - linkifier.linkifyRow(0); - setTimeout(() => { - assert.equal((rows[0].firstChild).tagName, 'A'); - assert.equal((rows[0].firstChild).textContent, uri); - done(); - }, 0); - } - describe('http links', () => { - function assertLinkifiesEntireRow(uri: string, done: MochaDone): void { - addRow(uri); - linkifier.linkifyRow(0); - setTimeout(() => { - assert.equal((rows[0].firstChild).tagName, 'A'); - assert.equal((rows[0].firstChild).textContent, uri); - done(); - }, 0); - } - it('should allow ~ character in URI path', done => assertLinkifiesEntireRow('http://foo.com/a~b#c~d?e~f', done)); + it('should allow ~ character in URI path', (done) => { + assertLinkifiesEntireRow('http://foo.com/a~b#c~d?e~f', done); + }); }); describe('link matcher', () => { - function assertLinkifiesRow(rowText: string, linkMatcherRegex: RegExp, expectedHtml: string, done: MochaDone): void { - addRow(rowText); - linkifier.registerLinkMatcher(linkMatcherRegex, () => {}); - linkifier.linkifyRow(0); - // Allow linkify to happen - setTimeout(() => { - assert.equal(rows[0].innerHTML, expectedHtml); - done(); - }, 0); - } it('should match a single link', done => { - assertLinkifiesRow('foo', /foo/, 'foo', done); + assertLinkifiesRow('foo', /foo/, [{x: 0, length: 3}], done); }); it('should match a single link at the start of a text node', done => { - assertLinkifiesRow('foo bar', /foo/, 'foo bar', done); + assertLinkifiesRow('foo bar', /foo/, [{x: 0, length: 3}], done); }); it('should match a single link in the middle of a text node', done => { - assertLinkifiesRow('foo bar baz', /bar/, 'foo bar baz', done); + assertLinkifiesRow('foo bar baz', /bar/, [{x: 4, length: 3}], done); }); it('should match a single link at the end of a text node', done => { - assertLinkifiesRow('foo bar', /bar/, 'foo bar', done); + assertLinkifiesRow('foo bar', /bar/, [{x: 4, length: 3}], done); }); it('should match a link after a link at the start of a text node', done => { - assertLinkifiesRow('foo bar', /foo|bar/, 'foo bar', done); + assertLinkifiesRow('foo bar', /foo|bar/, [{x: 0, length: 3}, {x: 4, length: 3}], done); }); it('should match a link after a link in the middle of a text node', done => { - assertLinkifiesRow('foo bar baz', /bar|baz/, 'foo bar baz', done); + assertLinkifiesRow('foo bar baz', /bar|baz/, [{x: 4, length: 3}, {x: 8, length: 3}], done); }); it('should match a link immediately after a link at the end of a text node', done => { - assertLinkifiesRow('foo barbaz', /bar|baz/, 'foo barbaz', done); + assertLinkifiesRow('foo barbaz', /bar|baz/, [{x: 4, length: 3}, {x: 7, length: 3}], done); }); it('should not duplicate text after a unicode character (wrapped in a span)', done => { // This is a regression test for an issue that came about when using // an oh-my-zsh theme that added the large blue diamond unicode // character (U+1F537) which caused the path to be duplicated. See #642. - assertLinkifiesRow('echo \'🔷foo\'', /foo/, 'echo \'🔷foo\'', done); + assertLinkifiesRow('echo \'🔷foo\'', /foo/, [{x: 8, length: 3}], done); }); }); @@ -131,10 +137,15 @@ describe('Linkifier', () => { it('should enable link if true', done => { addRow('test'); linkifier.registerLinkMatcher(/test/, () => done(), { - validationCallback: (url, element, cb) => { + validationCallback: (url, cb) => { + assert.equal(mouseZoneManager.zones.length, 0); cb(true); - assert.equal((rows[0].firstChild).tagName, 'A'); - setTimeout(() => clickElement(rows[0].firstChild), 0); + assert.equal(mouseZoneManager.zones.length, 1); + assert.equal(mouseZoneManager.zones[0].x1, 1); + assert.equal(mouseZoneManager.zones[0].x2, 5); + assert.equal(mouseZoneManager.zones[0].y, 1); + // Fires done() + mouseZoneManager.zones[0].clickCallback({}); } }); linkifier.linkifyRow(0); @@ -143,14 +154,14 @@ describe('Linkifier', () => { it('should disable link if false', done => { addRow('test'); linkifier.registerLinkMatcher(/test/, () => assert.fail(), { - validationCallback: (url, element, cb) => { + validationCallback: (url, cb) => { + assert.equal(mouseZoneManager.zones.length, 0); cb(false); - assert.equal((rows[0].firstChild).tagName, 'A'); - setTimeout(() => clickElement(rows[0].firstChild), 0); + assert.equal(mouseZoneManager.zones.length, 0); } }); linkifier.linkifyRow(0); - // Allow time for the click to be performed + // Allow time for the validation callback to be performed setTimeout(() => done(), 10); }); @@ -158,7 +169,7 @@ describe('Linkifier', () => { addRow('test test'); let count = 0; linkifier.registerLinkMatcher(/test/, () => assert.fail(), { - validationCallback: (url, element, cb) => { + validationCallback: (url, cb) => { count += 1; if (count === 2) { done(); diff --git a/src/Linkifier.ts b/src/Linkifier.ts index 20c5b713..cae0d662 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -2,7 +2,7 @@ * @license MIT */ -import { ILinkMatcherOptions, ITerminal } from './Interfaces'; +import { ILinkMatcherOptions, ITerminal, IBufferAccessor } from './Interfaces'; import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback, LineData } from './Types'; import { IMouseZoneManager } from './input/Interfaces'; import { MouseZone } from './input/MouseZoneManager'; @@ -52,7 +52,7 @@ export class Linkifier { private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID; constructor( - private _terminal: ITerminal + private _terminal: IBufferAccessor ) { this._rowTimeoutIds = []; this._linkMatchers = []; @@ -213,7 +213,7 @@ export class Linkifier { * @param {LinkMatcher} matcher The link matcher for this line. * @return The link element(s) that were added. */ - private _doLinkifyRow(rowIndex: number, text: string, matcher: LinkMatcher): void { + private _doLinkifyRow(rowIndex: number, text: string, matcher: LinkMatcher, offset: number = 0): void { // Iterate over nodes as we want to consider text nodes let result = []; const isHttpLinkMatcher = matcher.id === HYPERTEXT_LINK_MATCHER_ID; @@ -225,10 +225,6 @@ export class Linkifier { } let uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex]; - // TODO: Match more than one link per row - // Set the next searches start index - // let rowStartIndex = match.index + uri.length; - // Get index, match.index is for the outer match which includes negated chars const index = text.indexOf(uri); @@ -237,11 +233,18 @@ export class Linkifier { matcher.validationCallback(text, isValid => { if (isValid) { // TODO: Discard link if the line has already changed? - this._addLink(index, rowIndex, uri, matcher); + this._addLink(offset + index, rowIndex, uri, matcher); } }); } else { - this._addLink(index, rowIndex, uri, matcher); + this._addLink(offset + index, rowIndex, uri, matcher); + } + + // 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); } } diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index b5a9d552..8c90693e 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -60,7 +60,7 @@ describe('SelectionManager', () => { function stringToRow(text: string): LineData { let result: LineData = []; for (let i = 0; i < text.length; i++) { - result.push([0, text.charAt(i), 1]); + result.push([0, text.charAt(i), 1, text.charCodeAt(i)]); } return result; } @@ -99,21 +99,21 @@ describe('SelectionManager', () => { it('should expand selection for wide characters', () => { // Wide characters use a special format buffer.lines.set(0, [ - [null, '中', 2], - [null, '', 0], - [null, '文', 2], - [null, '', 0], - [null, ' ', 1], - [null, 'a', 1], - [null, '中', 2], - [null, '', 0], - [null, '文', 2], - [null, '', 0], - [null, 'b', 1], - [null, ' ', 1], - [null, 'f', 1], - [null, 'o', 1], - [null, 'o', 1] + [null, '中', 2, '中'.charCodeAt(0)], + [null, '', 0, null], + [null, '文', 2, '文'.charCodeAt(0)], + [null, '', 0, null], + [null, ' ', 1, ' '.charCodeAt(0)], + [null, 'a', 1, 'a'.charCodeAt(0)], + [null, '中', 2, '中'.charCodeAt(0)], + [null, '', 0, null], + [null, '文', 2, '文'.charCodeAt(0)], + [null, '', 0, ''.charCodeAt(0)], + [null, 'b', 1, 'b'.charCodeAt(0)], + [null, ' ', 1, ' '.charCodeAt(0)], + [null, 'f', 1, 'f'.charCodeAt(0)], + [null, 'o', 1, 'o'.charCodeAt(0)], + [null, 'o', 1, 'o'.charCodeAt(0)] ]); // Ensure wide characters take up 2 columns selectionManager.selectWordAt([0, 0]); diff --git a/src/Types.ts b/src/Types.ts index 378a66ac..1ced3d73 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -17,6 +17,5 @@ export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: bo export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean; export type Charset = {[key: string]: string}; -// TODO: Add code here? -export type CharData = [number, string, number]; +export type CharData = [number, string, number, number]; export type LineData = CharData[]; diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index d4dc16ab..0388e9da 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -4,6 +4,7 @@ import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper } from '../Interfaces'; import { LineData } from '../Types'; +import { Buffer } from '../Buffer'; import * as Browser from './Browser'; import { IColorSet } from '../renderer/Interfaces'; @@ -61,7 +62,7 @@ export class MockTerminal implements ITerminal { const line: LineData = []; cols = cols || this.cols; for (let i = 0; i < cols; i++) { - line.push([0, ' ', 1]); + line.push([0, ' ', 1, 32]); } return line; } @@ -130,7 +131,7 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { eraseLeft(x: number, y: number): void { throw new Error('Method not implemented.'); } - blankLine(cur?: boolean, isWrapped?: boolean): [number, string, number][] { + blankLine(cur?: boolean, isWrapped?: boolean): [number, string, number, number][] { throw new Error('Method not implemented.'); } prevStop(x?: number): number { @@ -182,7 +183,7 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { export class MockBuffer implements IBuffer { isCursorInViewport: boolean; - lines: ICircularList<[number, string, number][]>; + lines: ICircularList<[number, string, number, number][]>; ydisp: number; ybase: number; y: number; @@ -193,7 +194,7 @@ export class MockBuffer implements IBuffer { savedY: number; savedX: number; translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string { - throw new Error('Method not implemented.'); + return Buffer.prototype.translateBufferLineToString.apply(this, arguments); } nextStop(x?: number): number { throw new Error('Method not implemented.'); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index b1cea816..d6127978 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -133,6 +133,11 @@ interface ILinkMatcherOptions { */ validationCallback?: (uri: string, element: HTMLElement, callback: (isValid: boolean) => void) => void; + /** + * A callback that fired when the mouse hovers over a link. + */ + hoverCallback?: LinkMatcherHandler; + /** * The priority of the link matcher, this defines the order in which the link * matcher is evaluated relative to others, from highest to lowest. The