Merge pull request #1303 from Tyriar/24_multi_line_links

Get multi-line links working
This commit is contained in:
Daniel Imms
2018-03-21 07:33:43 -07:00
committed by GitHub
6 changed files with 134 additions and 30 deletions
+46 -10
View File
@@ -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: <HTMLElement>{}
};
terminal = new MockTerminal();
terminal.cols = 100;
terminal.buffer = new MockBuffer();
terminal.buffer.lines = new CircularList<LineData>(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(<any>{});
}
+45 -10
View File
@@ -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 ((<any>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 ((<any>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 &&
(<any>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, <ILinkHoverEvent>{ 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, <ILinkHoverEvent>{ 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, <ILinkHoverEvent>{ 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 };
}
}
+5 -3
View File
@@ -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 {
+19 -4
View File
@@ -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,
+2 -1
View File
@@ -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;
+17 -2
View File
@@ -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;
}