Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OSC hyperlink support #3904

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions css/xterm.css
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@
text-decoration: underline;
}

.xterm-link {
text-decoration: underline dashed;
}

.xterm-strikethrough {
text-decoration: line-through;
}
Expand Down
2 changes: 1 addition & 1 deletion src/browser/Linkifier2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
38 changes: 35 additions & 3 deletions src/browser/Linkifier2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,6 +32,7 @@ export class Linkifier2 extends Disposable implements ILinkifier2 {
public get onHideLinkUnderline(): IEvent<ILinkifierEvent> { return this._onHideLinkUnderline.event; }

constructor(
@IOscLinkService private readonly _oscLinkStore: IOscLinkService,
@IBufferService private readonly _bufferService: IBufferService
) {
super();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
};
}
}
13 changes: 13 additions & 0 deletions src/browser/renderer/BaseRenderLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 14 additions & 4 deletions src/browser/renderer/TextRenderLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<CharData>();
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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();
}
});
Expand Down
20 changes: 11 additions & 9 deletions src/browser/renderer/dom/DomRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/browser/renderer/dom/DomRendererRowFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ describe('DomRendererRowFactory', () => {
new MockCharacterJoinerService(),
new MockOptionsService({ drawBoldTextInBrightColors: true }),
new MockCoreService(),
new MockDecorationService()
new MockDecorationService(),
null!
);
lineData = createEmptyLineData(2);
});
Expand Down
19 changes: 16 additions & 3 deletions src/browser/renderer/dom/DomRendererRowFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
) {
}

Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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 = '&nbsp;';
}
}

if (cell.isStrikethrough()) {
charElement.classList.add(STRIKETHROUGH_CLASS);
}
Expand Down
29 changes: 25 additions & 4 deletions src/common/CoreTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
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 { OscLinkService } from 'common/services/OscLinkService';

// Only trigger this warning a single time per session
let hasWriteSyncWarnHappened = false;
Expand Down Expand Up @@ -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));
Expand All @@ -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 {
Expand Down
Loading