From 91d46dae509b1940404dc9973760844c027e9674 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 05:08:13 -0700 Subject: [PATCH 01/11] OSC link progress --- .../src/atlas/WebglCharAtlas.ts | 2 +- src/browser/OscLinkProvider.ts | 72 +++++++++++++++++++ src/browser/Terminal.ts | 2 + src/common/CoreTerminal.ts | 6 +- src/common/InputHandler.ts | 71 +++++++++++++++++- src/common/Types.d.ts | 15 +++- src/common/buffer/AttributeData.ts | 48 ++++++++++--- src/common/services/OscLinkService.ts | 22 ++++++ src/common/services/Services.ts | 14 +++- 9 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 src/browser/OscLinkProvider.ts create mode 100644 src/common/services/OscLinkService.ts diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index 13dceae67c..182b3b3e00 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -356,7 +356,7 @@ export class WebglCharAtlas implements IDisposable { private _drawToCache(codeOrChars: number | string, bg: number, fg: number, ext: number): IRasterizedGlyph { const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars; - + console.log('_drawToCache', chars, ext); this.hasCanvasChanged = true; // Allow 1 cell width per character, with a minimum of 2 (CJK), plus some padding. This is used diff --git a/src/browser/OscLinkProvider.ts b/src/browser/OscLinkProvider.ts new file mode 100644 index 0000000000..c7594479a6 --- /dev/null +++ b/src/browser/OscLinkProvider.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ILink, ILinkProvider } from 'browser/Types'; +import { CellData } from 'common/buffer/CellData'; +import { IBufferService, IOscLinkService } from 'common/services/Services'; + +export class OscLinkProvider implements ILinkProvider { + constructor( + @IBufferService private readonly _bufferService: IBufferService, + @IOscLinkService private readonly _oscLinkService: IOscLinkService + ) { + } + + public provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + const line = this._bufferService.buffer.lines.get(y - 1); + if (!line) { + callback(undefined); + return; + } + + const result: ILink[] = []; + const cell = new CellData(); + const lineLength = line.getTrimmedLength(); + let currentLinkId = -1; + let currentStart = -1; + let finishLink = false; + for (let x = 0; x < lineLength; x++) { + if (!line.hasContent(x)) { + continue; + } + + line.loadCell(x, cell); + if (cell.extended.urlId) { + if (currentStart === -1) { + currentStart = x; + currentLinkId = cell.extended.urlId; + continue; + } else { + finishLink = cell.extended.urlId !== currentLinkId; + } + } else { + if (currentStart !== -1) { + finishLink = true; + } + } + + if (finishLink || (currentStart !== -1 && x === lineLength - 1)) { + const text = this._oscLinkService.getLinkData(currentLinkId)?.uri; + if (text) { + // OSC links always use underline and pointer decorations + result.push({ + text, + // These ranges are 1-based + range: { + start: { x: currentStart + 1, y }, + end: { x: x + 1, y } + }, + activate(e, text) { + console.log('activate!', text); + } + // TODO: Embedder API to handle hover + }); + } + } + } + // TODO: Handle fetching and returning other link ranges to underline other links with the same id + callback(result); + } +} diff --git a/src/browser/Terminal.ts b/src/browser/Terminal.ts index 57099ac238..aacd6cf703 100644 --- a/src/browser/Terminal.ts +++ b/src/browser/Terminal.ts @@ -55,6 +55,7 @@ import { BufferDecorationRenderer } from 'browser/decorations/BufferDecorationRe import { OverviewRulerRenderer } from 'browser/decorations/OverviewRulerRenderer'; import { DecorationService } from 'common/services/DecorationService'; import { IDecorationService } from 'common/services/Services'; +import { OscLinkProvider } from 'browser/OscLinkProvider'; // Let it work inside Node.js for automated testing purposes. const document: Document = (typeof window !== 'undefined') ? window.document : null as any; @@ -163,6 +164,7 @@ export class Terminal extends CoreTerminal implements ITerminal { this._setup(); this.linkifier2 = this.register(this._instantiationService.createInstance(Linkifier2)); + this.linkifier2.registerLinkProvider(this._instantiationService.createInstance(OscLinkProvider)); this._decorationService = this._instantiationService.createInstance(DecorationService); this._instantiationService.setService(IDecorationService, this._decorationService); diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index af9ec3f9ef..4a1c99ffac 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -22,7 +22,7 @@ */ import { Disposable } from 'common/Lifecycle'; -import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, IDirtyRowService, LogLevelEnum, ITerminalOptions } from 'common/services/Services'; +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; @@ -49,6 +50,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { protected readonly _logService: ILogService; protected readonly _charsetService: ICharsetService; protected readonly _dirtyRowService: IDirtyRowService; + protected readonly _oscLinkService: IOscLinkService; public readonly coreMouseService: ICoreMouseService; public readonly coreService: ICoreService; @@ -118,6 +120,8 @@ 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); + this._oscLinkService = this._instantiationService.createInstance(OscLinkService); + this._instantiationService.setService(IOscLinkService, this._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); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index d5b8d9481c..b613a24a90 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, ColorIndex, ColorRequestType, IOscLinkData } from 'common/Types'; import { C0, C1 } from 'common/data/EscapeSequences'; import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets'; import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; @@ -214,8 +214,6 @@ class DECRQSS implements IDcsHandler { * @vt: #N DCS XTSETTCAP "Set Terminfo Data" "DCS + p Pt ST" "Set Terminfo Data." */ - - /** * The terminal's standard implementation of IInputHandler, this handles all * input from the Parser. @@ -230,6 +228,7 @@ export class InputHandler extends Disposable implements IInputHandler { private _workCell: CellData = new CellData(); private _windowTitle = ''; private _iconName = ''; + private _currentHyperlink?: IOscLinkData; protected _windowTitleStack: string[] = []; protected _iconNameStack: string[] = []; @@ -265,6 +264,10 @@ 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 _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 +406,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. @@ -2889,6 +2894,66 @@ export class InputHandler extends Disposable implements IInputHandler { return true; } + /** + * 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._curAttrData.extended = this._curAttrData.extended.clone(); + this._curAttrData.extended.urlId = 1; + this._curAttrData.updateExtended(); + console.log('hasExtendedAttrs?', this._curAttrData.hasExtendedAttrs()); + this._onStartHyperlink.fire(this._currentHyperlink); + return true; + } + + private _finishHyperlink(): boolean { + this._curAttrData.extended = this._curAttrData.extended.clone(); + this._curAttrData.extended.urlId = 0; + this._curAttrData.updateExtended(); + this._onFinishHyperlink.fire(); + this._currentHyperlink = undefined; + return true; + } + // special colors - OSC 10 | 11 | 12 private _specialColors = [ColorIndex.FOREGROUND, ColorIndex.BACKGROUND, ColorIndex.CURSOR]; diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 56815da0dd..129f8e1b41 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -9,6 +9,7 @@ import { IDeleteEvent, IInsertEvent } from 'common/CircularList'; import { IParams } from 'common/parser/Types'; import { ICoreMouseService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services'; import { IBufferSet } from 'common/buffer/Types'; +import { UnderlineStyle } from 'common/buffer/Constants'; export interface ICoreTerminal { coreMouseService: ICoreMouseService; @@ -114,12 +115,24 @@ export type IColorRGB = [number, number, number]; export interface IExtendedAttrs { ext: number; - underlineStyle: number; + underlineStyle: UnderlineStyle; underlineColor: number; + urlId: number; clone(): IExtendedAttrs; isEmpty(): boolean; } +/** + * 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 IOscLinkData { + id?: string; + uri: string; +} + /** Attribute data */ export interface IAttributeData { fg: number; diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index b51f7ecbe2..aac6a33dca 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -35,7 +35,12 @@ export class AttributeData implements IAttributeData { // flags public isInverse(): number { return this.fg & FgFlags.INVERSE; } public isBold(): number { return this.fg & FgFlags.BOLD; } - public isUnderline(): number { return this.fg & FgFlags.UNDERLINE; } + public isUnderline(): number { + if (this.hasExtendedAttrs() && this.extended.underlineStyle !== UnderlineStyle.NONE) { + return 1; + } + return this.fg & FgFlags.UNDERLINE; + } public isBlink(): number { return this.fg & FgFlags.BLINK; } public isInvisible(): number { return this.fg & FgFlags.INVISIBLE; } public isItalic(): number { return this.bg & BgFlags.ITALIC; } @@ -128,10 +133,24 @@ export class AttributeData implements IAttributeData { */ export class ExtendedAttrs implements IExtendedAttrs { private _ext: number = 0; - public get ext(): number { return this._ext; } + public get ext(): number { + // TODO: How to handle previous underline style if link overrides it? + if (this._urlId) { + console.log('ext, has url'); + return ( + (this._ext & ~ExtFlags.UNDERLINE_STYLE) | + (this.underlineStyle << 26) + ); + } + return this._ext; + } public set ext(value: number) { this._ext = value; } public get underlineStyle(): UnderlineStyle { + // Always return the URL style if it has one + if (this._urlId) { + return UnderlineStyle.DASHED; + } return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26; } public set underlineStyle(value: UnderlineStyle) { @@ -140,6 +159,11 @@ export class ExtendedAttrs implements IExtendedAttrs { } public get underlineColor(): number { + // Always return the URL color if it has one + if (this._urlId) { + // TODO: fix + return 0; + } return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK); } public set underlineColor(value: number) { @@ -147,16 +171,24 @@ export class ExtendedAttrs implements IExtendedAttrs { this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK); } + private _urlId: number = 0; + public get urlId(): number { + return this._urlId; + } + public set urlId(value: number) { + this._urlId = value; + } + constructor( - underlineStyle: UnderlineStyle = UnderlineStyle.NONE, - underlineColor: number = Attributes.CM_DEFAULT + ext: number = 0, + urlId: number = 0 ) { - this.underlineStyle = underlineStyle; - this.underlineColor = underlineColor; + this._ext = ext; + this._urlId = urlId; } public clone(): IExtendedAttrs { - return new ExtendedAttrs(this.underlineStyle, this.underlineColor); + return new ExtendedAttrs(this._ext, this._urlId); } /** @@ -164,6 +196,6 @@ export class ExtendedAttrs implements IExtendedAttrs { * that needs to be persistant in the buffer. */ public isEmpty(): boolean { - return this.underlineStyle === UnderlineStyle.NONE; + return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0; } } diff --git a/src/common/services/OscLinkService.ts b/src/common/services/OscLinkService.ts new file mode 100644 index 0000000000..58961c4f01 --- /dev/null +++ b/src/common/services/OscLinkService.ts @@ -0,0 +1,22 @@ +import { IBufferService, IOscLinkService } from 'common/services/Services'; +import { IOscLinkData } from 'common/Types'; + +export class OscLinkService implements IOscLinkService { + public serviceBrand: any; + + constructor( + @IBufferService private readonly _bufferService: IBufferService + ) { + } + + public registerLink(linkData: IOscLinkData): number { + // TODO: Add and return properly + return 1; + } + + public getLinkData(linkId: number): IOscLinkData | undefined { + return { + uri: 'https://github.com' + }; + } +} diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 585b29ac28..5f97a48738 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, IOscLinkData } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; import { IDecorationOptions, IDecoration } from 'xterm'; @@ -272,6 +272,18 @@ export interface ITheme { extendedAnsi?: string[]; } +export const IOscLinkService = createDecorator('OscLinkService'); +export interface IOscLinkService { + serviceBrand: undefined; + /** + * Registers a link to the service, returning the link ID. The link data is managed by this + * service and will be freed when this current cursor position is trimmed off the buffer. + */ + registerLink(linkData: IOscLinkData): number; + /** Get the link data associated with a link ID. */ + getLinkData(linkId: number): IOscLinkData | undefined; +} + export const IUnicodeService = createDecorator('UnicodeService'); export interface IUnicodeService { serviceBrand: undefined; From 7970b5c1e35726103ede881f5840453ad954a3e5 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 06:05:42 -0700 Subject: [PATCH 02/11] Fix edge cases in link creation --- .../src/atlas/WebglCharAtlas.ts | 2 +- demo/client.ts | 14 +++++++++++++ demo/index.html | 1 + src/browser/OscLinkProvider.ts | 20 +++++++++++++++---- src/common/InputHandler.ts | 3 ++- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index 182b3b3e00..13dceae67c 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -356,7 +356,7 @@ export class WebglCharAtlas implements IDisposable { private _drawToCache(codeOrChars: number | string, bg: number, fg: number, ext: number): IRasterizedGlyph { const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars; - console.log('_drawToCache', chars, ext); + this.hasCanvasChanged = true; // Allow 1 cell width per character, with a minimum of 2 (CJK), plus some padding. This is used diff --git a/demo/client.ts b/demo/client.ts index 64c694f5a5..8f858b39d9 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -189,6 +189,7 @@ if (document.location.pathname === '/test') { document.getElementById('powerline-symbol-test').addEventListener('click', powerlineSymbolTest); document.getElementById('underline-test').addEventListener('click', underlineTest); document.getElementById('ansi-colors').addEventListener('click', ansiColorsTest); + document.getElementById('osc-hyperlinks').addEventListener('click', addAnsiHyperlink); document.getElementById('add-decoration').addEventListener('click', addDecoration); document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler); } @@ -827,6 +828,19 @@ function ansiColorsTest() { } } +function addAnsiHyperlink() { + term.write('\n\n\r'); + term.writeln(`Regular link with no id:`); + term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;\x07'); + term.writeln('\x1b]8;;https://xtermjs.org\x07https://xtermjs.org\x1b]8;;\x07\x1b[C<- null cell'); + term.writeln(`\nShared ID links:`); + term.writeln('╔════╗ ╔════╗'); + term.writeln('║\x1b]8;;https://github.com\x07GitH\x1b]8;;\x07║ ║ ║'); + term.writeln('║\x1b]8;;https://github.com\x07ub\x1b]8;;\x07 ║ ║ ║'); + term.writeln('╚════╝ ╚════╝'); + term.write('\x1b[3A\x1b[8C\x1b]8;;https://xtermjs.org\x07xter\x1b[B\x1b[4Dm.js\x1b]8;;\x07\x1b[2B\x1b[12D'); +} + function addDecoration() { term.options['overviewRulerWidth'] = 15; const marker = term.registerMarker(1); diff --git a/demo/index.html b/demo/index.html index 836084b3a3..c38cb00727 100644 --- a/demo/index.html +++ b/demo/index.html @@ -79,6 +79,7 @@

Test

+
Decorations
diff --git a/src/browser/OscLinkProvider.ts b/src/browser/OscLinkProvider.ts index c7594479a6..7fee62aff5 100644 --- a/src/browser/OscLinkProvider.ts +++ b/src/browser/OscLinkProvider.ts @@ -28,12 +28,14 @@ export class OscLinkProvider implements ILinkProvider { let currentStart = -1; let finishLink = false; for (let x = 0; x < lineLength; x++) { - if (!line.hasContent(x)) { + // Minor optimization, only check for content if there isn't a link in case the link ends with + // a null cell + if (currentStart === -1 && !line.hasContent(x)) { continue; } line.loadCell(x, cell); - if (cell.extended.urlId) { + if (cell.hasExtendedAttrs() && cell.extended.urlId) { if (currentStart === -1) { currentStart = x; currentLinkId = cell.extended.urlId; @@ -55,8 +57,15 @@ export class OscLinkProvider implements ILinkProvider { text, // These ranges are 1-based range: { - start: { x: currentStart + 1, y }, - end: { x: x + 1, y } + start: { + x: currentStart + 1, + y + }, + end: { + // Offset end x if it's a link that ends on the last cell in the line + x: x + (!finishLink && x === lineLength - 1 ? 1 : 0), + y + } }, activate(e, text) { console.log('activate!', text); @@ -64,6 +73,9 @@ export class OscLinkProvider implements ILinkProvider { // TODO: Embedder API to handle hover }); } + currentStart = -1; + currentLinkId = -1; + finishLink = false; } } // TODO: Handle fetching and returning other link ranges to underline other links with the same id diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index b613a24a90..e6d154b9ea 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2937,15 +2937,16 @@ export class InputHandler extends Disposable implements IInputHandler { id = parsedParams[idParamIndex].slice(3) || undefined; } this._currentHyperlink = { id, uri }; + console.log('start hyperlink'); this._curAttrData.extended = this._curAttrData.extended.clone(); this._curAttrData.extended.urlId = 1; this._curAttrData.updateExtended(); - console.log('hasExtendedAttrs?', this._curAttrData.hasExtendedAttrs()); this._onStartHyperlink.fire(this._currentHyperlink); return true; } private _finishHyperlink(): boolean { + console.log('finish hyperlink'); this._curAttrData.extended = this._curAttrData.extended.clone(); this._curAttrData.extended.urlId = 0; this._curAttrData.updateExtended(); From fd79100d95daa54c727681a6b987df37180db7fd Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 06:10:27 -0700 Subject: [PATCH 03/11] Fix test service injection --- src/common/CoreTerminal.ts | 2 +- src/common/InputHandler.test.ts | 18 ++++++++++++------ src/common/InputHandler.ts | 11 +++-------- src/common/TestUtils.test.ts | 14 ++++++++++++-- src/common/services/OscLinkService.ts | 1 + 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 4a1c99ffac..6e318ce7c0 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -124,7 +124,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this._instantiationService.setService(IOscLinkService, this._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 = new InputHandler(this._bufferService, this._charsetService, this.coreService, this._dirtyRowService, this._logService, this.optionsService, this._oscLinkService, this.coreMouseService, this.unicodeService); this.register(forwardEvent(this._inputHandler.onLineFeed, this._onLineFeed)); this.register(this._inputHandler); diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index eac4105243..2fa4ac2fa2 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -11,7 +11,7 @@ import { CellData } from 'common/buffer/CellData'; import { Attributes, UnderlineStyle } from 'common/buffer/Constants'; import { AttributeData } from 'common/buffer/AttributeData'; import { Params } from 'common/parser/Params'; -import { MockCoreService, MockBufferService, MockDirtyRowService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService } from 'common/TestUtils.test'; +import { MockCoreService, MockBufferService, MockDirtyRowService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService } from 'common/TestUtils.test'; import { IBufferService, ICoreService } from 'common/services/Services'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { clone } from 'common/Clone'; @@ -67,7 +67,7 @@ describe('InputHandler', () => { bufferService.resize(80, 30); coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService); - inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService()); + inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); }); describe('SL/SR/DECIC/DECDC', () => { @@ -236,7 +236,7 @@ describe('InputHandler', () => { describe('setMode', () => { it('should toggle bracketedPasteMode', () => { const coreService = new MockCoreService(); - const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService()); + const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); // Set bracketed paste mode inputHandler.setModePrivate(Params.fromArray([2004])); assert.equal(coreService.decPrivateModes.bracketedPasteMode, true); @@ -261,6 +261,7 @@ describe('InputHandler', () => { new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), + new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -307,6 +308,7 @@ describe('InputHandler', () => { new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), + new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -357,6 +359,7 @@ describe('InputHandler', () => { new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), + new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -394,6 +397,7 @@ describe('InputHandler', () => { new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), + new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -444,6 +448,7 @@ describe('InputHandler', () => { new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), + new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -570,6 +575,7 @@ describe('InputHandler', () => { new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), + new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService() ); @@ -593,7 +599,7 @@ describe('InputHandler', () => { beforeEach(() => { bufferService = new MockBufferService(80, 30); - handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockCoreMouseService(), new MockUnicodeService()); + handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockDirtyRowService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); }); it('should handle DECSET/DECRST 47 (alt screen buffer)', async () => { await handler.parseP('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST'); @@ -790,7 +796,7 @@ describe('InputHandler', () => { describe('colon notation', () => { let inputHandler2: TestInputHandler; beforeEach(() => { - inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService()); + inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); }); describe('should equal to semicolon', () => { it('CSI 38:2::50:100:150 m', async () => { @@ -2156,7 +2162,7 @@ describe('InputHandler - async handlers', () => { coreService = new CoreService(() => { }, bufferService, new MockLogService(), optionsService); coreService.onData(data => { console.log(data); }); - inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockCoreMouseService(), new MockUnicodeService()); + inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockDirtyRowService(), new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); }); it('async CUP with CPR check', async () => { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index e6d154b9ea..76d35fd4bb 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -17,7 +17,7 @@ import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IFunctionId import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; import { AttributeData } from 'common/buffer/AttributeData'; -import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum } from 'common/services/Services'; +import { ICoreService, IBufferService, IOptionsService, ILogService, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services'; import { OscHandler } from 'common/parser/OscParser'; import { DcsHandler } from 'common/parser/DcsParser'; import { IBuffer } from 'common/buffer/Types'; @@ -264,10 +264,6 @@ 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 _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, @@ -284,6 +280,7 @@ export class InputHandler extends Disposable implements IInputHandler { private readonly _dirtyRowService: IDirtyRowService, private readonly _logService: ILogService, private readonly _optionsService: IOptionsService, + private readonly _oscLinkService: IOscLinkService, private readonly _coreMouseService: ICoreMouseService, private readonly _unicodeService: IUnicodeService, private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser() @@ -2937,11 +2934,10 @@ export class InputHandler extends Disposable implements IInputHandler { id = parsedParams[idParamIndex].slice(3) || undefined; } this._currentHyperlink = { id, uri }; - console.log('start hyperlink'); + this._oscLinkService.registerLink(this._currentHyperlink); this._curAttrData.extended = this._curAttrData.extended.clone(); this._curAttrData.extended.urlId = 1; this._curAttrData.updateExtended(); - this._onStartHyperlink.fire(this._currentHyperlink); return true; } @@ -2950,7 +2946,6 @@ export class InputHandler extends Disposable implements IInputHandler { this._curAttrData.extended = this._curAttrData.extended.clone(); this._curAttrData.extended.urlId = 0; this._curAttrData.updateExtended(); - this._onFinishHyperlink.fire(); this._currentHyperlink = undefined; return true; } diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 48f3a69e02..1d353b91dc 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -3,13 +3,13 @@ * @license MIT */ -import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration } from 'common/services/Services'; +import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IDirtyRowService, ICoreMouseService, ICharsetService, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService } from 'common/services/Services'; import { IEvent, EventEmitter } from 'common/EventEmitter'; import { clone } from 'common/Clone'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { IBufferSet, IBuffer } from 'common/buffer/Types'; import { BufferSet } from 'common/buffer/BufferSet'; -import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData } from 'common/Types'; +import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes, IAttributeData, IOscLinkData } from 'common/Types'; import { UnicodeV6 } from 'common/input/UnicodeV6'; import { IDecorationOptions, IDecoration } from 'xterm'; @@ -138,6 +138,16 @@ export class MockOptionsService implements IOptionsService { } } +export class MockOscLinkService implements IOscLinkService { + public serviceBrand: any; + public registerLink(linkData: IOscLinkData): number { + return 1; + } + public getLinkData(linkId: number): IOscLinkData | undefined { + return undefined; + } +} + // defaults to V6 always to keep tests passing export class MockUnicodeService implements IUnicodeService { public serviceBrand: any; diff --git a/src/common/services/OscLinkService.ts b/src/common/services/OscLinkService.ts index 58961c4f01..b67b984663 100644 --- a/src/common/services/OscLinkService.ts +++ b/src/common/services/OscLinkService.ts @@ -10,6 +10,7 @@ export class OscLinkService implements IOscLinkService { } public registerLink(linkData: IOscLinkData): number { + console.log('register link'); // TODO: Add and return properly return 1; } From ace99d125db2ac7d1cdc782ede16fcfd8315aaf7 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 07:02:42 -0700 Subject: [PATCH 04/11] Progress on osc link service --- demo/client.ts | 19 ++++-- src/common/InputHandler.ts | 15 +++-- src/common/TestUtils.test.ts | 2 + src/common/services/OscLinkService.ts | 85 ++++++++++++++++++++++++--- src/common/services/Services.ts | 4 ++ 5 files changed, 105 insertions(+), 20 deletions(-) diff --git a/demo/client.ts b/demo/client.ts index 8f858b39d9..96f6b33515 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -833,12 +833,19 @@ function addAnsiHyperlink() { term.writeln(`Regular link with no id:`); term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;\x07'); term.writeln('\x1b]8;;https://xtermjs.org\x07https://xtermjs.org\x1b]8;;\x07\x1b[C<- null cell'); - term.writeln(`\nShared ID links:`); - term.writeln('╔════╗ ╔════╗'); - term.writeln('║\x1b]8;;https://github.com\x07GitH\x1b]8;;\x07║ ║ ║'); - term.writeln('║\x1b]8;;https://github.com\x07ub\x1b]8;;\x07 ║ ║ ║'); - term.writeln('╚════╝ ╚════╝'); - term.write('\x1b[3A\x1b[8C\x1b]8;;https://xtermjs.org\x07xter\x1b[B\x1b[4Dm.js\x1b]8;;\x07\x1b[2B\x1b[12D'); + term.writeln(`\nAdjacent links:`); + term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;https://xtermjs.org\x07xterm.js\x1b]8;;\x07'); + term.writeln(`\nShared ID link:`); + term.writeln('╔════╗'); + term.writeln('║\x1b]8;id=testid;https://github.com\x07GitH\x1b]8;;\x07║'); + term.writeln('║\x1b]8;id=testid;https://github.com\x07ub\x1b]8;;\x07 ║'); + term.writeln('╚════╝'); + term.writeln(`\nWrapped link with no ID (not meant to share underline):`); + term.writeln('╔════╗'); + term.writeln('║ ║'); + term.writeln('║ ║'); + term.writeln('╚════╝'); + term.write('\x1b[3A\x1b[1C\x1b]8;;https://xtermjs.org\x07xter\x1b[B\x1b[4Dm.js\x1b]8;;\x07\x1b[2B\x1b[5D'); } function addDecoration() { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 76d35fd4bb..37a0a12749 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -228,7 +228,7 @@ export class InputHandler extends Disposable implements IInputHandler { private _workCell: CellData = new CellData(); private _windowTitle = ''; private _iconName = ''; - private _currentHyperlink?: IOscLinkData; + private _currentLinkId?: number; protected _windowTitleStack: string[] = []; protected _iconNameStack: string[] = []; @@ -639,6 +639,9 @@ export class InputHandler extends Disposable implements IInputHandler { if (screenReaderMode) { this._onA11yChar.fire(stringFromCodePoint(code)); } + if (this._currentLinkId !== undefined) { + this._oscLinkService.addLineToLink(this._currentLinkId, this._activeBuffer.ybase + this._activeBuffer.y); + } // insert combining char at last cursor position // this._activeBuffer.x should never be 0 for a combining char @@ -2924,7 +2927,7 @@ export class InputHandler extends Disposable implements IInputHandler { private _createHyperlink(params: string, uri: string): boolean { // It's legal to open a new hyperlink without explicitly finishing the previous one - if (this._currentHyperlink) { + if (this._currentLinkId !== undefined) { this._finishHyperlink(); } const parsedParams = params.split(':'); @@ -2933,10 +2936,10 @@ export class InputHandler extends Disposable implements IInputHandler { if (idParamIndex !== -1) { id = parsedParams[idParamIndex].slice(3) || undefined; } - this._currentHyperlink = { id, uri }; - this._oscLinkService.registerLink(this._currentHyperlink); this._curAttrData.extended = this._curAttrData.extended.clone(); - this._curAttrData.extended.urlId = 1; + this._currentLinkId = this._oscLinkService.registerLink({ id, uri }); + this._curAttrData.extended.urlId = this._currentLinkId; + console.log('register', uri, `id=${this._curAttrData.extended.urlId}`); this._curAttrData.updateExtended(); return true; } @@ -2946,7 +2949,7 @@ export class InputHandler extends Disposable implements IInputHandler { this._curAttrData.extended = this._curAttrData.extended.clone(); this._curAttrData.extended.urlId = 0; this._curAttrData.updateExtended(); - this._currentHyperlink = undefined; + this._currentLinkId = undefined; return true; } diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 1d353b91dc..215481afa5 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -146,6 +146,8 @@ export class MockOscLinkService implements IOscLinkService { public getLinkData(linkId: number): IOscLinkData | undefined { return undefined; } + public addLineToLink(linkId: number, y: number): void { + } } // defaults to V6 always to keep tests passing diff --git a/src/common/services/OscLinkService.ts b/src/common/services/OscLinkService.ts index b67b984663..a3f8af1d77 100644 --- a/src/common/services/OscLinkService.ts +++ b/src/common/services/OscLinkService.ts @@ -1,23 +1,92 @@ +/** + * Copyright (c) 2022 The xterm.js authors. All rights reserved. + * @license MIT + */ import { IBufferService, IOscLinkService } from 'common/services/Services'; -import { IOscLinkData } from 'common/Types'; +import { IMarker, IOscLinkData } from 'common/Types'; export class OscLinkService implements IOscLinkService { public serviceBrand: any; + private _nextId = 1; + + // TODO: Evict on marker dispose + private _entriesNoId: IOscLinkEntryNoId[] = []; + private _entriesWithId: Map = new Map(); + + // The "link id" (number) which is the numberic representation of a unique link should not be + // confused with "id" (string) which comes in with "id=" in the OSC link's properties + private _dataByLinkId: Map = new Map(); + constructor( @IBufferService private readonly _bufferService: IBufferService ) { } - public registerLink(linkData: IOscLinkData): number { - console.log('register link'); - // TODO: Add and return properly - return 1; + public registerLink(data: IOscLinkData): number { + // TODO: Extend range where appropriate + const buffer = this._bufferService.buffer; + + // Links with no id will only ever be registered a single time + if (data.id === undefined) { + const entry: IOscLinkEntryNoId = { + data, + id: this._nextId++, + lines: [buffer.addMarker(buffer.ybase + buffer.y)] + }; + this._entriesNoId.push(entry); + this._dataByLinkId.set(entry.id, entry); + return entry.id; + } + + const castData = data as Required; + const key = this._getEntryIdKey(castData); + const match = this._entriesWithId.get(key); + if (match) { + this.addLineToLink(match.id, buffer.ybase + buffer.y); + return match.id; + } + + const entry: IOscLinkEntryWithId = { + id: this._nextId++, + key: this._getEntryIdKey(castData), + data: castData, + lines: [buffer.addMarker(buffer.ybase + buffer.y)] + }; + this._entriesWithId.set(entry.key, entry); + this._dataByLinkId.set(entry.id, entry); + return entry.id; + } + + public addLineToLink(linkId: number, y: number): void { + const link = this._dataByLinkId.get(linkId); + if (!link) { + return; + } + if (link.lines.every(e => e.line !== y)) { + console.log(' add new line', y); + link.lines.push(this._bufferService.buffer.addMarker(y)); + } } public getLinkData(linkId: number): IOscLinkData | undefined { - return { - uri: 'https://github.com' - }; + return this._dataByLinkId.get(linkId)?.data; + } + + private _getEntryIdKey(linkData: Required): string { + return `${linkData.id};;${linkData.uri}`; } } + +interface IOscLinkEntry { + data: T; + id: number; + lines: IMarker[]; +} + +interface IOscLinkEntryNoId extends IOscLinkEntry { +} + +interface IOscLinkEntryWithId extends IOscLinkEntry> { + key: string; +} diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 5f97a48738..88a2125207 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -280,6 +280,10 @@ export interface IOscLinkService { * service and will be freed when this current cursor position is trimmed off the buffer. */ registerLink(linkData: IOscLinkData): number; + /** + * Adds a line to a link if needed. + */ + addLineToLink(linkId: number, y: number): void; /** Get the link data associated with a link ID. */ getLinkData(linkId: number): IOscLinkData | undefined; } From d9b8c6a838ed2e03972f418065858f2494ee5310 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 07:35:46 -0700 Subject: [PATCH 05/11] linkHandler option and default handler --- demo/client.ts | 4 +-- src/browser/OscLinkProvider.ts | 34 +++++++++++++++++++++---- src/common/services/OptionsService.ts | 1 + src/common/services/OscLinkService.ts | 4 +-- src/common/services/Services.ts | 3 ++- typings/xterm.d.ts | 36 +++++++++++++++++++++++++++ 6 files changed, 72 insertions(+), 10 deletions(-) diff --git a/demo/client.ts b/demo/client.ts index 96f6b33515..cfebf3849d 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -835,12 +835,12 @@ function addAnsiHyperlink() { term.writeln('\x1b]8;;https://xtermjs.org\x07https://xtermjs.org\x1b]8;;\x07\x1b[C<- null cell'); term.writeln(`\nAdjacent links:`); term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;https://xtermjs.org\x07xterm.js\x1b]8;;\x07'); - term.writeln(`\nShared ID link:`); + term.writeln(`\nShared ID link (underline should be shared):`); term.writeln('╔════╗'); term.writeln('║\x1b]8;id=testid;https://github.com\x07GitH\x1b]8;;\x07║'); term.writeln('║\x1b]8;id=testid;https://github.com\x07ub\x1b]8;;\x07 ║'); term.writeln('╚════╝'); - term.writeln(`\nWrapped link with no ID (not meant to share underline):`); + term.writeln(`\nWrapped link with no ID (not necessarily meant to share underline):`); term.writeln('╔════╗'); term.writeln('║ ║'); term.writeln('║ ║'); diff --git a/src/browser/OscLinkProvider.ts b/src/browser/OscLinkProvider.ts index 7fee62aff5..96a2c395d8 100644 --- a/src/browser/OscLinkProvider.ts +++ b/src/browser/OscLinkProvider.ts @@ -5,16 +5,22 @@ import { ILink, ILinkProvider } from 'browser/Types'; import { CellData } from 'common/buffer/CellData'; -import { IBufferService, IOscLinkService } from 'common/services/Services'; +import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services'; export class OscLinkProvider implements ILinkProvider { constructor( @IBufferService private readonly _bufferService: IBufferService, + @IOptionsService private readonly _optionsService: IOptionsService, @IOscLinkService private readonly _oscLinkService: IOscLinkService ) { } public provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { + // OSC links only work when a link handler is set + // if (this._optionsService.rawOptions.linkHandler === null) { + // return; + // } + const line = this._bufferService.buffer.lines.get(y - 1); if (!line) { callback(undefined); @@ -52,12 +58,14 @@ export class OscLinkProvider implements ILinkProvider { if (finishLink || (currentStart !== -1 && x === lineLength - 1)) { const text = this._oscLinkService.getLinkData(currentLinkId)?.uri; if (text) { + const linkHandler = this._optionsService.rawOptions.linkHandler; // OSC links always use underline and pointer decorations result.push({ text, // These ranges are 1-based range: { start: { + // TODO: Adjacent links aren't working correctly x: currentStart + 1, y }, @@ -67,10 +75,9 @@ export class OscLinkProvider implements ILinkProvider { y } }, - activate(e, text) { - console.log('activate!', text); - } - // TODO: Embedder API to handle hover + activate: linkHandler?.activate || defaultActivate, + hover: linkHandler?.hover, + leave: linkHandler?.leave }); } currentStart = -1; @@ -82,3 +89,20 @@ export class OscLinkProvider implements ILinkProvider { callback(result); } } + +function defaultActivate(e: MouseEvent, uri: string): void { + const answer = confirm(`Do you want to navigate to ${uri}?`); + if (answer) { + const newWindow = window.open(); + if (newWindow) { + try { + newWindow.opener = null; + } catch { + // no-op, Electron can throw + } + newWindow.location.href = uri; + } else { + console.warn('Opening link blocked as opener could not be cleared'); + } + } +} diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index ab9edfbfb2..744903f177 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -24,6 +24,7 @@ export const DEFAULT_OPTIONS: Readonly = { fontWeightBold: 'bold', lineHeight: 1.0, letterSpacing: 0, + linkHandler: null, logLevel: 'info', scrollback: 1000, scrollSensitivity: 1, diff --git a/src/common/services/OscLinkService.ts b/src/common/services/OscLinkService.ts index a3f8af1d77..d3744229c0 100644 --- a/src/common/services/OscLinkService.ts +++ b/src/common/services/OscLinkService.ts @@ -24,7 +24,6 @@ export class OscLinkService implements IOscLinkService { } public registerLink(data: IOscLinkData): number { - // TODO: Extend range where appropriate const buffer = this._bufferService.buffer; // Links with no id will only ever be registered a single time @@ -39,6 +38,7 @@ export class OscLinkService implements IOscLinkService { return entry.id; } + // Add the line to the link if it already exists const castData = data as Required; const key = this._getEntryIdKey(castData); const match = this._entriesWithId.get(key); @@ -47,6 +47,7 @@ export class OscLinkService implements IOscLinkService { return match.id; } + // Create the link const entry: IOscLinkEntryWithId = { id: this._nextId++, key: this._getEntryIdKey(castData), @@ -64,7 +65,6 @@ export class OscLinkService implements IOscLinkService { return; } if (link.lines.every(e => e.line !== y)) { - console.log(' add new line', y); link.lines.push(this._bufferService.buffer.addMarker(y)); } } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 88a2125207..63543c5689 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -7,7 +7,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, IOscLinkData } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; -import { IDecorationOptions, IDecoration } from 'xterm'; +import { IDecorationOptions, IDecoration, ILinkHandler } from 'xterm'; export const IBufferService = createDecorator('BufferService'); export interface IBufferService { @@ -223,6 +223,7 @@ export interface ITerminalOptions { fontWeightBold: FontWeight; letterSpacing: number; lineHeight: number; + linkHandler: ILinkHandler | null; logLevel: LogLevel; macOptionIsMeta: boolean; macOptionClickForcesSelection: boolean; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 05058d04df..2fa1742174 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -128,6 +128,14 @@ declare module 'xterm' { */ lineHeight?: number; + /** + * The handler for OSC 8 hyperlinks. Links will use the `confirm` browser + * API if no link handler is set. Consider the security of users when using + * this, there should be some tooltip or prompt when hovering or activating + * the link. + */ + linkHandler?: ILinkHandler | null; + /** * What log level to use, this will log for all levels below and including * what is set: @@ -1101,6 +1109,34 @@ declare module 'xterm' { y: number; } + /** + * A link handler for OSC 8 hyperlinks. + */ + interface ILinkHandler { + /** + * 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. To use this to create a DOM-based hover tooltip, + * create the hover element within `Terminal.element` and add the `xterm-hover` class to it, + * that will cause mouse events to not fall through and activate other links. + * @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 custom link provider. */ From c39d2351683008e22c5766458ec066da337ba4b8 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 07:42:21 -0700 Subject: [PATCH 06/11] Fix adjacent link edge case --- demo/client.ts | 2 +- src/browser/OscLinkProvider.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/demo/client.ts b/demo/client.ts index cfebf3849d..507663a88a 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -834,7 +834,7 @@ function addAnsiHyperlink() { term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;\x07'); term.writeln('\x1b]8;;https://xtermjs.org\x07https://xtermjs.org\x1b]8;;\x07\x1b[C<- null cell'); term.writeln(`\nAdjacent links:`); - term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;https://xtermjs.org\x07xterm.js\x1b]8;;\x07'); + term.writeln('\x1b]8;;https://github.com\x07GitHub\x1b]8;;https://xtermjs.org\x07\x1b[32mxterm.js\x1b[0m\x1b]8;;\x07'); term.writeln(`\nShared ID link (underline should be shared):`); term.writeln('╔════╗'); term.writeln('║\x1b]8;id=testid;https://github.com\x07GitH\x1b]8;;\x07║'); diff --git a/src/browser/OscLinkProvider.ts b/src/browser/OscLinkProvider.ts index 96a2c395d8..8d3977c634 100644 --- a/src/browser/OscLinkProvider.ts +++ b/src/browser/OscLinkProvider.ts @@ -65,7 +65,6 @@ export class OscLinkProvider implements ILinkProvider { // These ranges are 1-based range: { start: { - // TODO: Adjacent links aren't working correctly x: currentStart + 1, y }, @@ -80,12 +79,20 @@ export class OscLinkProvider implements ILinkProvider { leave: linkHandler?.leave }); } - currentStart = -1; - currentLinkId = -1; finishLink = false; + + // Clear link or start a new link if one starts immediately + if (cell.hasExtendedAttrs() && cell.extended.urlId) { + currentStart = x; + currentLinkId = cell.extended.urlId; + } else { + currentStart = -1; + currentLinkId = -1; + } } } // TODO: Handle fetching and returning other link ranges to underline other links with the same id + console.log('result', result); callback(result); } } From 9c9ff2b38c6e8f3f0b4693f11527a08339414789 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 07:46:16 -0700 Subject: [PATCH 07/11] Clean up --- src/browser/OscLinkProvider.ts | 9 ++------- src/common/InputHandler.ts | 3 --- src/common/buffer/AttributeData.ts | 7 ------- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/browser/OscLinkProvider.ts b/src/browser/OscLinkProvider.ts index 8d3977c634..38c0710681 100644 --- a/src/browser/OscLinkProvider.ts +++ b/src/browser/OscLinkProvider.ts @@ -16,11 +16,6 @@ export class OscLinkProvider implements ILinkProvider { } public provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { - // OSC links only work when a link handler is set - // if (this._optionsService.rawOptions.linkHandler === null) { - // return; - // } - const line = this._bufferService.buffer.lines.get(y - 1); if (!line) { callback(undefined); @@ -28,6 +23,7 @@ export class OscLinkProvider implements ILinkProvider { } const result: ILink[] = []; + const linkHandler = this._optionsService.rawOptions.linkHandler; const cell = new CellData(); const lineLength = line.getTrimmedLength(); let currentLinkId = -1; @@ -58,7 +54,6 @@ export class OscLinkProvider implements ILinkProvider { if (finishLink || (currentStart !== -1 && x === lineLength - 1)) { const text = this._oscLinkService.getLinkData(currentLinkId)?.uri; if (text) { - const linkHandler = this._optionsService.rawOptions.linkHandler; // OSC links always use underline and pointer decorations result.push({ text, @@ -91,8 +86,8 @@ export class OscLinkProvider implements ILinkProvider { } } } + // TODO: Handle fetching and returning other link ranges to underline other links with the same id - console.log('result', result); callback(result); } } diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 37a0a12749..34c99f844f 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2912,7 +2912,6 @@ export class InputHandler extends Disposable implements IInputHandler { */ public setHyperlink(data: string): boolean { const args = data.split(';'); - console.log('hyperlink', args); if (args.length < 2) { return false; } @@ -2939,13 +2938,11 @@ export class InputHandler extends Disposable implements IInputHandler { this._curAttrData.extended = this._curAttrData.extended.clone(); this._currentLinkId = this._oscLinkService.registerLink({ id, uri }); this._curAttrData.extended.urlId = this._currentLinkId; - console.log('register', uri, `id=${this._curAttrData.extended.urlId}`); this._curAttrData.updateExtended(); return true; } private _finishHyperlink(): boolean { - console.log('finish hyperlink'); this._curAttrData.extended = this._curAttrData.extended.clone(); this._curAttrData.extended.urlId = 0; this._curAttrData.updateExtended(); diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index aac6a33dca..3af3d29393 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -134,9 +134,7 @@ export class AttributeData implements IAttributeData { export class ExtendedAttrs implements IExtendedAttrs { private _ext: number = 0; public get ext(): number { - // TODO: How to handle previous underline style if link overrides it? if (this._urlId) { - console.log('ext, has url'); return ( (this._ext & ~ExtFlags.UNDERLINE_STYLE) | (this.underlineStyle << 26) @@ -159,11 +157,6 @@ export class ExtendedAttrs implements IExtendedAttrs { } public get underlineColor(): number { - // Always return the URL color if it has one - if (this._urlId) { - // TODO: fix - return 0; - } return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK); } public set underlineColor(value: number) { From 8191483fbd9b5a68c47cad2b6d66738adc5fa66b Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 08:05:42 -0700 Subject: [PATCH 08/11] Evict links from maps when markers are disposed --- src/common/services/OscLinkService.ts | 45 ++++++++++++++++++++------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/common/services/OscLinkService.ts b/src/common/services/OscLinkService.ts index d3744229c0..13bd8aa4bd 100644 --- a/src/common/services/OscLinkService.ts +++ b/src/common/services/OscLinkService.ts @@ -10,12 +10,16 @@ export class OscLinkService implements IOscLinkService { private _nextId = 1; - // TODO: Evict on marker dispose - private _entriesNoId: IOscLinkEntryNoId[] = []; + /** + * A map of the link key to link entry. This is used to add additional lines to links with ids. + */ private _entriesWithId: Map = new Map(); - // The "link id" (number) which is the numberic representation of a unique link should not be - // confused with "id" (string) which comes in with "id=" in the OSC link's properties + /** + * A map of the link id to the link entry. The "link id" (number) which is the numberic + * representation of a unique link should not be confused with "id" (string) which comes in with + * `id=` in the OSC link's properties. + */ private _dataByLinkId: Map = new Map(); constructor( @@ -28,12 +32,13 @@ export class OscLinkService implements IOscLinkService { // Links with no id will only ever be registered a single time if (data.id === undefined) { + const marker = buffer.addMarker(buffer.ybase + buffer.y); const entry: IOscLinkEntryNoId = { data, id: this._nextId++, - lines: [buffer.addMarker(buffer.ybase + buffer.y)] + lines: [marker] }; - this._entriesNoId.push(entry); + marker.onDispose(() => this._removeMarkerFromLink(entry, marker)); this._dataByLinkId.set(entry.id, entry); return entry.id; } @@ -48,24 +53,28 @@ export class OscLinkService implements IOscLinkService { } // Create the link + const marker = buffer.addMarker(buffer.ybase + buffer.y); const entry: IOscLinkEntryWithId = { id: this._nextId++, key: this._getEntryIdKey(castData), data: castData, - lines: [buffer.addMarker(buffer.ybase + buffer.y)] + lines: [marker] }; + marker.onDispose(() => this._removeMarkerFromLink(entry, marker)); this._entriesWithId.set(entry.key, entry); this._dataByLinkId.set(entry.id, entry); return entry.id; } public addLineToLink(linkId: number, y: number): void { - const link = this._dataByLinkId.get(linkId); - if (!link) { + const entry = this._dataByLinkId.get(linkId); + if (!entry) { return; } - if (link.lines.every(e => e.line !== y)) { - link.lines.push(this._bufferService.buffer.addMarker(y)); + if (entry.lines.every(e => e.line !== y)) { + const marker = this._bufferService.buffer.addMarker(y); + entry.lines.push(marker); + marker.onDispose(() => this._removeMarkerFromLink(entry, marker)); } } @@ -76,6 +85,20 @@ export class OscLinkService implements IOscLinkService { private _getEntryIdKey(linkData: Required): string { return `${linkData.id};;${linkData.uri}`; } + + private _removeMarkerFromLink(entry: IOscLinkEntryNoId | IOscLinkEntryWithId, marker: IMarker): void { + const index = entry.lines.indexOf(marker); + if (index === -1) { + return; + } + entry.lines.splice(index, 1); + if (entry.lines.length === 0) { + if (entry.data.id !== undefined) { + this._entriesWithId.delete((entry as IOscLinkEntryWithId).key); + } + this._dataByLinkId.delete(entry.id); + } + } } interface IOscLinkEntry { From e87e15e87bc6a7f8dda32b3047e9ce7c0b996b60 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 10:36:42 -0700 Subject: [PATCH 09/11] Only override AttributeData.isUnderline when urlId is set --- src/common/buffer/AttributeData.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index 3af3d29393..e4f464cdd2 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -36,7 +36,7 @@ export class AttributeData implements IAttributeData { public isInverse(): number { return this.fg & FgFlags.INVERSE; } public isBold(): number { return this.fg & FgFlags.BOLD; } public isUnderline(): number { - if (this.hasExtendedAttrs() && this.extended.underlineStyle !== UnderlineStyle.NONE) { + if (this.hasExtendedAttrs() && this.extended.urlId) { return 1; } return this.fg & FgFlags.UNDERLINE; From 2cfbee413ff2289506f4a6135865273d0700b539 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 11:30:08 -0700 Subject: [PATCH 10/11] Add some OscLinkService unit tests --- src/common/services/BufferService.ts | 10 ++--- src/common/services/OscLinkService.test.ts | 44 ++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 src/common/services/OscLinkService.test.ts diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index bba60dd8a3..e3b7dcd87a 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -32,13 +32,11 @@ export class BufferService extends Disposable implements IBufferService { /** An IBufferline to clone/copy from for new blank lines */ private _cachedBlankLine: IBufferLine | undefined; - constructor( - @IOptionsService private _optionsService: IOptionsService - ) { + constructor(@IOptionsService optionsService: IOptionsService) { super(); - this.cols = Math.max(_optionsService.rawOptions.cols || 0, MINIMUM_COLS); - this.rows = Math.max(_optionsService.rawOptions.rows || 0, MINIMUM_ROWS); - this.buffers = new BufferSet(_optionsService, this); + this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS); + this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS); + this.buffers = new BufferSet(optionsService, this); } public dispose(): void { diff --git a/src/common/services/OscLinkService.test.ts b/src/common/services/OscLinkService.test.ts new file mode 100644 index 0000000000..5000e8e273 --- /dev/null +++ b/src/common/services/OscLinkService.test.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2020 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { AttributeData } from 'common/buffer/AttributeData'; +import { BufferService } from 'common/services/BufferService'; +import { OptionsService } from 'common/services/OptionsService'; +import { OscLinkService } from 'common/services/OscLinkService'; +import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services'; + +describe('OscLinkService', () => { + describe('constructor', () => { + let bufferService: IBufferService; + let optionsService: IOptionsService; + let oscLinkService: IOscLinkService; + beforeEach(() => { + optionsService = new OptionsService({ rows: 3, cols: 10 }); + bufferService = new BufferService(optionsService); + oscLinkService = new OscLinkService(bufferService); + }); + + it('link IDs are created and fetched consistently', () => { + const linkId = oscLinkService.registerLink({ id: 'foo', uri: 'bar' }); + assert.ok(linkId); + assert.equal(oscLinkService.registerLink({ id: 'foo', uri: 'bar' }), linkId); + }); + + it('should dispose the link ID when the last marker is trimmed from the buffer', () => { + // Activate the alt buffer to get 0 scrollback + bufferService.buffers.activateAltBuffer(); + const linkId = oscLinkService.registerLink({ id: 'foo', uri: 'bar' }); + assert.ok(linkId); + bufferService.scroll(new AttributeData()); + assert.notStrictEqual(oscLinkService.registerLink({ id: 'foo', uri: 'bar' }), linkId); + }); + + it('should fetch link data from link id', () => { + const linkId = oscLinkService.registerLink({ id: 'foo', uri: 'bar' }); + assert.deepStrictEqual(oscLinkService.getLinkData(linkId), { id: 'foo', uri: 'bar' }); + }); + }); +}); From bc528cd870e52c75787137e06568343de1029155 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sun, 7 Aug 2022 11:51:52 -0700 Subject: [PATCH 11/11] Ensure extended underline style is cleared on SGR 24 --- src/common/InputHandler.ts | 1 + src/common/buffer/AttributeData.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 34c99f844f..5a0725a496 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2500,6 +2500,7 @@ export class InputHandler extends Disposable implements IInputHandler { } else if (p === 24) { // not underlined attr.fg &= ~FgFlags.UNDERLINE; + this._processUnderline(UnderlineStyle.NONE, attr); } else if (p === 25) { // not blink attr.fg &= ~FgFlags.BLINK; diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index e4f464cdd2..3af3d29393 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -36,7 +36,7 @@ export class AttributeData implements IAttributeData { public isInverse(): number { return this.fg & FgFlags.INVERSE; } public isBold(): number { return this.fg & FgFlags.BOLD; } public isUnderline(): number { - if (this.hasExtendedAttrs() && this.extended.urlId) { + if (this.hasExtendedAttrs() && this.extended.underlineStyle !== UnderlineStyle.NONE) { return 1; } return this.fg & FgFlags.UNDERLINE;