diff --git a/addons/xterm-addon-search/package.json b/addons/xterm-addon-search/package.json index d66e8a687..1913f1777 100644 --- a/addons/xterm-addon-search/package.json +++ b/addons/xterm-addon-search/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-search", - "version": "0.4.0", + "version": "0.5.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-serialize/package.json b/addons/xterm-addon-serialize/package.json index 2ebfccc3c..fbf1614e5 100644 --- a/addons/xterm-addon-serialize/package.json +++ b/addons/xterm-addon-serialize/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-serialize", - "version": "0.1.1", + "version": "0.1.2", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" @@ -18,6 +18,6 @@ "benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/addons/xterm-addon-serialize/benchmark/*benchmark.js" }, "peerDependencies": { - "xterm": "^3.14.0" + "xterm": "^4.0.0" } } diff --git a/addons/xterm-addon-web-links/src/WebLinkProvider.ts b/addons/xterm-addon-web-links/src/WebLinkProvider.ts new file mode 100644 index 000000000..d32a8166b --- /dev/null +++ b/addons/xterm-addon-web-links/src/WebLinkProvider.ts @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ILinkProvider, IBufferCellPosition, ILink, Terminal, IBuffer } from 'xterm'; + +export class WebLinkProvider implements ILinkProvider { + + constructor( + private readonly _terminal: Terminal, + private readonly _regex: RegExp, + private readonly _handler: (event: MouseEvent, uri: string) => void + ) { + + } + + provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void { + callback(LinkComputer.computeLink(position, this._regex, this._terminal, this._handler)); + } +} + +export class LinkComputer { + public static computeLink(position: IBufferCellPosition, regex: RegExp, terminal: Terminal, handler: (event: MouseEvent, uri: string) => void): ILink | undefined { + const rex = new RegExp(regex.source, (regex.flags || '') + 'g'); + + const [line, startLineIndex] = LinkComputer._translateBufferLineToStringWithWrap(position.y - 1, false, terminal); + + let match; + let stringIndex = -1; + + while ((match = rex.exec(line)) !== null) { + const text = match[1]; + if (!text) { + // 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 + console.log('match found without corresponding matchIndex'); + break; + } + + // 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 = line.indexOf(text, stringIndex + 1); + rex.lastIndex = stringIndex + text.length; + if (stringIndex < 0) { + // invalid stringIndex (should not have happened) + break; + } + + let endX = stringIndex + text.length + 1; + let endY = startLineIndex + 1; + + while (endX > terminal.cols) { + endX -= terminal.cols; + endY++; + } + + const range = { + start: { + x: stringIndex + 1, + y: startLineIndex + 1 + }, + end: { + x: endX, + y: endY + } + }; + + return { range, text, activate: handler }; + } + } + + /** + * Gets the entire line for the buffer line + * @param line The line being translated. + * @param trimRight Whether to trim whitespace to the right. + * @param terminal The terminal + */ + private static _translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean, terminal: Terminal): [string, number] { + let lineString = ''; + let lineWrapsToNext: boolean; + let prevLinesToWrap: boolean; + + do { + const line = terminal.buffer.getLine(lineIndex); + if (!line) { + break; + } + + if (line.isWrapped) { + lineIndex--; + } + + prevLinesToWrap = line.isWrapped; + } while (prevLinesToWrap); + + const startLineIndex = lineIndex; + + do { + const nextLine = terminal.buffer.getLine(lineIndex + 1); + lineWrapsToNext = nextLine ? nextLine.isWrapped : false; + const line = terminal.buffer.getLine(lineIndex); + if (!line) { + break; + } + lineString += line.translateToString(!lineWrapsToNext && trimRight).substring(0, terminal.cols); + lineIndex++; + } while (lineWrapsToNext); + + return [lineString, startLineIndex]; + } +} diff --git a/addons/xterm-addon-web-links/src/WebLinksAddon.api.ts b/addons/xterm-addon-web-links/src/WebLinksAddon.api.ts index ab61fdbfc..d2644e830 100644 --- a/addons/xterm-addon-web-links/src/WebLinksAddon.api.ts +++ b/addons/xterm-addon-web-links/src/WebLinksAddon.api.ts @@ -15,7 +15,7 @@ const width = 800; const height = 600; describe('WebLinksAddon', () => { - before(async function(): Promise { + before(async function (): Promise { this.timeout(10000); browser = await puppeteer.launch({ headless: process.argv.indexOf('--headless') !== -1, @@ -29,29 +29,29 @@ describe('WebLinksAddon', () => { await browser.close(); }); - beforeEach(async function(): Promise { + beforeEach(async function (): Promise { this.timeout(5000); await page.goto(APP); }); - it('.com', async function(): Promise { + it('.com', async function (): Promise { this.timeout(20000); await testHostName('foo.com'); }); - it('.com.au', async function(): Promise { + it('.com.au', async function (): Promise { this.timeout(20000); await testHostName('foo.com.au'); }); - it('.io', async function(): Promise { + it('.io', async function (): Promise { this.timeout(20000); await testHostName('foo.io'); }); }); async function testHostName(hostname: string): Promise { - await openTerminal({ rendererType: 'dom' }); + await openTerminal({ rendererType: 'dom', cols: 40 }); await page.evaluate(`window.term.loadAddon(new window.WebLinksAddon())`); const data = ` http://${hostname} \\r\\n` + ` http://${hostname}/a~b#c~d?e~f \\r\\n` + diff --git a/addons/xterm-addon-web-links/src/WebLinksAddon.ts b/addons/xterm-addon-web-links/src/WebLinksAddon.ts index 3290a59cf..8d0e40eea 100644 --- a/addons/xterm-addon-web-links/src/WebLinksAddon.ts +++ b/addons/xterm-addon-web-links/src/WebLinksAddon.ts @@ -1,9 +1,10 @@ /** - * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * Copyright (c) 2019 The xterm.js authors. All rights reserved. * @license MIT */ -import { Terminal, ILinkMatcherOptions, ITerminalAddon } from 'xterm'; +import { Terminal, ILinkMatcherOptions, ITerminalAddon, ILinkProvider, IDisposable } from 'xterm'; +import { WebLinkProvider } from './WebLinkProvider'; const protocolClause = '(https?:\\/\\/)'; const domainCharacterSet = '[\\da-z\\.-]+'; @@ -38,22 +39,32 @@ function handleLink(event: MouseEvent, uri: string): void { export class WebLinksAddon implements ITerminalAddon { private _linkMatcherId: number | undefined; private _terminal: Terminal | undefined; + private _linkProvider: IDisposable | undefined; constructor( private _handler: (event: MouseEvent, uri: string) => void = handleLink, - private _options: ILinkMatcherOptions = {} + private _options: ILinkMatcherOptions = {}, + private _useLinkProvider: boolean = false ) { this._options.matchIndex = 1; } public activate(terminal: Terminal): void { this._terminal = terminal; - this._linkMatcherId = this._terminal.registerLinkMatcher(strictUrlRegex, this._handler, this._options); + + if (this._useLinkProvider && 'registerLinkProvider' in this._terminal) { + this._linkProvider = this._terminal.registerLinkProvider(new WebLinkProvider(this._terminal, strictUrlRegex, this._handler)); + } else { + // TODO: This should be removed eventually + this._linkMatcherId = (this._terminal).registerLinkMatcher(strictUrlRegex, this._handler, this._options); + } } public dispose(): void { if (this._linkMatcherId !== undefined && this._terminal !== undefined) { this._terminal.deregisterLinkMatcher(this._linkMatcherId); } + + this._linkProvider?.dispose(); } } diff --git a/addons/xterm-addon-web-links/typings/xterm-addon-web-links.d.ts b/addons/xterm-addon-web-links/typings/xterm-addon-web-links.d.ts index 1c53bcdeb..f05647049 100644 --- a/addons/xterm-addon-web-links/typings/xterm-addon-web-links.d.ts +++ b/addons/xterm-addon-web-links/typings/xterm-addon-web-links.d.ts @@ -15,8 +15,12 @@ declare module 'xterm-addon-web-links' { * Creates a new web links addon. * @param handler The callback when the link is called. * @param options Options for the link matcher. + * @param useLinkProvider Whether to use the new link provider API to create + * the links. This is an option because use of both link matcher (old) and + * link provider (new) may cause issues. Link provider will eventually be + * the default and only option. */ - constructor(handler?: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions); + constructor(handler?: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions, useLinkProvider?: boolean); /** * Activates the addon diff --git a/addons/xterm-addon-webgl/src/renderLayer/LinkRenderLayer.ts b/addons/xterm-addon-webgl/src/renderLayer/LinkRenderLayer.ts index 118aedc31..ebf773d3b 100644 --- a/addons/xterm-addon-webgl/src/renderLayer/LinkRenderLayer.ts +++ b/addons/xterm-addon-webgl/src/renderLayer/LinkRenderLayer.ts @@ -18,6 +18,9 @@ export class LinkRenderLayer extends BaseRenderLayer { super(container, 'link', zIndex, true, colors); terminal.linkifier.onLinkHover(e => this._onLinkHover(e)); terminal.linkifier.onLinkLeave(e => this._onLinkLeave(e)); + + terminal.linkifier2.onLinkHover(e => this._onLinkHover(e)); + terminal.linkifier2.onLinkLeave(e => this._onLinkLeave(e)); } public resize(terminal: Terminal, dim: IRenderDimensions): void { diff --git a/demo/client.ts b/demo/client.ts index 6174216c0..b009a534e 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -152,7 +152,8 @@ function createTerminal(): void { addons.serialize.instance = new SerializeAddon(); addons.fit.instance = new FitAddon(); addons.unicode11.instance = new Unicode11Addon(); - addons['web-links'].instance = new WebLinksAddon(); + // TODO: Remove arguments when link provider API is the default + addons['web-links'].instance = new WebLinksAddon(undefined, undefined, true); typedTerm.loadAddon(addons.fit.instance); typedTerm.loadAddon(addons.search.instance); typedTerm.loadAddon(addons.serialize.instance); diff --git a/demo/index.html b/demo/index.html index 4d5149f35..65408fd7a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -13,7 +13,7 @@

Options

-

These options can be set in the Terminal constructor or using the Terminal.setOption function.

+

These options can be set in the Terminal constructor or by using the Terminal.setOption function.

diff --git a/src/Terminal.ts b/src/Terminal.ts index 1bcac1f05..c432e2132 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -37,7 +37,7 @@ import * as Strings from 'browser/LocalizableStrings'; import { SoundService } from 'browser/services/SoundService'; import { MouseZoneManager } from 'browser/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; -import { ITheme, IMarker, IDisposable, ISelectionPosition } from 'xterm'; +import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider } from 'xterm'; import { DomRenderer } from 'browser/renderer/dom/DomRenderer'; import { IKeyboardEvent, KeyboardResultType, IBufferLine, IAttributeData, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; @@ -57,11 +57,12 @@ import { MouseService } from 'browser/services/MouseService'; import { IParams, IFunctionIdentifier } from 'common/parser/Types'; import { CoreService } from 'common/services/CoreService'; import { LogService } from 'common/services/LogService'; -import { ILinkifier, IMouseZoneManager, LinkMatcherHandler, ILinkMatcherOptions, IViewport } from 'browser/Types'; +import { ILinkifier, IMouseZoneManager, LinkMatcherHandler, ILinkMatcherOptions, IViewport, ILinkifier2 } from 'browser/Types'; import { DirtyRowService } from 'common/services/DirtyRowService'; import { InstantiationService } from 'common/services/InstantiationService'; import { CoreMouseService } from 'common/services/CoreMouseService'; import { WriteBuffer } from 'common/input/WriteBuffer'; +import { Linkifier2 } from 'browser/Linkifier2'; import { CoreBrowserService } from 'browser/services/CoreBrowserService'; import { UnicodeService } from 'common/services/UnicodeService'; import { CharsetService } from 'common/services/CharsetService'; @@ -131,6 +132,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp private _inputHandler: InputHandler; public linkifier: ILinkifier; + public linkifier2: ILinkifier2; public viewport: IViewport; private _compositionHelper: ICompositionHelper; private _mouseZoneManager: IMouseZoneManager; @@ -228,7 +230,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this._windowsMode = undefined; this._renderService?.dispose(); this._customKeyEventHandler = null; - this.write = () => {}; + this.write = () => { }; this.element?.parentNode?.removeChild(this.element); } @@ -257,6 +259,9 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp if (!this.linkifier) { this.linkifier = new Linkifier(this._bufferService, this._logService, this.optionsService, this.unicodeService); } + if (!this.linkifier2) { + this.linkifier2 = new Linkifier2(this._bufferService); + } if (this.options.windowsMode) { this._enableWindowsMode(); @@ -585,6 +590,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp this.register(this._mouseZoneManager); this.register(this.onScroll(() => this._mouseZoneManager.clearAll())); this.linkifier.attachToDom(this.element, this._mouseZoneManager); + this.linkifier2.attachToDom(this.element, this._mouseService, this._renderService); // This event listener must be registered aftre MouseZoneManager is created this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService.onMouseDown(e))); @@ -619,8 +625,8 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp private _createRenderer(): IRenderer { switch (this.options.rendererType) { - case 'canvas': return this._instantiationService.createInstance(Renderer, this._colorManager.colors, this.screenElement, this.linkifier); - case 'dom': return this._instantiationService.createInstance(DomRenderer, this._colorManager.colors, this.element, this.screenElement, this._viewportElement, this.linkifier); + case 'canvas': return this._instantiationService.createInstance(Renderer, this._colorManager.colors, this.screenElement, this.linkifier, this.linkifier2); + case 'dom': return this._instantiationService.createInstance(DomRenderer, this._colorManager.colors, this.element, this.screenElement, this._viewportElement, this.linkifier, this.linkifier2); default: throw new Error(`Unrecognized rendererType "${this.options.rendererType}"`); } } @@ -729,13 +735,13 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp * Note: 'mousedown' currently is "always on" and not managed * by onProtocolChange. */ - const requestedEvents: {[key: string]: ((ev: Event) => void) | null} = { + const requestedEvents: { [key: string]: ((ev: Event) => void) | null } = { mouseup: null, wheel: null, mousedrag: null, mousemove: null }; - const eventListeners: {[key: string]: (ev: Event) => void} = { + const eventListeners: { [key: string]: (ev: Event) => void } = { mouseup: (ev: MouseEvent) => { sendEvent(ev); if (!ev.buttons) { @@ -858,7 +864,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp } // Construct and send sequences - const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + ( ev.deltaY < 0 ? 'A' : 'B'); + const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B'); let data = ''; for (let i = 0; i < Math.abs(amount); i++) { data += sequence; @@ -1121,6 +1127,10 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp } } + public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { + return this.linkifier2.registerLinkProvider(linkProvider); + } + public registerCharacterJoiner(handler: CharacterJoinerHandler): number { const joinerId = this._renderService.registerCharacterJoiner(handler); this.refresh(0, this.rows - 1); @@ -1273,8 +1283,8 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp private _isThirdLevelShift(browser: IBrowser, ev: IKeyboardEvent): boolean { const thirdLevelKey = - (browser.isMac && !this.options.macOptionIsMeta && ev.altKey && !ev.ctrlKey && !ev.metaKey) || - (browser.isWindows && ev.altKey && ev.ctrlKey && !ev.metaKey); + (browser.isMac && !this.options.macOptionIsMeta && ev.altKey && !ev.ctrlKey && !ev.metaKey) || + (browser.isWindows && ev.altKey && ev.ctrlKey && !ev.metaKey); if (ev.type === 'keypress') { return thirdLevelKey; diff --git a/src/TestUtils.test.ts b/src/TestUtils.test.ts index c96cd05be..d0bc906dd 100644 --- a/src/TestUtils.test.ts +++ b/src/TestUtils.test.ts @@ -9,10 +9,10 @@ import { IBuffer, IBufferStringIterator, IBufferSet } from 'common/buffer/Types' import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, ICharset } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; import * as Browser from 'common/Platform'; -import { IDisposable, IMarker, IEvent, ISelectionPosition } from 'xterm'; +import { IDisposable, IMarker, IEvent, ISelectionPosition, ILinkProvider } from 'xterm'; import { Terminal } from './Terminal'; import { AttributeData } from 'common/buffer/AttributeData'; -import { IColorManager, IColorSet, ILinkMatcherOptions, ILinkifier, IViewport } from 'browser/Types'; +import { IColorManager, IColorSet, ILinkMatcherOptions, ILinkifier, IViewport, ILinkifier2 } from 'browser/Types'; import { IOptionsService, IUnicodeService } from 'common/services/Services'; import { EventEmitter } from 'common/EventEmitter'; import { IParams, IFunctionIdentifier } from 'common/parser/Types'; @@ -94,6 +94,9 @@ export class MockTerminal implements ITerminal { deregisterLinkMatcher(matcherId: number): void { throw new Error('Method not implemented.'); } + registerLinkProvider(linkProvider: ILinkProvider): IDisposable { + throw new Error('Method not implemented.'); + } hasSelection(): boolean { throw new Error('Method not implemented.'); } @@ -136,6 +139,7 @@ export class MockTerminal implements ITerminal { bracketedPasteMode: boolean; renderer: IRenderer; linkifier: ILinkifier; + linkifier2: ILinkifier2; isFocused: boolean; options: ITerminalOptions = {}; element: HTMLElement; @@ -300,16 +304,16 @@ export class MockRenderer implements IRenderer { setColors(colors: IColorSet): void { throw new Error('Method not implemented.'); } - onResize(cols: number, rows: number): void {} - onCharSizeChanged(): void {} - onBlur(): void {} - onFocus(): void {} - onSelectionChanged(start: [number, number], end: [number, number]): void {} - onCursorMove(): void {} - onOptionsChanged(): void {} - onDevicePixelRatioChange(): void {} - clear(): void {} - renderRows(start: number, end: number): void {} + onResize(cols: number, rows: number): void { } + onCharSizeChanged(): void { } + onBlur(): void { } + onFocus(): void { } + onSelectionChanged(start: [number, number], end: [number, number]): void { } + onCursorMove(): void { } + onOptionsChanged(): void { } + onDevicePixelRatioChange(): void { } + clear(): void { } + renderRows(start: number, end: number): void { } registerCharacterJoiner(handler: CharacterJoinerHandler): number { return 0; } deregisterCharacterJoiner(): boolean { return true; } } diff --git a/src/Types.d.ts b/src/Types.d.ts index e304ddc18..cb0ba8444 100644 --- a/src/Types.d.ts +++ b/src/Types.d.ts @@ -3,10 +3,10 @@ * @license MIT */ -import { ITerminalOptions as IPublicTerminalOptions, IDisposable, IMarker, ISelectionPosition } from 'xterm'; +import { ITerminalOptions as IPublicTerminalOptions, IDisposable, IMarker, ISelectionPosition, ILinkProvider } from 'xterm'; import { ICharset, IAttributeData, CharData, CoreMouseEventType } from 'common/Types'; import { IEvent, IEventEmitter } from 'common/EventEmitter'; -import { IColorSet, ILinkifier, ILinkMatcherOptions, IViewport } from 'browser/Types'; +import { IColorSet, ILinkifier, ILinkMatcherOptions, IViewport, ILinkifier2 } from 'browser/Types'; import { IOptionsService, IUnicodeService } from 'common/services/Services'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IParams, IFunctionIdentifier } from 'common/parser/Types'; @@ -179,6 +179,7 @@ export interface IPublicTerminal extends IDisposable { addOscHandler(ident: number, callback: (data: string) => boolean): IDisposable; registerLinkMatcher(regex: RegExp, handler: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions): number; deregisterLinkMatcher(matcherId: number): void; + registerLinkProvider(linkProvider: ILinkProvider): IDisposable; registerCharacterJoiner(handler: (text: string) => [number, number][]): number; deregisterCharacterJoiner(joinerId: number): void; addMarker(cursorYOffset: number): IMarker; @@ -212,6 +213,7 @@ export interface IElementAccessor { export interface ILinkifierAccessor { linkifier: ILinkifier; + linkifier2: ILinkifier2; } // TODO: The options that are not in the public API should be reviewed diff --git a/src/browser/Linkifier2.ts b/src/browser/Linkifier2.ts new file mode 100644 index 000000000..2eeec1dff --- /dev/null +++ b/src/browser/Linkifier2.ts @@ -0,0 +1,243 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ILinkifier2, ILinkProvider, IBufferCellPosition, ILink, ILinkifierEvent } from './Types'; +import { IDisposable } from 'common/Types'; +import { IMouseService, IRenderService } from './services/Services'; +import { IBufferService, ICoreService } from 'common/services/Services'; +import { EventEmitter, IEvent } from 'common/EventEmitter'; + +export class Linkifier2 implements ILinkifier2 { + private _element: HTMLElement | undefined; + private _mouseService: IMouseService | undefined; + private _renderService: IRenderService | undefined; + private _linkProviders: ILinkProvider[] = []; + private _currentLink: ILink | undefined; + private _lastMouseEvent: MouseEvent | undefined; + private _linkCacheDisposables: IDisposable[] = []; + private _lastBufferCell: IBufferCellPosition | undefined; + + private _onLinkHover = new EventEmitter(); + public get onLinkHover(): IEvent { return this._onLinkHover.event; } + private _onLinkLeave = new EventEmitter(); + public get onLinkLeave(): IEvent { return this._onLinkLeave.event; } + + constructor( + private readonly _bufferService: IBufferService + ) { + + } + + public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { + this._linkProviders.push(linkProvider); + return { + dispose: () => { + // Remove the link provider from the list + const providerIndex = this._linkProviders.indexOf(linkProvider); + + if (providerIndex !== -1) { + this._linkProviders.splice(providerIndex, 1); + } + } + }; + } + + public attachToDom(element: HTMLElement, mouseService: IMouseService, renderService: IRenderService): void { + this._element = element; + this._mouseService = mouseService; + this._renderService = renderService; + + this._element.addEventListener('mousemove', this._onMouseMove.bind(this)); + this._element.addEventListener('click', this._onMouseDown.bind(this)); + } + + private _onMouseMove(event: MouseEvent): void { + this._lastMouseEvent = event; + + if (!this._element || !this._mouseService) { + return; + } + + const position = this._positionFromMouseEvent(event, this._element, this._mouseService); + + if (!position) { + return; + } + + if (!this._lastBufferCell || (position.x !== this._lastBufferCell.x || position.y !== this._lastBufferCell.y)) { + this._onHover(position); + this._lastBufferCell = position; + } + } + + private _onHover(position: IBufferCellPosition): void { + if (this._currentLink) { + // Check the if the link is in the mouse position + const isInPosition = this._linkAtPosition(this._currentLink, position); + + // Check if we need to clear the link + if (!isInPosition) { + this._clearCurrentLink(); + this._askForLink(position); + } + } else { + this._askForLink(position); + } + } + + private _askForLink(position: IBufferCellPosition): void { + const providerReplies: Map = new Map(); + let linkProvided = false; + + // There is no link cached, so ask for one + this._linkProviders.forEach((linkProvider, i) => { + linkProvider.provideLink(position, (link: ILink | undefined) => { + providerReplies.set(i, link); + + // Check if every provider before this one has come back undefined + let hasLinkBefore = false; + for (let j = 0; j < i; j++) { + if (!providerReplies.has(j) || providerReplies.get(j)) { + hasLinkBefore = true; + } + } + + // If all providers with higher priority came back undefined, then this link should be used + if (!hasLinkBefore && link) { + linkProvided = true; + this._handleNewLink(link); + } + + // Check if all the providers have responded + if (providerReplies.size === this._linkProviders.length && !linkProvided) { + // Respect the order of the link providers + for (let j = 0; j < providerReplies.size; j++) { + const currentLink = providerReplies.get(j); + if (currentLink) { + this._handleNewLink(currentLink); + break; + } + } + } + }); + }); + } + + private _onMouseDown(event: MouseEvent): void { + if (!this._element || !this._mouseService || !this._currentLink) { + return; + } + + const position = this._positionFromMouseEvent(event, this._element, this._mouseService); + + if (!position) { + return; + } + + if (this._linkAtPosition(this._currentLink, position)) { + this._currentLink.activate(event, this._currentLink.text); + } + } + + private _clearCurrentLink(startRow?: number, endRow?: number): void { + if (!this._element || !this._currentLink || !this._lastMouseEvent) { + return; + } + + // If we have a start and end row, check that the link is within it + if (!startRow || !endRow || (this._currentLink.range.start.y >= startRow && this._currentLink.range.end.y <= endRow)) { + this._linkLeave(this._element, this._currentLink, this._lastMouseEvent); + this._currentLink = undefined; + this._linkCacheDisposables.forEach(l => l.dispose()); + this._linkCacheDisposables = []; + } + } + + private _handleNewLink(link: ILink): void { + if (!this._element || !this._lastMouseEvent || !this._mouseService) { + return; + } + + const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService); + + if (!position) { + return; + } + + // Trigger hover if the we have a link at the position + if (this._linkAtPosition(link, position)) { + this._currentLink = link; + this._linkHover(this._element, link, this._lastMouseEvent); + + // Add listener for rerendering + if (this._renderService) { + this._linkCacheDisposables.push(this._renderService.onRender(e => { + this._clearCurrentLink(e.start + 1 + this._bufferService.buffer.ydisp, e.end + 1 + this._bufferService.buffer.ydisp); + })); + } + } + } + + private _linkHover(element: HTMLElement, link: ILink, event: MouseEvent): void { + const range = link.range; + const scrollOffset = this._bufferService.buffer.ydisp; + + this._onLinkHover.fire(this._createLinkHoverEvent(range.start.x - 1, range.start.y - scrollOffset - 1, range.end.x - 1, range.end.y - scrollOffset - 1, undefined)); + element.classList.add('xterm-cursor-pointer'); + + if (link.hover) { + link.hover(event, link.text); + } + } + + private _linkLeave(element: HTMLElement, link: ILink, event: MouseEvent): void { + const range = link.range; + const scrollOffset = this._bufferService.buffer.ydisp; + + this._onLinkLeave.fire(this._createLinkHoverEvent(range.start.x - 1, range.start.y - scrollOffset - 1, range.end.x - 1, range.end.y - scrollOffset - 1, undefined)); + element.classList.remove('xterm-cursor-pointer'); + + if (link.leave) { + link.leave(event, link.text); + } + } + + /** + * Check if the buffer position is within the link + * @param link + * @param position + */ + private _linkAtPosition(link: ILink, position: IBufferCellPosition): boolean { + const sameLine = link.range.start.y === link.range.end.y; + const wrappedFromLeft = link.range.start.y < position.y; + const wrappedToRight = link.range.end.y > position.y; + + // If the start and end have the same y, then the position must be between start and end x + // If not, then handle each case seperately, depending on which way it wraps + return ((sameLine && link.range.start.x <= position.x && link.range.end.x >= position.x) || + (wrappedFromLeft && link.range.end.x >= position.x) || + (wrappedToRight && link.range.start.x <= position.x) || + (wrappedFromLeft && wrappedToRight)) && + link.range.start.y <= position.y && + link.range.end.y >= position.y; + } + + /** + * Get the buffer position from a mouse event + * @param event + */ + private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement, mouseService: IMouseService): IBufferCellPosition | undefined { + const coords = mouseService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows); + if (!coords) { + return; + } + + return { x: coords[0], y: coords[1] + this._bufferService.buffer.ydisp }; + } + + private _createLinkHoverEvent(x1: number, y1: number, x2: number, y2: number, fg: number | undefined): ILinkifierEvent { + return { x1, y1, x2, y2, cols: this._bufferService.cols, fg }; + } +} diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 31d00af85..c2b8604eb 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -5,6 +5,7 @@ import { IEvent } from 'common/EventEmitter'; import { IDisposable } from 'common/Types'; +import { IMouseService, IRenderService } from './services/Services'; export interface IColorManager { colors: IColorSet; @@ -105,6 +106,14 @@ export interface ILinkifier { deregisterLinkMatcher(matcherId: number): boolean; } +export interface ILinkifier2 { + onLinkHover: IEvent; + onLinkLeave: IEvent; + + attachToDom(element: HTMLElement, mouseService: IMouseService, renderService: IRenderService): void; + registerLinkProvider(linkProvider: ILinkProvider): IDisposable; +} + export interface ILinkMatcherOptions { /** * The index of the link from the regex.match(text) call. This defaults to 0 @@ -155,3 +164,25 @@ export interface IMouseZone { leaveCallback: () => any | undefined; willLinkActivate: (e: MouseEvent) => boolean; } + +interface ILinkProvider { + provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void; +} + +interface ILink { + range: IBufferRange; + text: string; + activate(event: MouseEvent, text: string): void; + hover?(event: MouseEvent, text: string): void; + leave?(event: MouseEvent, text: string): void; +} + +interface IBufferRange { + start: IBufferCellPosition; + end: IBufferCellPosition; +} + +interface IBufferCellPosition { + x: number; + y: number; +} diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index b88f381d8..29edce6f7 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -167,7 +167,7 @@ export class Viewport extends Disposable implements IViewport { private _bubbleScroll(ev: Event, amount: number): boolean { const scrollPosFromTop = this._viewportElement.scrollTop + this._lastRecordedViewportHeight; if ((amount < 0 && this._viewportElement.scrollTop !== 0) || - (amount > 0 && scrollPosFromTop < this._lastRecordedBufferHeight)) { + (amount > 0 && scrollPosFromTop < this._lastRecordedBufferHeight)) { if (ev.cancelable) { ev.preventDefault(); } @@ -235,8 +235,8 @@ export class Viewport extends Disposable implements IViewport { const modifier = this._optionsService.options.fastScrollModifier; // Multiply the scroll speed when the modifier is down if ((modifier === 'alt' && ev.altKey) || - (modifier === 'ctrl' && ev.ctrlKey) || - (modifier === 'shift' && ev.shiftKey)) { + (modifier === 'ctrl' && ev.ctrlKey) || + (modifier === 'shift' && ev.shiftKey)) { return amount * this._optionsService.options.fastScrollSensitivity * this._optionsService.options.scrollSensitivity; } diff --git a/src/browser/renderer/LinkRenderLayer.ts b/src/browser/renderer/LinkRenderLayer.ts index a7be54ee4..73e9f85f0 100644 --- a/src/browser/renderer/LinkRenderLayer.ts +++ b/src/browser/renderer/LinkRenderLayer.ts @@ -7,7 +7,7 @@ import { IRenderDimensions } from 'browser/renderer/Types'; import { BaseRenderLayer } from './BaseRenderLayer'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; import { is256Color } from 'browser/renderer/atlas/CharAtlasUtils'; -import { IColorSet, ILinkifierEvent, ILinkifier } from 'browser/Types'; +import { IColorSet, ILinkifierEvent, ILinkifier, ILinkifier2 } from 'browser/Types'; import { IBufferService, IOptionsService } from 'common/services/Services'; export class LinkRenderLayer extends BaseRenderLayer { @@ -19,12 +19,16 @@ export class LinkRenderLayer extends BaseRenderLayer { colors: IColorSet, rendererId: number, linkifier: ILinkifier, + linkifier2: ILinkifier2, readonly bufferService: IBufferService, readonly optionsService: IOptionsService ) { super(container, 'link', zIndex, true, colors, rendererId, bufferService, optionsService); linkifier.onLinkHover(e => this._onLinkHover(e)); linkifier.onLinkLeave(e => this._onLinkLeave(e)); + + linkifier2.onLinkHover(e => this._onLinkHover(e)); + linkifier2.onLinkLeave(e => this._onLinkLeave(e)); } public resize(dim: IRenderDimensions): void { diff --git a/src/browser/renderer/Renderer.ts b/src/browser/renderer/Renderer.ts index 26b464cbd..a1161d4f2 100644 --- a/src/browser/renderer/Renderer.ts +++ b/src/browser/renderer/Renderer.ts @@ -10,7 +10,7 @@ import { IRenderLayer, IRenderer, IRenderDimensions, CharacterJoinerHandler, ICh import { LinkRenderLayer } from 'browser/renderer/LinkRenderLayer'; import { CharacterJoinerRegistry } from 'browser/renderer/CharacterJoinerRegistry'; import { Disposable } from 'common/Lifecycle'; -import { IColorSet, ILinkifier } from 'browser/Types'; +import { IColorSet, ILinkifier, ILinkifier2 } from 'browser/Types'; import { ICharSizeService, ICoreBrowserService } from 'browser/services/Services'; import { IBufferService, IOptionsService, ICoreService } from 'common/services/Services'; import { removeTerminalFromCache } from 'browser/renderer/atlas/CharAtlasCache'; @@ -33,7 +33,8 @@ export class Renderer extends Disposable implements IRenderer { constructor( private _colors: IColorSet, private readonly _screenElement: HTMLElement, - private readonly _linkifier: ILinkifier, + readonly linkifier: ILinkifier, + readonly linkifier2: ILinkifier2, @IBufferService private readonly _bufferService: IBufferService, @ICharSizeService private readonly _charSizeService: ICharSizeService, @IOptionsService private readonly _optionsService: IOptionsService, @@ -47,7 +48,7 @@ export class Renderer extends Disposable implements IRenderer { this._renderLayers = [ new TextRenderLayer(this._screenElement, 0, this._colors, this._characterJoinerRegistry, allowTransparency, this._id, this._bufferService, _optionsService), new SelectionRenderLayer(this._screenElement, 1, this._colors, this._id, this._bufferService, _optionsService), - new LinkRenderLayer(this._screenElement, 2, this._colors, this._id, this._linkifier, this._bufferService, _optionsService), + new LinkRenderLayer(this._screenElement, 2, this._colors, this._id, linkifier, linkifier2, this._bufferService, _optionsService), new CursorRenderLayer(this._screenElement, 3, this._colors, this._id, this._onRequestRefreshRows, this._bufferService, _optionsService, coreService, coreBrowserService) ]; this.dimensions = { diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 8f87a8e34..c2464d0e8 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -7,7 +7,7 @@ import { IRenderer, IRenderDimensions, CharacterJoinerHandler, IRequestRefreshRo import { BOLD_CLASS, ITALIC_CLASS, CURSOR_CLASS, CURSOR_STYLE_BLOCK_CLASS, CURSOR_BLINK_CLASS, CURSOR_STYLE_BAR_CLASS, CURSOR_STYLE_UNDERLINE_CLASS, DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; import { Disposable } from 'common/Lifecycle'; -import { IColorSet, ILinkifierEvent, ILinkifier } from 'browser/Types'; +import { IColorSet, ILinkifierEvent, ILinkifier, ILinkifier2 } from 'browser/Types'; import { ICharSizeService } from 'browser/services/Services'; import { IOptionsService, IBufferService } from 'common/services/Services'; import { EventEmitter, IEvent } from 'common/EventEmitter'; @@ -48,6 +48,7 @@ export class DomRenderer extends Disposable implements IRenderer { private readonly _screenElement: HTMLElement, private readonly _viewportElement: HTMLElement, private readonly _linkifier: ILinkifier, + private readonly _linkifier2: ILinkifier2, @ICharSizeService private readonly _charSizeService: ICharSizeService, @IOptionsService private readonly _optionsService: IOptionsService, @IBufferService private readonly _bufferService: IBufferService @@ -88,6 +89,9 @@ export class DomRenderer extends Disposable implements IRenderer { this._linkifier.onLinkHover(e => this._onLinkHover(e)); this._linkifier.onLinkLeave(e => this._onLinkLeave(e)); + + this._linkifier2.onLinkHover(e => this._onLinkHover(e)); + this._linkifier2.onLinkLeave(e => this._onLinkLeave(e)); } public dispose(): void { @@ -127,12 +131,12 @@ export class DomRenderer extends Disposable implements IRenderer { } const styles = - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` + - ` display: inline-block;` + - ` height: 100%;` + - ` vertical-align: top;` + - ` width: ${this.dimensions.actualCellWidth}px` + - `}`; + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` + + ` display: inline-block;` + + ` height: 100%;` + + ` vertical-align: top;` + + ` width: ${this.dimensions.actualCellWidth}px` + + `}`; this._dimensionsStyleElement.innerHTML = styles; @@ -154,84 +158,84 @@ export class DomRenderer extends Disposable implements IRenderer { // Base CSS let styles = - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` + - ` color: ${this._colors.foreground.css};` + - ` font-family: ${this._optionsService.options.fontFamily};` + - ` font-size: ${this._optionsService.options.fontSize}px;` + - `}`; + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` + + ` color: ${this._colors.foreground.css};` + + ` font-family: ${this._optionsService.options.fontFamily};` + + ` font-size: ${this._optionsService.options.fontSize}px;` + + `}`; // Text styles styles += - `${this._terminalSelector} span:not(.${BOLD_CLASS}) {` + - ` font-weight: ${this._optionsService.options.fontWeight};` + - `}` + - `${this._terminalSelector} span.${BOLD_CLASS} {` + - ` font-weight: ${this._optionsService.options.fontWeightBold};` + - `}` + - `${this._terminalSelector} span.${ITALIC_CLASS} {` + - ` font-style: italic;` + - `}`; + `${this._terminalSelector} span:not(.${BOLD_CLASS}) {` + + ` font-weight: ${this._optionsService.options.fontWeight};` + + `}` + + `${this._terminalSelector} span.${BOLD_CLASS} {` + + ` font-weight: ${this._optionsService.options.fontWeightBold};` + + `}` + + `${this._terminalSelector} span.${ITALIC_CLASS} {` + + ` font-style: italic;` + + `}`; // Blink animation styles += - `@keyframes blink_box_shadow` + `_` + this._terminalClass + ` {` + - ` 50% {` + - ` box-shadow: none;` + - ` }` + - `}`; + `@keyframes blink_box_shadow` + `_` + this._terminalClass + ` {` + + ` 50% {` + + ` box-shadow: none;` + + ` }` + + `}`; styles += - `@keyframes blink_block` + `_` + this._terminalClass + ` {` + - ` 0% {` + - ` background-color: ${this._colors.cursor.css};` + - ` color: ${this._colors.cursorAccent.css};` + - ` }` + - ` 50% {` + - ` background-color: ${this._colors.cursorAccent.css};` + - ` color: ${this._colors.cursor.css};` + - ` }` + - `}`; + `@keyframes blink_block` + `_` + this._terminalClass + ` {` + + ` 0% {` + + ` background-color: ${this._colors.cursor.css};` + + ` color: ${this._colors.cursorAccent.css};` + + ` }` + + ` 50% {` + + ` background-color: ${this._colors.cursorAccent.css};` + + ` color: ${this._colors.cursor.css};` + + ` }` + + `}`; // Cursor styles += - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` + - ` outline: 1px solid ${this._colors.cursor.css};` + - ` outline-offset: -1px;` + - `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}:not(.${CURSOR_STYLE_BLOCK_CLASS}) {` + - ` animation: blink_box_shadow` + `_` + this._terminalClass + ` 1s step-end infinite;` + - `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` + - ` animation: blink_block` + `_` + this._terminalClass + ` 1s step-end infinite;` + - `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` + - ` background-color: ${this._colors.cursor.css};` + - ` color: ${this._colors.cursorAccent.css};` + - `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BAR_CLASS} {` + - ` box-shadow: ${this._optionsService.options.cursorWidth}px 0 0 ${this._colors.cursor.css} inset;` + - `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_UNDERLINE_CLASS} {` + - ` box-shadow: 0 -1px 0 ${this._colors.cursor.css} inset;` + - `}`; + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` + + ` outline: 1px solid ${this._colors.cursor.css};` + + ` outline-offset: -1px;` + + `}` + + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}:not(.${CURSOR_STYLE_BLOCK_CLASS}) {` + + ` animation: blink_box_shadow` + `_` + this._terminalClass + ` 1s step-end infinite;` + + `}` + + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` + + ` animation: blink_block` + `_` + this._terminalClass + ` 1s step-end infinite;` + + `}` + + `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` + + ` background-color: ${this._colors.cursor.css};` + + ` color: ${this._colors.cursorAccent.css};` + + `}` + + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BAR_CLASS} {` + + ` box-shadow: ${this._optionsService.options.cursorWidth}px 0 0 ${this._colors.cursor.css} inset;` + + `}` + + `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_UNDERLINE_CLASS} {` + + ` box-shadow: 0 -1px 0 ${this._colors.cursor.css} inset;` + + `}`; // Selection styles += - `${this._terminalSelector} .${SELECTION_CLASS} {` + - ` position: absolute;` + - ` top: 0;` + - ` left: 0;` + - ` z-index: 1;` + - ` pointer-events: none;` + - `}` + - `${this._terminalSelector} .${SELECTION_CLASS} div {` + - ` position: absolute;` + - ` background-color: ${this._colors.selection.css};` + - `}`; + `${this._terminalSelector} .${SELECTION_CLASS} {` + + ` position: absolute;` + + ` top: 0;` + + ` left: 0;` + + ` z-index: 1;` + + ` pointer-events: none;` + + `}` + + `${this._terminalSelector} .${SELECTION_CLASS} div {` + + ` position: absolute;` + + ` background-color: ${this._colors.selection.css};` + + `}`; // Colors this._colors.ansi.forEach((c, i) => { styles += - `${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` + - `${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`; + `${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` + + `${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`; }); styles += - `${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(this._colors.background).css}; }` + - `${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${this._colors.foreground.css}; }`; + `${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(this._colors.background).css}; }` + + `${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${this._colors.foreground.css}; }`; this._themeStyleElement.innerHTML = styles; } diff --git a/src/public/Terminal.ts b/src/public/Terminal.ts index 065dfb645..da03c127d 100644 --- a/src/public/Terminal.ts +++ b/src/public/Terminal.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBuffer as IBufferApi, IBufferLine as IBufferLineApi, IBufferCell as IBufferCellApi, IParser, IFunctionIdentifier, IUnicodeHandling, IUnicodeVersionProvider } from 'xterm'; +import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ISelectionPosition, IBuffer as IBufferApi, IBufferLine as IBufferLineApi, IBufferCell as IBufferCellApi, IParser, IFunctionIdentifier, ILinkProvider, IUnicodeHandling, IUnicodeVersionProvider } from 'xterm'; import { ITerminal } from '../Types'; import { IBufferLine, ICellData } from 'common/Types'; import { IBuffer } from 'common/buffer/Types'; @@ -72,6 +72,9 @@ export class Terminal implements ITerminalApi { public deregisterLinkMatcher(matcherId: number): void { this._core.deregisterLinkMatcher(matcherId); } + public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { + return this._core.registerLinkProvider(linkProvider); + } public registerCharacterJoiner(handler: (text: string) => [number, number][]): number { return this._core.registerCharacterJoiner(handler); } @@ -229,7 +232,7 @@ class BufferLineApiView implements IBufferLineApi { } class ParserApi implements IParser { - constructor(private _core: ITerminal) {} + constructor(private _core: ITerminal) { } public registerCsiHandler(id: IFunctionIdentifier, callback: (params: (number | number[])[]) => boolean): IDisposable { return this._core.addCsiHandler(id, (params: IParams) => callback(params.toArray())); @@ -258,7 +261,7 @@ class ParserApi implements IParser { } class UnicodeApi implements IUnicodeHandling { - constructor(private _core: ITerminal) {} + constructor(private _core: ITerminal) { } public register(provider: IUnicodeVersionProvider): void { this._core.unicodeService.register(provider); diff --git a/test/api/Terminal.api.ts b/test/api/Terminal.api.ts index cdfab61f3..36966bfb5 100644 --- a/test/api/Terminal.api.ts +++ b/test/api/Terminal.api.ts @@ -524,6 +524,142 @@ describe('API Integration Tests', function(): void { await page.evaluate(`window.term.dispose()`); assert.equal(await page.evaluate(`window.term._core._isDisposed`), true); }); + + describe('registerLinkProvider', () => { + it('should fire provideLink when hovering cells', async () => { + await openTerminal({ rendererType: 'dom' }); + await page.evaluate(` + window.calls = []; + window.disposable = window.term.registerLinkProvider({ + provideLink: (position, cb) => { + calls.push(position); + cb(undefined); + } + }); + `); + const dims = await getDimensions(); + await moveMouseCell(page, dims, 1, 1); + await moveMouseCell(page, dims, 2, 2); + await moveMouseCell(page, dims, 10, 4); + await pollFor(page, `window.calls`, [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 10, y: 4 }]); + await page.evaluate(`window.disposable.dispose()`); + }); + + it('should fire hover and leave events on the link', async () => { + await openTerminal({ rendererType: 'dom' }); + await writeSync(page, 'foo bar baz'); + // Wait for renderer to catch up as links are cleared on render + await pollFor(page, `document.querySelector('.xterm-rows').textContent`, 'foo bar baz '); + await page.evaluate(` + window.calls = []; + window.disposable = window.term.registerLinkProvider({ + provideLink: (position, cb) => { + window.calls.push('provide ' + position.x + ',' + position.y); + if (position.x >= 5 && position.x <= 7 && position.y === 1) { + window.calls.push('match'); + cb({ + range: { start: { x: 5, y: 1 }, end: { x: 7, y: 1 } }, + text: 'bar', + activate: () => window.calls.push('activate'), + hover: () => window.calls.push('hover'), + leave: () => window.calls.push('leave') + }); + } + } + }); + `); + const dims = await getDimensions(); + await moveMouseCell(page, dims, 5, 1); + await pollFor(page, `window.calls`, ['provide 5,1', 'match', 'hover']); + await moveMouseCell(page, dims, 4, 1); + await pollFor(page, `window.calls`, ['provide 5,1', 'match', 'hover', 'leave', 'provide 4,1']); + await moveMouseCell(page, dims, 7, 1); + await pollFor(page, `window.calls`, ['provide 5,1', 'match', 'hover', 'leave', 'provide 4,1', 'provide 7,1', 'match', 'hover']); + await moveMouseCell(page, dims, 8, 1); + await pollFor(page, `window.calls`, ['provide 5,1', 'match', 'hover', 'leave', 'provide 4,1', 'provide 7,1', 'match', 'hover', 'leave', 'provide 8,1']); + await page.evaluate(`window.disposable.dispose()`); + }); + + it('should work fine when hover and leave callbacks are not provided', async () => { + await openTerminal({ rendererType: 'dom' }); + await writeSync(page, 'foo bar baz'); + // Wait for renderer to catch up as links are cleared on render + await pollFor(page, `document.querySelector('.xterm-rows').textContent`, 'foo bar baz '); + await page.evaluate(` + window.calls = []; + window.disposable = window.term.registerLinkProvider({ + provideLink: (position, cb) => { + window.calls.push('provide ' + position.x + ',' + position.y); + if (position.x >= 5 && position.x <= 7 && position.y === 1) { + window.calls.push('match'); + cb({ + range: { start: { x: 5, y: 1 }, end: { x: 7, y: 1 } }, + text: 'bar', + activate: () => window.calls.push('activate') + }); + } + } + }); + `); + const dims = await getDimensions(); + await moveMouseCell(page, dims, 5, 1); + await pollFor(page, `window.calls`, ['provide 5,1', 'match']); + await moveMouseCell(page, dims, 4, 1); + await pollFor(page, `window.calls`, ['provide 5,1', 'match', 'provide 4,1']); + await moveMouseCell(page, dims, 7, 1); + await pollFor(page, `window.calls`, ['provide 5,1', 'match', 'provide 4,1', 'provide 7,1', 'match']); + await moveMouseCell(page, dims, 8, 1); + await pollFor(page, `window.calls`, ['provide 5,1', 'match', 'provide 4,1', 'provide 7,1', 'match', 'provide 8,1']); + await page.evaluate(`window.disposable.dispose()`); + }); + + it('should fire activate events when clicking the link', async () => { + await openTerminal({ rendererType: 'dom' }); + await writeSync(page, 'a b c'); + + // Wait for renderer to catch up as links are cleared on render + await pollFor(page, `document.querySelector('.xterm-rows').textContent`, 'a b c '); + + // Focus terminal to avoid a render event clearing the active link + const dims = await getDimensions(); + await moveMouseCell(page, dims, 5, 5); + await page.mouse.down(); + await page.mouse.up(); + await timeout(50); // Not sure how to avoid this timeout, checking for xterm-focus doesn't help + + await page.evaluate(` + window.calls = []; + window.disposable = window.term.registerLinkProvider({ + provideLink: (position, cb) => { + window.calls.push('provide ' + position.x + ',' + position.y); + cb({ + range: { start: position, end: position }, + text: window.term.buffer.getLine(position.y - 1).getCell(position.x - 1).getChars(), + activate: (_, text) => window.calls.push('activate ' + text), + hover: () => window.calls.push('hover'), + leave: () => window.calls.push('leave') + }); + } + }); + `); + await moveMouseCell(page, dims, 3, 1); + await pollFor(page, `window.calls`, ['provide 3,1', 'hover']); + await page.mouse.down(); + await page.mouse.up(); + await pollFor(page, `window.calls`, ['provide 3,1', 'hover', 'activate b']); + await moveMouseCell(page, dims, 1, 1); + await pollFor(page, `window.calls`, ['provide 3,1', 'hover', 'activate b', 'leave', 'provide 1,1', 'hover']); + await page.mouse.down(); + await page.mouse.up(); + await pollFor(page, `window.calls`, ['provide 3,1', 'hover', 'activate b', 'leave', 'provide 1,1', 'hover', 'activate a']); + await moveMouseCell(page, dims, 5, 1); + await pollFor(page, `window.calls`, ['provide 3,1', 'hover', 'activate b', 'leave', 'provide 1,1', 'hover', 'activate a', 'leave', 'provide 5,1', 'hover']); + await page.mouse.down(); + await page.mouse.up(); + await pollFor(page, `window.calls`, ['provide 3,1', 'hover', 'activate b', 'leave', 'provide 1,1', 'hover', 'activate a', 'leave', 'provide 5,1', 'hover', 'activate c']); + await page.evaluate(`window.disposable.dispose()`); + }); + }); }); async function openTerminal(options: ITerminalOptions = {}): Promise { @@ -535,3 +671,49 @@ async function openTerminal(options: ITerminalOptions = {}): Promise { await page.waitForSelector('.xterm-text-layer'); } } + +interface IDimensions { + top: number; + left: number; + renderDimensions: IRenderDimensions; +} + +interface IRenderDimensions { + scaledCharWidth: number; + scaledCharHeight: number; + scaledCellWidth: number; + scaledCellHeight: number; + scaledCharLeft: number; + scaledCharTop: number; + scaledCanvasWidth: number; + scaledCanvasHeight: number; + canvasWidth: number; + canvasHeight: number; + actualCellWidth: number; + actualCellHeight: number; +} + +async function getDimensions(): Promise { + return await page.evaluate(` + (function() { + const rect = document.querySelector('.xterm-rows').getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + renderDimensions: window.term._core._renderService.dimensions + }; + })(); + `); +} + +async function getCellCoordinates(dimensions: IDimensions, col: number, row: number): Promise<{ x: number, y: number }> { + return { + x: dimensions.left + dimensions.renderDimensions.scaledCellWidth * (col - 0.5), + y: dimensions.top + dimensions.renderDimensions.scaledCellHeight * (row - 0.5) + }; +} + +async function moveMouseCell(page: puppeteer.Page, dimensions: IDimensions, col: number, row: number) { + const coords = await getCellCoordinates(dimensions, col, row); + await page.mouse.move(coords.x, coords.y); +} diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 7b4307227..4fb50ae3c 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -387,10 +387,10 @@ declare module 'xterm' { /** * Enable various window manipulation and report features (CSI Ps ; Ps ; Ps t). - * + * * Most settings have no default implementation, as they heavily rely on * the embedding environment. - * + * * To implement a feature, create a custom CSI hook like this: * ```ts * term.parser.addCsiHandler({final: 't'}, params => { @@ -403,8 +403,8 @@ declare module 'xterm' { * return false; // any Ps that was not handled * }); * ``` - * - * Note on security: + * + * Note on security: * Most features are meant to deal with some information of the host machine * where the terminal runs on. This is seen as a security risk possibly leaking * sensitive data of the host to the program in the terminal. Therefore all options @@ -413,18 +413,18 @@ declare module 'xterm' { */ export interface IWindowOptions { /** - * Ps=1 De-iconify window. + * Ps=1 De-iconify window. * No default implementation. */ restoreWin?: boolean; /** - * Ps=2 Iconify window. + * Ps=2 Iconify window. * No default implementation. */ minimizeWin?: boolean; /** * Ps=3 ; x ; y - * Move window to [x, y]. + * Move window to [x, y]. * No default implementation. */ setWinPosition?: boolean; @@ -432,17 +432,17 @@ declare module 'xterm' { * Ps = 4 ; height ; width * Resize the window to given `height` and `width` in pixels. * Omitted parameters should reuse the current height or width. - * Zero parameters should use the display's height or width. + * Zero parameters should use the display's height or width. * No default implementation. */ setWinSizePixels?: boolean; /** - * Ps=5 Raise the window to the front of the stacking order. + * Ps=5 Raise the window to the front of the stacking order. * No default implementation. */ raiseWin?: boolean; /** - * Ps=6 Lower the xterm window to the bottom of the stacking order. + * Ps=6 Lower the xterm window to the bottom of the stacking order. * No default implementation. */ lowerWin?: boolean; @@ -452,7 +452,7 @@ declare module 'xterm' { * Ps = 8 ; height ; width * Resize the text area to given height and width in characters. * Omitted parameters should reuse the current height or width. - * Zero parameters use the display's height or width. + * Zero parameters use the display's height or width. * No default implementation. */ setWinSizeChars?: boolean; @@ -460,81 +460,81 @@ declare module 'xterm' { * Ps=9 ; 0 Restore maximized window. * Ps=9 ; 1 Maximize window (i.e., resize to screen size). * Ps=9 ; 2 Maximize window vertically. - * Ps=9 ; 3 Maximize window horizontally. + * Ps=9 ; 3 Maximize window horizontally. * No default implementation. */ maximizeWin?: boolean; /** * Ps=10 ; 0 Undo full-screen mode. * Ps=10 ; 1 Change to full-screen. - * Ps=10 ; 2 Toggle full-screen. + * Ps=10 ; 2 Toggle full-screen. * No default implementation. */ fullscreenWin?: boolean; /** Ps=11 Report xterm window state. * If the xterm window is non-iconified, it returns "CSI 1 t". - * If the xterm window is iconified, it returns "CSI 2 t". + * If the xterm window is iconified, it returns "CSI 2 t". * No default implementation. */ getWinState?: boolean; /** * Ps=13 Report xterm window position. Result is "CSI 3 ; x ; y t". - * Ps=13 ; 2 Report xterm text-area position. Result is "CSI 3 ; x ; y t". + * Ps=13 ; 2 Report xterm text-area position. Result is "CSI 3 ; x ; y t". * No default implementation. */ getWinPosition?: boolean; /** * Ps=14 Report xterm text area size in pixels. Result is "CSI 4 ; height ; width t". - * Ps=14 ; 2 Report xterm window size in pixels. Result is "CSI 4 ; height ; width t". + * Ps=14 ; 2 Report xterm window size in pixels. Result is "CSI 4 ; height ; width t". * Has a default implementation. */ getWinSizePixels?: boolean; /** - * Ps=15 Report size of the screen in pixels. Result is "CSI 5 ; height ; width t". + * Ps=15 Report size of the screen in pixels. Result is "CSI 5 ; height ; width t". * No default implementation. */ getScreenSizePixels?: boolean; /** - * Ps=16 Report xterm character cell size in pixels. Result is "CSI 6 ; height ; width t". + * Ps=16 Report xterm character cell size in pixels. Result is "CSI 6 ; height ; width t". * Has a default implementation. */ getCellSizePixels?: boolean; /** - * Ps=18 Report the size of the text area in characters. Result is "CSI 8 ; height ; width t". + * Ps=18 Report the size of the text area in characters. Result is "CSI 8 ; height ; width t". * Has a default implementation. */ getWinSizeChars?: boolean; /** - * Ps=19 Report the size of the screen in characters. Result is "CSI 9 ; height ; width t". + * Ps=19 Report the size of the screen in characters. Result is "CSI 9 ; height ; width t". * No default implementation. */ getScreenSizeChars?: boolean; /** - * Ps=20 Report xterm window's icon label. Result is "OSC L label ST". + * Ps=20 Report xterm window's icon label. Result is "OSC L label ST". * No default implementation. */ getIconTitle?: boolean; /** - * Ps=21 Report xterm window's title. Result is "OSC l label ST". + * Ps=21 Report xterm window's title. Result is "OSC l label ST". * No default implementation. */ getWinTitle?: boolean; /** * Ps=22 ; 0 Save xterm icon and window title on stack. * Ps=22 ; 1 Save xterm icon title on stack. - * Ps=22 ; 2 Save xterm window title on stack. + * Ps=22 ; 2 Save xterm window title on stack. * All variants have a default implementation. */ pushTitle?: boolean; /** * Ps=23 ; 0 Restore xterm icon and window title from stack. * Ps=23 ; 1 Restore xterm icon title from stack. - * Ps=23 ; 2 Restore xterm window title from stack. + * Ps=23 ; 2 Restore xterm window title from stack. * All variants have a default implementation. */ popTitle?: boolean; /** - * Ps>=24 Resize to Ps lines (DECSLPP). + * Ps>=24 Resize to Ps lines (DECSLPP). * DECSLPP is not implemented. This settings is also used to * enable / disable DECCOLM (earlier variant of DECSLPP). */ @@ -722,6 +722,8 @@ declare module 'xterm' { /** * (EXPERIMENTAL) Registers a link matcher, allowing custom link patterns to * be matched and handled. + * @deprecated The link matcher API is now deprecated in favor of the link + * provider API, see `registerLinkProvider`. * @param regex The regular expression to search for, specifically this * searches the textContent of the rows. You will want to use \s to match a * space ' ' character for example. @@ -733,10 +735,20 @@ declare module 'xterm' { /** * (EXPERIMENTAL) Deregisters a link matcher if it has been registered. + * @deprecated The link matcher API is now deprecated in favor of the link + * provider API, see `registerLinkProvider`. * @param matcherId The link matcher's ID (returned after register) */ deregisterLinkMatcher(matcherId: number): void; + /** + * (EXPERIMENTAL) Registers a link provider, allowing a custom parser to + * be used to match and handle links. Multiple link providers can be used, + * they will be asked in the order in which they are registered. + * @param linkProvider The link provider to use to detect links. + */ + registerLinkProvider(linkProvider: ILinkProvider): IDisposable; + /** * (EXPERIMENTAL) Registers a character joiner, allowing custom sequences of * characters to be rendered as a single unit. This is useful in particular @@ -1073,6 +1085,85 @@ declare module 'xterm' { y: number; } + /** + * A custom link provider. + */ + interface ILinkProvider { + /** + * Provides a link a buffer position + * @param position The position of the buffer that is currently active. + * @param callback The callback to be fired with the resulting link or + * `undefined` when ready. + */ + provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void; + } + + /** + * A link within the terminal. + */ + interface ILink { + /** + * The buffer range of the link. + */ + range: IBufferRange; + + /** + * The text of the link. + */ + text: string; + + /** + * Calls when the link is activated. + * @param event The mouse event triggering the callback. + * @param text The text of the link. + */ + activate(event: MouseEvent, text: string): void; + + /** + * Called when the mouse hovers the link. + * @param event The mouse event triggering the callback. + * @param text The text of the link. + */ + hover?(event: MouseEvent, text: string): void; + + /** + * Called when the mouse leaves the link. + * @param event The mouse event triggering the callback. + * @param text The text of the link. + */ + leave?(event: MouseEvent, text: string): void; + } + + /** + * A range within a buffer. + */ + interface IBufferRange { + /** + * The start position of the range. + */ + start: IBufferCellPosition; + + /** + * The end position of the range. + */ + end: IBufferCellPosition; + } + + /** + * A position within a buffer. + */ + interface IBufferCellPosition { + /** + * The x position within the buffer. + */ + x: number; + + /** + * The y position within the buffer. + */ + y: number; + } + /** * Represents a terminal buffer. */