Skip to content

Commit

Permalink
Basic OSC link model
Browse files Browse the repository at this point in the history
Part of xtermjs#1134
  • Loading branch information
Tyriar committed Jul 16, 2022
1 parent 0fbd980 commit 0190f0c
Show file tree
Hide file tree
Showing 4 changed files with 205 additions and 4 deletions.
27 changes: 24 additions & 3 deletions src/common/CoreTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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));
Expand All @@ -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 {
Expand Down
66 changes: 65 additions & 1 deletion src/common/InputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -265,6 +266,12 @@ export class InputHandler extends Disposable implements IInputHandler {
public get onTitleChange(): IEvent<string> { return this._onTitleChange.event; }
private _onColor = new EventEmitter<IColorEvent>();
public get onColor(): IEvent<IColorEvent> { return this._onColor.event; }
private _onPrintChar = new EventEmitter<void>();
public get onPrintChar(): IEvent<void> { return this._onPrintChar.event; }
private _onStartHyperlink = new EventEmitter<IHyperlinkIdentifier>();
public get onStartHyperlink(): IEvent<IHyperlinkIdentifier> { return this._onStartHyperlink.event; }
private _onFinishHyperlink = new EventEmitter<void>();
public get onFinishHyperlink(): IEvent<void> { return this._onFinishHyperlink.event; }

private _parseStack: IParseStack = {
paused: false,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ; <params> ; <uri> 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,
Expand Down
105 changes: 105 additions & 0 deletions src/common/OscLinkifier.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, Link>> = new Map();
private _linksByLine: Map<number, IOscLink> = new Map();
private _linksById: Map<string, Map<string, IOscLink>> = 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;
}
11 changes: 11 additions & 0 deletions src/common/Types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

0 comments on commit 0190f0c

Please sign in to comment.