diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index 1aaf02c9eb..0e67403c9c 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -5,13 +5,13 @@ import { assert } from 'chai'; import { IMouseZoneManager, IMouseZone } from './input/Types'; -import { ILinkMatcher, LineData, IBufferAccessor, IElementAccessor } from './Types'; +import { ILinkMatcher, LineData, ITerminal } from './Types'; import { Linkifier } from './Linkifier'; -import { MockBuffer } from './utils/TestUtils.test'; +import { MockBuffer, MockTerminal } from './utils/TestUtils.test'; import { CircularList } from './utils/CircularList'; class TestLinkifier extends Linkifier { - constructor(_terminal: IBufferAccessor & IElementAccessor) { + constructor(_terminal: ITerminal) { super(_terminal); Linkifier.TIME_BEFORE_LINKIFY = 0; } @@ -32,15 +32,14 @@ class TestMouseZoneManager implements IMouseZoneManager { } describe('Linkifier', () => { - let terminal: IBufferAccessor & IElementAccessor; + let terminal: ITerminal; let linkifier: TestLinkifier; let mouseZoneManager: TestMouseZoneManager; beforeEach(() => { - terminal = { - buffer: new MockBuffer(), - element: {} - }; + terminal = new MockTerminal(); + terminal.cols = 100; + terminal.buffer = new MockBuffer(); terminal.buffer.lines = new CircularList(20); terminal.buffer.ydisp = 0; linkifier = new TestLinkifier(terminal); @@ -69,7 +68,25 @@ describe('Linkifier', () => { 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, terminal.buffer.lines.length); + assert.equal(mouseZoneManager.zones[i].y1, terminal.buffer.lines.length); + assert.equal(mouseZoneManager.zones[i].y2, terminal.buffer.lines.length); + }); + done(); + }, 0); + } + + function assertLinkifiesMultiLineLink(rowText: string, linkMatcherRegex: RegExp, links: {x1: number, y1: number, x2: number, y2: number}[], done: MochaDone): void { + addRow(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); @@ -118,6 +135,24 @@ describe('Linkifier', () => { // character (U+1F537) which caused the path to be duplicated. See #642. assertLinkifiesRow('echo \'🔷foo\'', /foo/, [{x: 8, length: 3}], done); }); + describe('multi-line links', () => { + it('should match links that start on line 1/2 of a wrapped line and end on the last character of line 1/2', done => { + terminal.cols = 4; + assertLinkifiesMultiLineLink('12345', /1234/, [{x1: 0, x2: 4, y1: 0, y2: 0}], done); + }); + it('should match links that start on line 1/2 of a wrapped line and wrap to line 2/2', done => { + terminal.cols = 4; + assertLinkifiesMultiLineLink('12345', /12345/, [{x1: 0, x2: 1, y1: 0, y2: 1}], done); + }); + it('should match links that start and end on line 2/2 of a wrapped line', done => { + terminal.cols = 4; + assertLinkifiesMultiLineLink('12345678', /5678/, [{x1: 0, x2: 4, y1: 1, y2: 1}], done); + }); + it('should match links that start on line 2/3 of a wrapped line and wrap to line 3/3', done => { + terminal.cols = 4; + assertLinkifiesMultiLineLink('123456789', /56789/, [{x1: 0, x2: 1, y1: 1, y2: 2}], done); + }); + }); }); describe('validationCallback', () => { @@ -130,7 +165,8 @@ describe('Linkifier', () => { 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); + assert.equal(mouseZoneManager.zones[0].y1, 1); + assert.equal(mouseZoneManager.zones[0].y2, 1); // Fires done() mouseZoneManager.zones[0].clickCallback({}); } diff --git a/src/Linkifier.ts b/src/Linkifier.ts index 93eb9063ae..2a19c8d8e0 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -4,7 +4,7 @@ */ import { IMouseZoneManager } from './input/Types'; -import { ILinkHoverEvent, ILinkMatcher, LinkMatcherHandler, LinkHoverEventTypes, ILinkMatcherOptions, IBufferAccessor, ILinkifier, IElementAccessor } from './Types'; +import { ILinkHoverEvent, ILinkMatcher, LinkMatcherHandler, LinkHoverEventTypes, ILinkMatcherOptions, ILinkifier, ITerminal } from './Types'; import { MouseZone } from './input/MouseZoneManager'; import { EventEmitter } from './EventEmitter'; @@ -27,7 +27,7 @@ export class Linkifier extends EventEmitter implements ILinkifier { private _rowsToLinkify: {start: number, end: number}; constructor( - protected _terminal: IBufferAccessor & IElementAccessor + protected _terminal: ITerminal ) { super(); this._rowsToLinkify = { @@ -157,11 +157,32 @@ export class Linkifier extends EventEmitter implements ILinkifier { * @param rowIndex The index of the row to linkify. */ private _linkifyRow(rowIndex: number): void { - const absoluteRowIndex = this._terminal.buffer.ydisp + rowIndex; + // Ensure the row exists + let absoluteRowIndex = this._terminal.buffer.ydisp + rowIndex; if (absoluteRowIndex >= this._terminal.buffer.lines.length) { return; } - const text = this._terminal.buffer.translateBufferLineToString(absoluteRowIndex, false); + + 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 + do { + rowIndex--; + absoluteRowIndex--; + } while ((this._terminal.buffer.lines.get(absoluteRowIndex)).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]); } @@ -218,10 +239,20 @@ export class Linkifier extends EventEmitter implements ILinkifier { * @param matcher The link matcher for the link. */ private _addLink(x: number, y: number, uri: string, matcher: ILinkMatcher): void { + 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); + if (x2 === 0) { + x2 = this._terminal.cols; + y2--; + } + this._mouseZoneManager.add(new MouseZone( - x + 1, - x + 1 + uri.length, - y + 1, + x1 + 1, + y1 + 1, + x2 + 1, + y2 + 1, e => { if (matcher.handler) { return matcher.handler(e, uri); @@ -229,17 +260,17 @@ export class Linkifier extends EventEmitter implements ILinkifier { window.open(uri, '_blank'); }, e => { - this.emit(LinkHoverEventTypes.HOVER, { x, y, length: uri.length}); + this.emit(LinkHoverEventTypes.HOVER, this._createLinkHoverEvent(x1, y1, x2, y2)); this._terminal.element.classList.add('xterm-cursor-pointer'); }, e => { - this.emit(LinkHoverEventTypes.TOOLTIP, { x, y, length: uri.length}); + this.emit(LinkHoverEventTypes.TOOLTIP, this._createLinkHoverEvent(x1, y1, x2, y2)); if (matcher.hoverTooltipCallback) { matcher.hoverTooltipCallback(e, uri); } }, () => { - this.emit(LinkHoverEventTypes.LEAVE, { x, y, length: uri.length}); + this.emit(LinkHoverEventTypes.LEAVE, this._createLinkHoverEvent(x1, y1, x2, y2)); this._terminal.element.classList.remove('xterm-cursor-pointer'); if (matcher.hoverLeaveCallback) { matcher.hoverLeaveCallback(); @@ -253,4 +284,8 @@ export class Linkifier extends EventEmitter implements ILinkifier { } )); } + + private _createLinkHoverEvent(x1: number, y1: number, x2: number, y2: number): ILinkHoverEvent { + return { x1, y1, x2, y2, cols: this._terminal.cols }; + } } diff --git a/src/Types.ts b/src/Types.ts index dd1b22cad3..84355e1ff9 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -174,9 +174,11 @@ export interface ICharset { } export interface ILinkHoverEvent { - x: number; - y: number; - length: number; + x1: number; + y1: number; + x2: number; + y2: number; + cols: number; } export interface ITerminal extends PublicTerminal, IElementAccessor, IBufferAccessor, ILinkifierAccessor { diff --git a/src/input/MouseZoneManager.ts b/src/input/MouseZoneManager.ts index 3ab86e7c14..65fe74de7d 100644 --- a/src/input/MouseZoneManager.ts +++ b/src/input/MouseZoneManager.ts @@ -59,7 +59,9 @@ export class MouseZoneManager implements IMouseZoneManager { // Iterate through zones and clear them out if they're within the range for (let i = 0; i < this._zones.length; i++) { const zone = this._zones[i]; - if (zone.y > start && zone.y <= end + 1) { + if ((zone.y1 > start && zone.y1 <= end + 1) || + (zone.y2 > start && zone.y2 <= end + 1) || + (zone.y1 < start && zone.y2 > end + 1)) { if (this._currentZone && this._currentZone === zone) { this._currentZone.leaveCallback(); this._currentZone = null; @@ -173,10 +175,22 @@ export class MouseZoneManager implements IMouseZoneManager { if (!coords) { return null; } + const x = coords[0]; + const y = coords[1]; for (let i = 0; i < this._zones.length; i++) { const zone = this._zones[i]; - if (zone.y === coords[1] && zone.x1 <= coords[0] && zone.x2 > coords[0]) { - return zone; + if (zone.y1 === zone.y2) { + // Single line link + if (y === zone.y1 && x >= zone.x1 && x < zone.x2) { + return zone; + } + } else { + // Multi-line link + if ((y === zone.y1 && x >= zone.x1) || + (y === zone.y2 && x < zone.x2) || + (y > zone.y1 && y < zone.y2)) { + return zone; + } } } return null; @@ -186,8 +200,9 @@ export class MouseZoneManager implements IMouseZoneManager { export class MouseZone implements IMouseZone { constructor( public x1: number, + public y1: number, public x2: number, - public y: number, + public y2: number, public clickCallback: (e: MouseEvent) => any, public hoverCallback: (e: MouseEvent) => any, public tooltipCallback: (e: MouseEvent) => any, diff --git a/src/input/Types.ts b/src/input/Types.ts index f13984646e..2bd805bf38 100644 --- a/src/input/Types.ts +++ b/src/input/Types.ts @@ -11,7 +11,8 @@ export interface IMouseZoneManager { export interface IMouseZone { x1: number; x2: number; - y: number; + y1: number; + y2: number; clickCallback: (e: MouseEvent) => any; hoverCallback: (e: MouseEvent) => any | undefined; tooltipCallback: (e: MouseEvent) => any | undefined; diff --git a/src/renderer/LinkRenderLayer.ts b/src/renderer/LinkRenderLayer.ts index f94a47f883..29a5036b08 100644 --- a/src/renderer/LinkRenderLayer.ts +++ b/src/renderer/LinkRenderLayer.ts @@ -28,14 +28,29 @@ export class LinkRenderLayer extends BaseRenderLayer { private _clearCurrentLink(): void { if (this._state) { - this.clearCells(this._state.x, this._state.y, this._state.length, 1); + this.clearCells(this._state.x1, this._state.y1, this._state.cols - this._state.x1, 1); + const middleRowCount = this._state.y2 - this._state.y1 - 1; + if (middleRowCount > 0) { + this.clearCells(0, this._state.y1 + 1, this._state.cols, middleRowCount); + } + this.clearCells(0, this._state.y2, this._state.x2, 1); this._state = null; } } private _onLinkHover(e: ILinkHoverEvent): void { this._ctx.fillStyle = this._colors.foreground; - this.fillBottomLineAtCells(e.x, e.y, e.length); + if (e.y1 === e.y2) { + // Single line link + this.fillBottomLineAtCells(e.x1, e.y1, e.x2 - e.x1); + } else { + // Multi-line link + this.fillBottomLineAtCells(e.x1, e.y1, e.cols - e.x1); + for (let y = e.y1 + 1; y < e.y2; y++) { + this.fillBottomLineAtCells(0, y, e.cols); + } + this.fillBottomLineAtCells(0, e.y2, e.x2); + } this._state = e; }