diff --git a/css/xterm.css b/css/xterm.css index 95fc61edcb..6809651691 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -167,6 +167,10 @@ text-decoration: underline; } +.xterm-link { + text-decoration: underline dashed; +} + .xterm-strikethrough { text-decoration: line-through; } diff --git a/src/browser/Linkifier2.test.ts b/src/browser/Linkifier2.test.ts index c1a0cf6c25..a1f9fe69a5 100644 --- a/src/browser/Linkifier2.test.ts +++ b/src/browser/Linkifier2.test.ts @@ -11,7 +11,7 @@ import { ILink } from 'browser/Types'; class TestLinkifier2 extends Linkifier2 { constructor(bufferService: IBufferService) { - super(bufferService); + super(null!, bufferService); } public set currentLink(link: any) { diff --git a/src/browser/Linkifier2.ts b/src/browser/Linkifier2.ts index dae9acfa04..a0c337579a 100644 --- a/src/browser/Linkifier2.ts +++ b/src/browser/Linkifier2.ts @@ -3,10 +3,10 @@ * @license MIT */ -import { ILinkifier2, ILinkProvider, IBufferCellPosition, ILink, ILinkifierEvent, ILinkDecorations, ILinkWithState } from 'browser/Types'; -import { IDisposable } from 'common/Types'; +import { ILinkifier2, ILinkProvider, IBufferCellPosition, ILink, ILinkifierEvent, ILinkDecorations, ILinkWithState, IBufferRange } from 'browser/Types'; +import { IDisposable, IOscLink } from 'common/Types'; import { IMouseService, IRenderService } from './services/Services'; -import { IBufferService } from 'common/services/Services'; +import { IBufferService, IOscLinkService } from 'common/services/Services'; import { EventEmitter, IEvent } from 'common/EventEmitter'; import { Disposable, getDisposeArrayDisposable, disposeArray } from 'common/Lifecycle'; import { addDisposableDomListener } from 'browser/Lifecycle'; @@ -32,6 +32,7 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { public get onHideLinkUnderline(): IEvent { return this._onHideLinkUnderline.event; } constructor( + @IOscLinkService private readonly _oscLinkStore: IOscLinkService, @IBufferService private readonly _bufferService: IBufferService ) { super(); @@ -130,6 +131,17 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } let linkProvided = false; + // If there is an OSC link, use that + const oscLinks = this._oscLinkStore.getLinksByLine(position.y - 1); + const oscLink = oscLinks.find(e => e.ranges.some(p => position.x >= p.x && position.x <= p.x + p.length)); + if (oscLink) { + const linkWithState = this._oscLinkToLinkWithState(oscLink, position); + if (linkWithState) { + this._handleNewLink(linkWithState); + return; + } + } + // There is no link cached, so ask for one for (const [i, linkProvider] of this._linkProviders.entries()) { if (useLineCache) { @@ -394,4 +406,24 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { private _createLinkUnderlineEvent(x1: number, y1: number, x2: number, y2: number, fg: number | undefined): ILinkifierEvent { return { x1, y1, x2, y2, cols: this._bufferService.cols, fg }; } + + private _oscLinkToLinkWithState(oscLink: IOscLink, position: IBufferCellPosition): ILinkWithState | undefined { + // TODO: Support returning all ranges so they get highlighted correctly across lines + const range = oscLink.ranges.find(e => e.y.line === position.y - 1); + if (!range) { + return undefined; + } + return { + link: { + activate(e, text) { + console.log('activate', e, text); + }, + range: { + start: { x: range.x, y: range.y.line + 1 }, + end: { x: range.x + range.length, y: range.y.line + 1 } + }, + text: oscLink.id.uri + } + }; + } } diff --git a/src/browser/renderer/BaseRenderLayer.ts b/src/browser/renderer/BaseRenderLayer.ts index 0a9b805701..ec2b999546 100644 --- a/src/browser/renderer/BaseRenderLayer.ts +++ b/src/browser/renderer/BaseRenderLayer.ts @@ -196,6 +196,19 @@ export abstract class BaseRenderLayer implements IRenderLayer { window.devicePixelRatio); } + protected _dashedUnderlineAtCells(x: number, y: number, width: number = 1): void { + const third = Math.floor(this._scaledCellWidth / 3); + const yPx = (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */; + this._ctx.save(); + this._ctx.strokeStyle = this._ctx.fillStyle; + this._ctx.setLineDash([third, this._scaledCellWidth - third * 2, third * 2, this._scaledCellWidth - third * 2, third]); + this._ctx.beginPath(); + this._ctx.moveTo(x * this._scaledCellWidth, yPx); + this._ctx.lineTo((x + width) * this._scaledCellWidth, yPx); + this._ctx.stroke(); + this._ctx.restore(); + } + /** * Fills a 1px line (2px on HDPI) at the left of the cell. This uses the * existing fillStyle on the context. diff --git a/src/browser/renderer/TextRenderLayer.ts b/src/browser/renderer/TextRenderLayer.ts index ef5a9b62ea..61d83ce39f 100644 --- a/src/browser/renderer/TextRenderLayer.ts +++ b/src/browser/renderer/TextRenderLayer.ts @@ -4,14 +4,14 @@ */ import { IRenderDimensions } from 'browser/renderer/Types'; -import { CharData, ICellData } from 'common/Types'; +import { CharData, ICellData, IOscLink } from 'common/Types'; import { GridCache } from 'browser/renderer/GridCache'; import { BaseRenderLayer } from 'browser/renderer/BaseRenderLayer'; import { AttributeData } from 'common/buffer/AttributeData'; import { NULL_CELL_CODE, Content } from 'common/buffer/Constants'; import { IColorSet } from 'browser/Types'; import { CellData } from 'common/buffer/CellData'; -import { IOptionsService, IBufferService, IDecorationService } from 'common/services/Services'; +import { IOptionsService, IBufferService, IDecorationService, IOscLinkService } from 'common/services/Services'; import { ICharacterJoinerService } from 'browser/services/Services'; import { JoinedCellData } from 'browser/services/CharacterJoinerService'; @@ -38,7 +38,8 @@ export class TextRenderLayer extends BaseRenderLayer { @IBufferService bufferService: IBufferService, @IOptionsService optionsService: IOptionsService, @ICharacterJoinerService private readonly _characterJoinerService: ICharacterJoinerService, - @IDecorationService decorationService: IDecorationService + @IDecorationService decorationService: IDecorationService, + @IOscLinkService private readonly _oscLinkService: IOscLinkService ) { super(container, 'text', zIndex, alpha, colors, rendererId, bufferService, optionsService, decorationService); this._state = new GridCache(); @@ -224,12 +225,18 @@ export class TextRenderLayer extends BaseRenderLayer { } private _drawForeground(firstRow: number, lastRow: number): void { + let hasLink = true; + const links: IOscLink[][] = []; + for (let line = firstRow; line <= lastRow; line++) { + links.push(this._oscLinkService.getLinksByLine(line)); + } this._forEachCell(firstRow, lastRow, (cell, x, y) => { + hasLink = links[y - firstRow].some(e => e.ranges.some(p => x >= p.x && x < p.x + p.length)); if (cell.isInvisible()) { return; } this._drawChars(cell, x, y); - if (cell.isUnderline() || cell.isStrikethrough()) { + if (cell.isUnderline() || cell.isStrikethrough() || hasLink) { this._ctx.save(); if (cell.isInverse()) { @@ -264,6 +271,9 @@ export class TextRenderLayer extends BaseRenderLayer { if (cell.isUnderline()) { this._fillBottomLineAtCells(x, y, cell.getWidth()); } + if (hasLink) { + this._dashedUnderlineAtCells(x, y, cell.getWidth()); + } this._ctx.restore(); } }); diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 248f37ac4b..99ba159a18 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -9,10 +9,11 @@ import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; import { Disposable } from 'common/Lifecycle'; import { IColorSet, ILinkifierEvent, ILinkifier, ILinkifier2 } from 'browser/Types'; import { ICharSizeService } from 'browser/services/Services'; -import { IOptionsService, IBufferService, IInstantiationService, IDecorationService } from 'common/services/Services'; +import { IOptionsService, IBufferService, IInstantiationService, IDecorationService, IOscLinkService } from 'common/services/Services'; import { EventEmitter, IEvent } from 'common/EventEmitter'; import { color } from 'common/Color'; import { removeElementFromParent } from 'browser/Dom'; +import { OscLinkService } from 'common/services/OscLinkService'; const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-'; const ROW_CONTAINER_CLASS = 'xterm-rows'; @@ -47,12 +48,13 @@ export class DomRenderer extends Disposable implements IRenderer { private readonly _element: HTMLElement, private readonly _screenElement: HTMLElement, private readonly _viewportElement: HTMLElement, - private readonly _linkifier: ILinkifier, - private readonly _linkifier2: ILinkifier2, + linkifier: ILinkifier, + linkifier2: ILinkifier2, @IInstantiationService instantiationService: IInstantiationService, @ICharSizeService private readonly _charSizeService: ICharSizeService, @IOptionsService private readonly _optionsService: IOptionsService, - @IBufferService private readonly _bufferService: IBufferService + @IBufferService private readonly _bufferService: IBufferService, + @IOscLinkService private readonly _oscLinkService: IOscLinkService ) { super(); this._rowContainer = document.createElement('div'); @@ -87,11 +89,11 @@ export class DomRenderer extends Disposable implements IRenderer { this._screenElement.appendChild(this._rowContainer); this._screenElement.appendChild(this._selectionContainer); - this.register(this._linkifier.onShowLinkUnderline(e => this._onLinkHover(e))); - this.register(this._linkifier.onHideLinkUnderline(e => this._onLinkLeave(e))); + this.register(linkifier.onShowLinkUnderline(e => this._onLinkHover(e))); + this.register(linkifier.onHideLinkUnderline(e => this._onLinkLeave(e))); - this.register(this._linkifier2.onShowLinkUnderline(e => this._onLinkHover(e))); - this.register(this._linkifier2.onHideLinkUnderline(e => this._onLinkLeave(e))); + this.register(linkifier2.onShowLinkUnderline(e => this._onLinkHover(e))); + this.register(linkifier2.onHideLinkUnderline(e => this._onLinkLeave(e))); } public dispose(): void { @@ -392,7 +394,7 @@ export class DomRenderer extends Disposable implements IRenderer { } const span = row.children[x] as HTMLElement; if (span) { - span.style.textDecoration = enabled ? 'underline' : 'none'; + span.style.textDecoration = enabled ? 'underline' : ''; } if (++x >= cols) { x = 0; diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index db5d258ef0..26241050f4 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -50,7 +50,8 @@ describe('DomRendererRowFactory', () => { new MockCharacterJoinerService(), new MockOptionsService({ drawBoldTextInBrightColors: true }), new MockCoreService(), - new MockDecorationService() + new MockDecorationService(), + null! ); lineData = createEmptyLineData(2); }); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index fadf50325f..17fc296278 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -7,17 +7,19 @@ import { IBufferLine, ICellData, IColor } from 'common/Types'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/atlas/Constants'; import { NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Attributes } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; -import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { ICoreService, IDecorationService, IOptionsService, IOscLinkService } from 'common/services/Services'; import { color, rgba } from 'common/Color'; import { IColorSet } from 'browser/Types'; -import { ICharacterJoinerService, ISelectionService } from 'browser/services/Services'; +import { ICharacterJoinerService } from 'browser/services/Services'; import { JoinedCellData } from 'browser/services/CharacterJoinerService'; import { excludeFromContrastRatioDemands } from 'browser/renderer/RendererUtils'; +import { OscLinkService } from 'common/services/OscLinkService'; export const BOLD_CLASS = 'xterm-bold'; export const DIM_CLASS = 'xterm-dim'; export const ITALIC_CLASS = 'xterm-italic'; export const UNDERLINE_CLASS = 'xterm-underline'; +export const LINK_CLASS = 'xterm-link'; export const STRIKETHROUGH_CLASS = 'xterm-strikethrough'; export const CURSOR_CLASS = 'xterm-cursor'; export const CURSOR_BLINK_CLASS = 'xterm-cursor-blink'; @@ -38,7 +40,8 @@ export class DomRendererRowFactory { @ICharacterJoinerService private readonly _characterJoinerService: ICharacterJoinerService, @IOptionsService private readonly _optionsService: IOptionsService, @ICoreService private readonly _coreService: ICoreService, - @IDecorationService private readonly _decorationService: IDecorationService + @IDecorationService private readonly _decorationService: IDecorationService, + @IOscLinkService private readonly _oscLinkService: OscLinkService ) { } @@ -69,6 +72,8 @@ export class DomRendererRowFactory { } } + const links = this._oscLinkService.getLinksByLine(row); + for (let x = 0; x < lineLength; x++) { lineData.loadCell(x, this._workCell); let width = this._workCell.getWidth(); @@ -81,6 +86,7 @@ export class DomRendererRowFactory { // If true, indicates that the current character(s) to draw were joined. let isJoined = false; let lastCharX = x; + const hasLink = links.some(e => e.ranges.some(p => x >= p.x && x < p.x + p.length)); // Process any joined character ranges as needed. Because of how the // ranges are produced, we know that they are valid for the characters @@ -165,6 +171,13 @@ export class DomRendererRowFactory { charElement.textContent = cell.getChars() || WHITESPACE_CELL_CHAR; } + if (hasLink) { + charElement.classList.add(LINK_CLASS); + if (charElement.textContent === WHITESPACE_CELL_CHAR) { + charElement.innerHTML = ' '; + } + } + if (cell.isStrikethrough()) { charElement.classList.add(STRIKETHROUGH_CLASS); } diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index af9ec3f9ef..ef98070167 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -21,8 +21,8 @@ * http://linux.die.net/man/7/urxvt */ -import { Disposable } from 'common/Lifecycle'; -import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, IDirtyRowService, LogLevelEnum, ITerminalOptions } from 'common/services/Services'; +import { Disposable, disposeArray } from 'common/Lifecycle'; +import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, IDirtyRowService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services'; import { InstantiationService } from 'common/services/InstantiationService'; import { LogService } from 'common/services/LogService'; import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService'; @@ -39,6 +39,7 @@ import { IFunctionIdentifier, IParams } from 'common/parser/Types'; import { IBufferSet } from 'common/buffer/Types'; import { InputHandler } from 'common/InputHandler'; import { WriteBuffer } from 'common/input/WriteBuffer'; +import { OscLinkService } from 'common/services/OscLinkService'; // Only trigger this warning a single time per session let hasWriteSyncWarnHappened = false; @@ -118,11 +119,12 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this._instantiationService.setService(IUnicodeService, this.unicodeService); this._charsetService = this._instantiationService.createInstance(CharsetService); this._instantiationService.setService(ICharsetService, this._charsetService); + const oscLinkService = this._instantiationService.createInstance(OscLinkService); + this._instantiationService.setService(IOscLinkService, oscLinkService); // Register input handler and handle/forward events - this._inputHandler = new InputHandler(this._bufferService, this._charsetService, this.coreService, this._dirtyRowService, this._logService, this.optionsService, this.coreMouseService, this.unicodeService); + this._inputHandler = this.register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._dirtyRowService, this._logService, this.optionsService, this.coreMouseService, this.unicodeService)); this.register(forwardEvent(this._inputHandler.onLineFeed, this._onLineFeed)); - this.register(this._inputHandler); // Setup listeners this.register(forwardEvent(this._bufferService.onResize, this._onResize)); @@ -141,6 +143,25 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { // Setup WriteBuffer this._writeBuffer = new WriteBuffer((data, promiseResult) => this._inputHandler.parse(data, promiseResult)); this.register(forwardEvent(this._writeBuffer.onWriteParsed, this._onWriteParsed)); + + // Setup OSC links + let activeHyperlink = false; + this.register(this._inputHandler.onStartHyperlink(e => { + if (activeHyperlink) { + return; + } + activeHyperlink = true; + oscLinkService.startHyperlink(e); + const disposables: IDisposable[] = []; + disposables.push(this._inputHandler.onPrintChar(width => { + oscLinkService.addCellToLink(this._bufferService.buffer.x, this._bufferService.buffer.ybase + this._bufferService.buffer.y, width); + })); + disposables.push(this._inputHandler.onFinishHyperlink(() => { + oscLinkService.finishHyperlink(); + disposeArray(disposables); + activeHyperlink = false; + })); + })); } public dispose(): void { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index d5b8d9481c..23f4c55fab 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -4,7 +4,7 @@ * @license MIT */ -import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType } from 'common/Types'; +import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, IMarker, ColorIndex, ColorRequestType, IHyperlinkIdentifier } from 'common/Types'; import { C0, C1 } from 'common/data/EscapeSequences'; import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets'; import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; @@ -230,6 +230,7 @@ export class InputHandler extends Disposable implements IInputHandler { private _workCell: CellData = new CellData(); private _windowTitle = ''; private _iconName = ''; + private _currentHyperlink?: IHyperlinkIdentifier; protected _windowTitleStack: string[] = []; protected _iconNameStack: string[] = []; @@ -265,6 +266,12 @@ export class InputHandler extends Disposable implements IInputHandler { public get onTitleChange(): IEvent { return this._onTitleChange.event; } private _onColor = new EventEmitter(); public get onColor(): IEvent { return this._onColor.event; } + private _onPrintChar = new EventEmitter(); + public get onPrintChar(): IEvent { return this._onPrintChar.event; } + private _onStartHyperlink = new EventEmitter(); + public get onStartHyperlink(): IEvent { return this._onStartHyperlink.event; } + private _onFinishHyperlink = new EventEmitter(); + public get onFinishHyperlink(): IEvent { return this._onFinishHyperlink.event; } private _parseStack: IParseStack = { paused: false, @@ -403,6 +410,8 @@ export class InputHandler extends Disposable implements IInputHandler { // 5 - Change Special Color Number // 6 - Enable/disable Special Color Number c // 7 - current directory? (not in xterm spec, see https://gitlab.com/gnachman/iterm2/issues/3939) + // 8 - create hyperlink (not in xterm spec, see https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) + this._parser.registerOscHandler(8, new OscHandler(data => this.setHyperlink(data))); // 10 - Change VT100 text foreground color to Pt. this._parser.registerOscHandler(10, new OscHandler(data => this.setOrReportFgColor(data))); // 11 - Change VT100 text background color to Pt. @@ -637,6 +646,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (screenReaderMode) { this._onA11yChar.fire(stringFromCodePoint(code)); } + this._onPrintChar.fire(chWidth); // insert combining char at last cursor position // this._activeBuffer.x should never be 0 for a combining char @@ -2892,6 +2902,60 @@ export class InputHandler extends Disposable implements IInputHandler { // special colors - OSC 10 | 11 | 12 private _specialColors = [ColorIndex.FOREGROUND, ColorIndex.BACKGROUND, ColorIndex.CURSOR]; + /** + * OSC 8 ; ; ST - create hyperlink + * OSC 8 ; ; ST - finish hyperlink + * + * Test case: + * + * ```sh + * printf '\e]8;;http://example.com\e\\This is a link\e]8;;\e\\\n' + * ``` + * + * @vt: #Y OSC 8 "Create hyperlink" "OSC 8 ; params ; uri BEL" "Create a hyperlink to `uri` using `params`." + * `uri` is a hyperlink starting with `http://`, `https://`, `ftp://`, `file://` or `mailto://`. `params` is an + * optional list of key=value assignments, separated by the : character. Example: `id=xyz123:foo=bar:baz=quux`. + * Currently only the id key is defined. Cells that share the same ID and URI share hover feedback. + * Use `OSC 8 ; ; BEL` to finish the current hyperlink. + */ + public setHyperlink(data: string): boolean { + const args = data.split(';'); + console.log('hyperlink', args); + if (args.length < 2) { + return false; + } + if (args[1]) { + return this._createHyperlink(args[0], args[1]); + } + if (args[0]) { + return false; + } + return this._finishHyperlink(); + } + + private _createHyperlink(params: string, uri: string): boolean { + // It's legal to open a new hyperlink without explicitly finishing the previous one + if (this._currentHyperlink) { + this._finishHyperlink(); + } + const parsedParams = params.split(':'); + let id: string | undefined; + const idParamIndex = parsedParams.findIndex(e => e.startsWith('id=')); + if (idParamIndex !== -1) { + id = parsedParams[idParamIndex].slice(3) || undefined; + } + this._currentHyperlink = { id, uri }; + this._onStartHyperlink.fire(this._currentHyperlink); + return true; + } + + private _finishHyperlink(): boolean { + const buffer = this._bufferService.buffer; + this._onFinishHyperlink.fire(); + this._currentHyperlink = undefined; + return true; + } + /** * Apply colors requests for special colors in OSC 10 | 11 | 12. * Since these commands are stacking from multiple parameters, diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index ed9a7124ec..e905a7b01d 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -489,3 +489,25 @@ interface IParseStack { decodedLength: number; position: number; } + +/** + * Tracks the current hyperlink. Since these are treated as extended attirbutes, these get passed on + * to the linkifier when anything is printed. Doing it this way ensures that even when the cursor + * moves around unexpectedly the link is tracked, as opposed to using a start position and + * finalizing it at the end. + */ +export interface IHyperlinkIdentifier { + id?: string; + uri: string; +} + +export interface IOscLink { + id: IHyperlinkIdentifier; + ranges: IMarkerRange[]; +} + +export interface IMarkerRange { + x: number; + y: IMarker; + length: number; +} diff --git a/src/common/services/OscLinkService.ts b/src/common/services/OscLinkService.ts new file mode 100644 index 0000000000..6b6395174d --- /dev/null +++ b/src/common/services/OscLinkService.ts @@ -0,0 +1,162 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IBufferService, IOscLinkService } from 'common/services/Services'; +import { IHyperlinkIdentifier, IMarker, IMarkerRange, IOscLink } from 'common/Types'; + +export class OscLinkService implements IOscLinkService { + public serviceBrand: any; + + private _currentHyperlink?: IPendingOscLink; + + // private _linkMap: Map> = new Map(); + // private _linksByLine: Map = new Map(); + // private _linksById: Map> = new Map(); + private _linkStore = new LinkStore(); + + constructor( + @IBufferService private readonly _bufferService: IBufferService + ) { + } + + public startHyperlink(linkId: IHyperlinkIdentifier): void { + this._currentHyperlink = { + id: linkId, + cells: [] + }; + } + + public finishHyperlink(): void { + if (!this._currentHyperlink || this._currentHyperlink.cells.length === 0) { + this._currentHyperlink = undefined; + return; + } + const ranges = this._convertCellsToRanges(this._currentHyperlink.cells); + this._linkStore.add(this._currentHyperlink.id, { + id: this._currentHyperlink.id, + ranges + }); + console.log('finalize links', this._currentHyperlink, ranges); + this._currentHyperlink = undefined; + // TODO: Finalize link + } + + public addCellToLink(x: number, y: number, width: number): void { + if (!this._currentHyperlink) { + return; + } + let cell: IMarkerCell; + if (this._currentHyperlink.cells.length > 0 && y === this._currentHyperlink.cells[this._currentHyperlink.cells.length - 1].y.line) { + cell = { x, y: this._currentHyperlink.cells[this._currentHyperlink.cells.length - 1].y, width }; + } else { + cell = { x, y: this._bufferService.buffer.addMarker(y), width }; + } + this._currentHyperlink.cells.push(cell); + } + + public getLinksByLine(y: number): IOscLink[] { + return this._linkStore.getByLine(y); + } + + /** + * Converts an array of cells to ranges, sharing y markers between adjacent entries when possible. + */ + private _convertCellsToRanges(cells: IMarkerCell[]): IMarkerRange[] { + if (cells.length === 0) { + return []; + } + let currentCell: IMarkerCell; + let lastCell: IMarkerCell = cells[0]; + let currentRange: IMarkerRange = { + x: lastCell.x, + y: lastCell.y, + length: lastCell.width + }; + const ranges: IMarkerRange[] = [currentRange]; + for (let i = 1; i < cells.length; i++) { + currentCell = cells[i]; + if (currentCell.y.line === lastCell.y.line && currentCell.x === lastCell.x + lastCell.width) { + currentRange.length += currentCell.width; + } else { + currentRange = { + x: currentCell.x, + y: currentCell.y, + length: currentCell.width + }; + ranges.push(currentRange); + } + lastCell = currentCell; + } + return ranges; + } +} + +interface IPendingOscLink { + id: IHyperlinkIdentifier; + cells: IMarkerCell[]; +} + +interface IMarkerCell { + x: number; + y: IMarker; + width: number; +} + +class LinkStore { + private _entriesNoId: IOscLink[] = []; + private _entriesById: { [id: string]: { [link: string]: IOscLink | undefined } | undefined } = {}; + + public clear(): void { + this._entriesById = {}; + } + + public add(linkIdentifier: IHyperlinkIdentifier, link: IOscLink): void { + // TODO: Remove links when markers dispose + if (!linkIdentifier.id) { + this._entriesNoId.push(link); + return; + } + if (!this._entriesById[linkIdentifier.id]) { + this._entriesById[linkIdentifier.id] = {}; + } + const existingLink = this._entriesById[linkIdentifier.id]![linkIdentifier.uri]; + if (existingLink) { + existingLink.ranges.push(...link.ranges); + } else { + this._entriesById[linkIdentifier.id]![linkIdentifier.uri] = link; + } + } + + public getAll(): IOscLink[] { + const result = this._entriesNoId.slice(); + const ids = Object.keys(this._entriesById); + for (const id of ids) { + const byUri = this._entriesById[id]; + if (!byUri) { + continue; + } + const uris = Object.keys(byUri); + for (const uri of uris) { + const link = byUri[uri]; + if (link) { + result.push(link); + } + } + } + return result; + } + + public getByLine(y: number): IOscLink[] { + // This is very much not optimized, creating a new array and iterating over everything on each + // request. + const result: IOscLink[] = []; + for (const link of this.getAll()) { + if (link.ranges.some(e => e.y.line === y)) { + result.push(link); + } + } + return result; + } +} diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index bad6c004c4..8a278acd35 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -5,7 +5,7 @@ import { IEvent, IEventEmitter } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable, IColorRGB, IColor, CursorStyle } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable, IColorRGB, IColor, CursorStyle, IHyperlinkIdentifier, IOscLink } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; import { IDecorationOptions, IDecoration } from 'xterm'; @@ -321,3 +321,12 @@ export interface IInternalDecoration extends IDecoration { readonly foregroundColorRGB: IColor | undefined; readonly onRenderEmitter: IEventEmitter; } + +export const IOscLinkService = createDecorator('OscLinkService'); +export interface IOscLinkService { + serviceBrand: undefined; + startHyperlink(linkId: IHyperlinkIdentifier): void; + finishHyperlink(): void; + addCellToLink(x: number, y: number, width: number): void; + getLinksByLine(y: number): IOscLink[]; +}