From eabb25c7966763ffb816100f65433fd04b00df09 Mon Sep 17 00:00:00 2001 From: Jeff Principe Date: Sat, 19 May 2018 09:52:49 -0700 Subject: [PATCH] add ability to register/deregister character joiner --- src/Terminal.ts | 14 ++- src/Types.ts | 2 + src/renderer/Renderer.ts | 41 ++++++++- src/renderer/TextRenderLayer.ts | 153 ++++++++++++++++++++++++++++++-- src/renderer/Types.ts | 19 +++- src/utils/MergeRanges.test.ts | 45 ++++++++++ src/utils/MergeRanges.ts | 70 +++++++++++++++ src/utils/TestUtils.test.ts | 6 +- typings/xterm.d.ts | 20 +++++ 9 files changed, 355 insertions(+), 15 deletions(-) create mode 100644 src/utils/MergeRanges.test.ts create mode 100644 src/utils/MergeRanges.ts diff --git a/src/Terminal.ts b/src/Terminal.ts index 63f7efdb2a..9a47b0e2d8 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -21,7 +21,7 @@ * http://linux.die.net/man/7/urxvt */ -import { ICharset, IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharData, LineData } from './Types'; +import { ICharset, IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharData, LineData, CharacterJoinerHandler } from './Types'; import { IMouseZoneManager } from './input/Types'; import { IRenderer } from './renderer/Types'; import { BufferSet } from './BufferSet'; @@ -1373,6 +1373,18 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II } } + public registerCharacterJoiner(handler: CharacterJoinerHandler): number { + const joinerId = this.renderer.registerCharacterJoiner(handler); + this.refresh(0, this.rows - 1); + return joinerId; + } + + public deregisterCharacterJoiner(joinerId: number): void { + if (this.renderer.deregisterCharacterJoiner(joinerId)) { + this.refresh(0, this.rows - 1); + } + } + public get markers(): IMarker[] { return this.buffer.markers; } diff --git a/src/Types.ts b/src/Types.ts index 5075daa9bf..f7490a99c1 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -17,6 +17,8 @@ export type LineData = CharData[]; export type LinkMatcherHandler = (event: MouseEvent, uri: string) => void; export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: boolean) => void) => void; +export type CharacterJoinerHandler = (text: string) => [number, number][]; + export const enum LinkHoverEventTypes { HOVER = 'linkhover', TOOLTIP = 'linktooltip', diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts index c41dece551..f2fa8a3d96 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -7,8 +7,8 @@ import { TextRenderLayer } from './TextRenderLayer'; import { SelectionRenderLayer } from './SelectionRenderLayer'; import { CursorRenderLayer } from './CursorRenderLayer'; import { ColorManager } from './ColorManager'; -import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions } from './Types'; -import { ITerminal } from '../Types'; +import { IRenderLayer, IColorSet, IRenderer, IRenderDimensions, ICharacterJoiner } from './Types'; +import { ITerminal, CharacterJoinerHandler } from '../Types'; import { LinkRenderLayer } from './LinkRenderLayer'; import { EventEmitter } from '../EventEmitter'; import { RenderDebouncer } from '../utils/RenderDebouncer'; @@ -23,6 +23,8 @@ export class Renderer extends EventEmitter implements IRenderer { private _screenDprMonitor: ScreenDprMonitor; private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; + private _joiners: ICharacterJoiner[] = []; + private _nextJoinerId: number = 0; public colorManager: ColorManager; public dimensions: IRenderDimensions; @@ -66,7 +68,7 @@ export class Renderer extends EventEmitter implements IRenderer { // Detect whether IntersectionObserver is detected and enable renderer pause // and resume based on terminal visibility if so if ('IntersectionObserver' in window) { - const observer = new IntersectionObserver(e => this.onIntersectionChange(e[0]), {threshold: 0}); + const observer = new IntersectionObserver(e => this.onIntersectionChange(e[0]), { threshold: 0 }); observer.observe(this._terminal.element); } } @@ -186,7 +188,7 @@ export class Renderer extends EventEmitter implements IRenderer { */ private _renderRows(start: number, end: number): void { this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); - this._terminal.emit('refresh', {start, end}); + this._terminal.emit('refresh', { start, end }); } /** @@ -248,4 +250,35 @@ export class Renderer extends EventEmitter implements IRenderer { this.dimensions.actualCellHeight = this.dimensions.canvasHeight / this._terminal.rows; this.dimensions.actualCellWidth = this.dimensions.canvasWidth / this._terminal.cols; } + + public registerCharacterJoiner(handler: CharacterJoinerHandler): number { + const joiner: ICharacterJoiner = { + id: this._nextJoinerId++, + handler: handler + }; + + this._renderLayers.forEach(l => { + if (l.registerCharacterJoiner) { + l.registerCharacterJoiner(joiner); + } + }); + + return joiner.id; + } + + public deregisterCharacterJoiner(joinerId: number): boolean { + for (let i = 0; i < this._joiners.length; i++) { + if (this._joiners[i].id === joinerId) { + this._joiners.splice(i, 1); + this._renderLayers.forEach(l => { + if (l.deregisterCharacterJoiner) { + l.deregisterCharacterJoiner(joinerId); + } + }); + return true; + } + } + + return false; + } } diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index d6f9693667..6cf86ade21 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -4,11 +4,12 @@ */ import { CHAR_DATA_ATTR_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX } from '../Buffer'; -import { FLAGS, IColorSet, IRenderDimensions } from './Types'; +import { FLAGS, IColorSet, IRenderDimensions, ICharacterJoiner } from './Types'; import { CharData, ITerminal } from '../Types'; import { INVERTED_DEFAULT_COLOR } from './atlas/Types'; import { GridCache } from './GridCache'; import { BaseRenderLayer } from './BaseRenderLayer'; +import { merge } from '../utils/MergeRanges'; /** * This CharData looks like a null character, which will forc a clear and render @@ -22,6 +23,7 @@ export class TextRenderLayer extends BaseRenderLayer { private _characterWidth: number; private _characterFont: string; private _characterOverlapCache: { [key: string]: boolean } = {}; + private _joiners: ICharacterJoiner[] = []; constructor(container: HTMLElement, zIndex: number, colors: IColorSet, alpha: boolean) { super(container, 'text', zIndex, alpha, colors); @@ -52,6 +54,7 @@ export class TextRenderLayer extends BaseRenderLayer { terminal: ITerminal, firstRow: number, lastRow: number, + foreground: boolean, callback: ( code: number, char: string, @@ -66,10 +69,12 @@ export class TextRenderLayer extends BaseRenderLayer { for (let y = firstRow; y <= lastRow; y++) { const row = y + terminal.buffer.ydisp; const line = terminal.buffer.lines.get(row); + let index = 0; + const joinedRanges = foreground ? this._getJoinedCharacters(terminal, row) : []; for (let x = 0; x < terminal.cols; x++) { - const charData = line[x]; + let charData = line[x]; const code: number = charData[CHAR_DATA_CODE_INDEX]; - const char: string = charData[CHAR_DATA_CHAR_INDEX]; + let char: string = charData[CHAR_DATA_CHAR_INDEX]; const attr: number = charData[CHAR_DATA_ATTR_INDEX]; let width: number = charData[CHAR_DATA_WIDTH_INDEX]; @@ -79,6 +84,41 @@ export class TextRenderLayer extends BaseRenderLayer { continue; } + // Just in case we ended up in the middle of a range, lop off any + // ranges that have already passed + while (joinedRanges.length > 0 && joinedRanges[0][0] < x) { + joinedRanges.shift(); + } + + // Process any joined character ranges as needed. Because of how the + // ranges are produced, we know that they are valid for the characters + // and attributes of our input. + let lastCharX = x; + if (joinedRanges.length > 0 && x === joinedRanges[0][0]) { + const range = joinedRanges.shift(); + + // We need to start the searching at the next character + lastCharX++; + index++; + + // Build up the string + for (; lastCharX < terminal.cols && index < range[1]; lastCharX++) { + charData = line[lastCharX]; + if (charData[CHAR_DATA_WIDTH_INDEX] !== 0) { + char += charData[CHAR_DATA_CHAR_INDEX]; + index++; + } + } + + // Update our data accordingly. We use the width of the last character + // for the rest of the checks and decrement our column/index so that + // they align with the last character matched rather than the next + // character in the sequence. + width = charData[CHAR_DATA_WIDTH_INDEX]; + lastCharX--; + index--; + } + // If the character is an overlapping char and the character to the right is a // space, take ownership of the cell to the right. if (this._isOverlapping(charData)) { @@ -89,7 +129,7 @@ export class TextRenderLayer extends BaseRenderLayer { // get removed, and `a` would not re-render because it thinks it's // already in the correct state. // this._state.cache[x][y] = OVERLAP_OWNED_CHAR_DATA; - if (x < line.length - 1 && line[x + 1][CHAR_DATA_CODE_INDEX] === 32 /*' '*/) { + if (lastCharX < line.length - 1 && line[lastCharX + 1][CHAR_DATA_CODE_INDEX] === 32 /*' '*/) { width = 2; // this._clearChar(x + 1, y); // The overlapping char's char data will force a clear and render when the @@ -116,7 +156,19 @@ export class TextRenderLayer extends BaseRenderLayer { } } - callback(code, char, width, x, y, fg, bg, flags); + callback( + char.length === 1 ? code : Infinity, + char, + char.length + width - 1, + x, + y, + fg, + bg, + flags + ); + + x = lastCharX; + index++; } } } @@ -134,7 +186,7 @@ export class TextRenderLayer extends BaseRenderLayer { ctx.save(); - this._forEachCell(terminal, firstRow, lastRow, (code, char, width, x, y, fg, bg, flags) => { + this._forEachCell(terminal, firstRow, lastRow, false, (code, char, width, x, y, fg, bg, flags) => { // libvte and xterm both draw the background (but not foreground) of invisible characters, // so we should too. let nextFillStyle = null; // null represents default background color @@ -176,7 +228,7 @@ export class TextRenderLayer extends BaseRenderLayer { } private _drawForeground(terminal: ITerminal, firstRow: number, lastRow: number): void { - this._forEachCell(terminal, firstRow, lastRow, (code, char, width, x, y, fg, bg, flags) => { + this._forEachCell(terminal, firstRow, lastRow, true, (code, char, width, x, y, fg, bg, flags) => { if (flags & FLAGS.INVISIBLE) { return; } @@ -190,7 +242,7 @@ export class TextRenderLayer extends BaseRenderLayer { } else { this._ctx.fillStyle = this._colors.foreground.css; } - this.fillBottomLineAtCells(x, y); + this.fillBottomLineAtCells(x, y, width); this._ctx.restore(); } this.drawChar( @@ -219,6 +271,19 @@ export class TextRenderLayer extends BaseRenderLayer { this.setTransparency(terminal, terminal.options.allowTransparency); } + public registerCharacterJoiner(joiner: ICharacterJoiner): void { + this._joiners.push(joiner); + } + + public deregisterCharacterJoiner(joinerId: number): void { + for (let i = 0; i < this._joiners.length; i++) { + if (this._joiners[i].id === joinerId) { + this._joiners.splice(i, 1); + return; + } + } + } + /** * Whether a character is overlapping to the next cell. */ @@ -258,6 +323,78 @@ export class TextRenderLayer extends BaseRenderLayer { return overlaps; } + private _getJoinedCharacters(terminal: ITerminal, row: number): [number, number][] { + if (this._joiners.length === 0) { + return []; + } + + const line = terminal.buffer.lines.get(row); + if (line.length === 0) { + return []; + } + + const ranges: [number, number][] = []; + const lineStr = terminal.buffer.translateBufferLineToString(row, true); + + let currentIndex = 0; + let rangeStartIndex = 0; + let rangeAttr = line[0][CHAR_DATA_ATTR_INDEX] >> 9; + for (let x = 0; x < terminal.cols; x++) { + const charData = line[x]; + const width = charData[CHAR_DATA_WIDTH_INDEX]; + const attr = charData[CHAR_DATA_ATTR_INDEX] >> 9; + + if (width === 0) { + // If this character is of width 0, skip it + continue; + } + + // End of range + if (attr !== rangeAttr) { + // If we ended up with a sequence of more than one character, look for + // ranges to join + if (currentIndex - rangeStartIndex > 1) { + const subRanges = this._getSubRanges(lineStr, rangeStartIndex, currentIndex); + for (let i = 0; i < subRanges.length; i++) { + ranges.push(subRanges[i]); + } + } + + // Reset our markers for a new range + rangeStartIndex = x; + rangeAttr = attr; + } + + currentIndex++; + } + + // Process any trailing ranges + if (currentIndex - rangeStartIndex > 1) { + const subRanges = this._getSubRanges(lineStr, rangeStartIndex, currentIndex); + for (let i = 0; i < subRanges.length; i++) { + ranges.push(subRanges[i]); + } + } + + return ranges; + } + + private _getSubRanges(line: string, startIndex: number, endIndex: number): [number, number][] { + const text = line.substring(startIndex, endIndex); + // At this point we already know that there is at least one joiner so + // we can just pull its value and assign it directly rather than + // merging it into an empty array, which incurs unnecessary writes. + const subRanges: [number, number][] = this._joiners[0].handler(text); + for (let i = 1; i < this._joiners.length; i++) { + // We merge any overlapping ranges across the different joiners + const joinerSubRanges = this._joiners[i].handler(text); + for (let j = 0; j < joinerSubRanges.length; j++) { + merge(subRanges, joinerSubRanges[j]); + } + } + return subRanges.map<[number, number]>(range => [range[0] + startIndex, range[1] + endIndex]); + } + /** * Clear the charcater at the cell specified. * @param x The column of the char. diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts index edecf8b0f6..c6110d4f35 100644 --- a/src/renderer/Types.ts +++ b/src/renderer/Types.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { ITerminal } from '../Types'; +import { ITerminal, CharacterJoinerHandler } from '../Types'; import { IEventEmitter, ITheme } from 'xterm'; import { IColorSet } from '../shared/Types'; @@ -35,6 +35,8 @@ export interface IRenderer extends IEventEmitter { onOptionsChanged(): void; clear(): void; refreshRows(start: number, end: number): void; + registerCharacterJoiner(handler: CharacterJoinerHandler): number; + deregisterCharacterJoiner(joinerId: number): boolean; } export interface IColorManager { @@ -96,6 +98,16 @@ export interface IRenderLayer { */ onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void; + /** + * Registers a handler to join characters to render as a group + */ + registerCharacterJoiner?(joiner: ICharacterJoiner): void; + + /** + * Deregisters the specified character joiner handler + */ + deregisterCharacterJoiner?(joinerId: number): void; + /** * Resize the render layer. */ @@ -106,3 +118,8 @@ export interface IRenderLayer { */ reset(terminal: ITerminal): void; } + +export interface ICharacterJoiner { + id: number; + handler: CharacterJoinerHandler; +} diff --git a/src/utils/MergeRanges.test.ts b/src/utils/MergeRanges.test.ts new file mode 100644 index 0000000000..d43cca74c0 --- /dev/null +++ b/src/utils/MergeRanges.test.ts @@ -0,0 +1,45 @@ +import { assert } from 'chai'; + +import { merge } from './MergeRanges'; + +describe('merge', () => { + it('inserts a new range before the existing ones', () => { + const result = merge([[1, 2], [2, 3]], [0, 1]); + assert.deepEqual(result, [[0, 1], [1, 2], [2, 3]]); + }); + + it('inserts in between two ranges', () => { + const result = merge([[0, 2], [4, 6]], [2, 4]); + assert.deepEqual(result, [[0, 2], [2, 4], [4, 6]]); + }); + + it('inserts after the last range', () => { + const result = merge([[0, 2], [4, 6]], [6, 8]); + assert.deepEqual(result, [[0, 2], [4, 6], [6, 8]]); + }); + + it('extends the beginning of a range', () => { + const result = merge([[0, 2], [4, 6]], [3, 5]); + assert.deepEqual(result, [[0, 2], [3, 6]]); + }); + + it('extends the end of a range', () => { + const result = merge([[0, 2], [4, 6]], [1, 4]); + assert.deepEqual(result, [[0, 4], [4, 6]]); + }); + + it('extends the last range', () => { + const result = merge([[0, 2], [4, 6]], [5, 7]); + assert.deepEqual(result, [[0, 2], [4, 7]]); + }); + + it('connects two ranges', () => { + const result = merge([[0, 2], [4, 6]], [1, 5]); + assert.deepEqual(result, [[0, 6]]); + }); + + it('connects more than two ranges', () => { + const result = merge([[0, 2], [4, 6], [8, 10], [12, 14]], [1, 10]); + assert.deepEqual(result, [[0, 10], [12, 14]]); + }); +}); diff --git a/src/utils/MergeRanges.ts b/src/utils/MergeRanges.ts new file mode 100644 index 0000000000..b9d91ef745 --- /dev/null +++ b/src/utils/MergeRanges.ts @@ -0,0 +1,70 @@ +/** + * Merges the range defined by the provided start and end into the list of + * existing ranges. The merge is done in place on the existing range for + * performance and is also returned. + * @param ranges Existing range list + * @param newRange Tuple of two numbers representing the new range to merge in. + * @returns The ranges input with the new range merged in place + */ +export function merge(ranges: [number, number][], newRange: [number, number]): [number, number][] { + let inRange = false; + for (let i = 0; i < ranges.length; i++) { + const range = ranges[i]; + if (!inRange) { + if (newRange[1] <= range[0]) { + // Case 1: New range is before the search range + ranges.splice(i, 0, newRange); + return ranges; + } + + if (newRange[1] <= range[1]) { + // Case 2: New range is either wholly contained within the + // search range or overlaps with the front of it + range[0] = Math.min(newRange[0], range[0]); + return ranges; + } + + if (newRange[0] < range[1]) { + // Case 3: New range either wholly contains the search range + // or overlaps with the end of it + range[0] = Math.min(newRange[0], range[0]); + inRange = true; + } + + // Case 4: New range starts after the search range + continue; + } else { + if (newRange[1] <= range[0]) { + // Case 5: New range extends from previous range but doesn't + // reach the current one + ranges[i - 1][1] = newRange[1]; + return ranges; + } + + if (newRange[1] <= range[1]) { + // Case 6: New range extends from prvious range into the + // current range + ranges[i - 1][1] = Math.max(newRange[1], range[1]); + ranges.splice(i, 1); + inRange = false; + return ranges; + } + + // Case 7: New range extends from previous range past the + // end of the current range + ranges.splice(i, 1); + i--; + } + } + + if (inRange) { + // Case 8: New range extends past the last existing range + ranges[ranges.length - 1][1] = newRange[1]; + } else { + // Case 9: New range starts after the last existing range + ranges.push(newRange); + } + + return ranges; +} + diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index e3ae89d303..6e95e2a750 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -4,7 +4,7 @@ */ import { IColorSet, IRenderer, IRenderDimensions, IColorManager } from '../renderer/Types'; -import { LineData, IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, ICircularList, ILinkifier, IMouseHelper, ILinkMatcherOptions, XtermListener } from '../Types'; +import { LineData, IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, ICircularList, ILinkifier, IMouseHelper, ILinkMatcherOptions, XtermListener, CharacterJoinerHandler } from '../Types'; import { Buffer } from '../Buffer'; import * as Browser from '../shared/utils/Browser'; import { ITheme, IDisposable, IMarker } from 'xterm'; @@ -157,6 +157,8 @@ export class MockTerminal implements ITerminal { } return line; } + registerCharacterJoiner(handler: CharacterJoinerHandler): number { return 0; } + deregisterCharacterJoiner(joinerId: number): void { } } export class MockCharMeasure implements ICharMeasure { @@ -339,6 +341,8 @@ export class MockRenderer implements IRenderer { onWindowResize(devicePixelRatio: number): void {} clear(): void {} refreshRows(start: number, end: number): void {} + registerCharacterJoiner(handler: CharacterJoinerHandler): number { return 0; } + deregisterCharacterJoiner(): boolean { return true; } } export class MockViewport implements IViewport { diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index a8724922e4..dff5cf08d2 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -439,6 +439,26 @@ declare module 'xterm' { */ deregisterLinkMatcher(matcherId: number): void; + /** + * (EXPERIMENTAL) Registers a character joiner, allowing custom sequences of + * characters to be rendered as a single unit. This is useful in particular + * for rendering ligatures. NOTE: character joiners are only used by the + * canvas renderer. + * @param handler The function that determines character joins. It is called + * with a string of text that is eligible for joining and returns an array + * where each entry is an array containing the start (inclusive) and end + * (exclusive) indexes of ranges that should be rendered as a single unit. + * @return The ID of the new joiner, this can be used to deregister + */ + registerCharacterJoiner(handler: (text: string) => [number, number][]): number; + + /** + * (EXPERIMENTAL) Deregisters the character joiner if one was registered. + * NOTE: character joiners are only used by the canvas renderer. + * @param joinerId The character joiner's ID (returned after register) + */ + deregisterCharacterJoiner(joinerId: number): void; + /** * (EXPERIMENTAL) Adds a marker to the normal buffer and returns it. If the * alt buffer is active, undefined is returned.