From 0190f0ced79c3dae816e84f575176b331c400e36 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 16 Jul 2022 15:07:48 -0700 Subject: [PATCH] Basic OSC link model Part of #1134 --- src/common/CoreTerminal.ts | 27 ++++++++-- src/common/InputHandler.ts | 66 ++++++++++++++++++++++- src/common/OscLinkifier.ts | 105 +++++++++++++++++++++++++++++++++++++ src/common/Types.d.ts | 11 ++++ 4 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 src/common/OscLinkifier.ts diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index af9ec3f9ef..a39316e6c3 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -21,7 +21,7 @@ * http://linux.die.net/man/7/urxvt */ -import { Disposable } from 'common/Lifecycle'; +import { Disposable, disposeArray } from 'common/Lifecycle'; import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, IDirtyRowService, LogLevelEnum, ITerminalOptions } from 'common/services/Services'; import { InstantiationService } from 'common/services/InstantiationService'; import { LogService } from 'common/services/LogService'; @@ -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 { OscLinkifier } from 'common/OscLinkifier'; // Only trigger this warning a single time per session let hasWriteSyncWarnHappened = false; @@ -56,6 +57,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { public readonly optionsService: IOptionsService; protected _inputHandler: InputHandler; + private _oscLinkifier: OscLinkifier; private _writeBuffer: WriteBuffer; private _windowsMode: IDisposable | undefined; @@ -120,9 +122,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this._instantiationService.setService(ICharsetService, this._charsetService); // 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 +142,26 @@ 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)); + + // OSC Linkifier + this._oscLinkifier = this._instantiationService.createInstance(OscLinkifier); + let activeHyperlink = false; + this.register(this._inputHandler.onStartHyperlink(e => { + if (activeHyperlink) { + return; + } + activeHyperlink = true; + this._oscLinkifier.startHyperlink(e); + const disposables: IDisposable[] = []; + disposables.push(this._inputHandler.onPrintChar(() => { + this._oscLinkifier.addCellToLink(this._bufferService.buffer.x, this._bufferService.buffer.ybase + this._bufferService.buffer.y); + })); + disposables.push(this._inputHandler.onFinishHyperlink(() => { + this._oscLinkifier.finishHyperlink(); + disposeArray(disposables); + activeHyperlink = false; + })); + })); } public dispose(): void { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index d5b8d9481c..9ff7f93dcb 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(); // 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/OscLinkifier.ts b/src/common/OscLinkifier.ts new file mode 100644 index 0000000000..3499ea01c3 --- /dev/null +++ b/src/common/OscLinkifier.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IBufferService } from 'common/services/Services'; +import { IHyperlinkIdentifier, IMarker } from 'common/Types'; + +export class OscLinkifier { + private _currentHyperlink?: IPendingOscLink; + + // private _linkMap: Map> = new Map(); + private _linksByLine: Map = new Map(); + private _linksById: Map> = new Map(); + + 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); + console.log('finalize links', this._currentHyperlink, ranges); + this._currentHyperlink = undefined; + // TODO: Finalize link + } + + public addCellToLink(x: number, y: 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 }; + } else { + cell = { x, y: this._bufferService.buffer.addMarker(y) }; + } + this._currentHyperlink.cells.push(cell); + } + + /** + * 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, + // TODO: Wide chars + length: 1 + }; + 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 + 1) { + currentRange.length++; + } else { + currentRange = { + x: currentCell.x, + y: currentCell.y, + length: 1 + }; + ranges.push(currentRange); + } + lastCell = currentCell; + } + return ranges; + } +} + +interface IOscLink { + ranges: IMarkerRange[]; +} + +interface IPendingOscLink { + id: IHyperlinkIdentifier; + cells: IMarkerCell[]; +} + +interface IMarkerRange { + // TODO: How to handle wrapped lines? + x: number; + length: number; + y: IMarker; +} + +interface IMarkerCell { + x: number; + y: IMarker; +} diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index ed9a7124ec..6f572e979e 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -489,3 +489,14 @@ 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; +}