diff --git a/src/Terminal.ts b/src/Terminal.ts index d13e93c33e..e836629041 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -21,7 +21,7 @@ * http://linux.die.net/man/7/urxvt */ -import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharData, LineData } from './Types'; +import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminalOptions, ITerminal, IBrowser, ILinkifier, ILinkMatcherOptions, CustomKeyEventHandler, LinkMatcherHandler, CharData, LineData, CharacterJoinerHandler } from './Types'; import { IMouseZoneManager } from './ui/Types'; import { IRenderer } from './renderer/Types'; import { BufferSet } from './BufferSet'; @@ -1374,6 +1374,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 f3a8455079..26a8a8618b 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -18,6 +18,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/public/Terminal.ts b/src/public/Terminal.ts index a56a270ad0..8ff7cf2b3b 100644 --- a/src/public/Terminal.ts +++ b/src/public/Terminal.ts @@ -65,6 +65,12 @@ export class Terminal implements ITerminalApi { public deregisterLinkMatcher(matcherId: number): void { this._core.deregisterLinkMatcher(matcherId); } + public registerCharacterJoiner(handler: (text: string) => [number, number][]): number { + return this._core.registerCharacterJoiner(handler); + } + public deregisterCharacterJoiner(joinerId: number): void { + this._core.deregisterCharacterJoiner(joinerId); + } public addMarker(cursorYOffset: number): IMarker { return this._core.addMarker(cursorYOffset); } diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts index 2e9de38940..111e63ff09 100644 --- a/src/renderer/BaseRenderLayer.ts +++ b/src/renderer/BaseRenderLayer.ts @@ -224,12 +224,12 @@ export abstract class BaseRenderLayer implements IRenderLayer { } /** - * Draws a character at a cell. If possible this will draw using the character - * atlas to reduce draw time. + * Draws one or more characters at a cell. If possible this will draw using + * the character atlas to reduce draw time. * @param terminal The terminal. - * @param char The character. + * @param chars The character or characters. * @param code The character code. - * @param width The width of the character. + * @param width The width of the characters. * @param x The column to draw at. * @param y The row to draw at. * @param fg The foreground color, in the format stored within the attributes. @@ -237,33 +237,33 @@ export abstract class BaseRenderLayer implements IRenderLayer { * This is used to validate whether a cached image can be used. * @param bold Whether the text is bold. */ - protected drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bg: number, bold: boolean, dim: boolean, italic: boolean): void { + protected drawChars(terminal: ITerminal, chars: string, code: number, width: number, x: number, y: number, fg: number, bg: number, bold: boolean, dim: boolean, italic: boolean): void { const drawInBrightColor = terminal.options.drawBoldTextInBrightColors && bold && fg < 8; fg += drawInBrightColor ? 8 : 0; const atlasDidDraw = this._charAtlas && this._charAtlas.draw( this._ctx, - {char, code, bg, fg, bold: bold && terminal.options.enableBold, dim, italic}, + {chars, code, bg, fg, bold: bold && terminal.options.enableBold, dim, italic}, x * this._scaledCellWidth + this._scaledCharLeft, y * this._scaledCellHeight + this._scaledCharTop ); if (!atlasDidDraw) { - this._drawUncachedChar(terminal, char, width, fg, x, y, bold && terminal.options.enableBold, dim, italic); + this._drawUncachedChars(terminal, chars, width, fg, x, y, bold && terminal.options.enableBold, dim, italic); } } /** - * Draws a character at a cell. The character will be clipped to - * ensure that it fits with the cell, including the cell to the right if it's - * a wide character. + * Draws one or more characters at one or more cells. The character(s) will be + * clipped to ensure that they fit with the cell(s), including the cell to the + * right if the last character is a wide character. * @param terminal The terminal. - * @param char The character. + * @param chars The character. * @param width The width of the character. * @param fg The foreground color, in the format stored within the attributes. * @param x The column to draw at. * @param y The row to draw at. */ - private _drawUncachedChar(terminal: ITerminal, char: string, width: number, fg: number, x: number, y: number, bold: boolean, dim: boolean, italic: boolean): void { + private _drawUncachedChars(terminal: ITerminal, chars: string, width: number, fg: number, x: number, y: number, bold: boolean, dim: boolean, italic: boolean): void { this._ctx.save(); this._ctx.font = this._getFont(terminal, bold, italic); this._ctx.textBaseline = 'top'; @@ -285,7 +285,7 @@ export abstract class BaseRenderLayer implements IRenderLayer { } // Draw the character this._ctx.fillText( - char, + chars, x * this._scaledCellWidth + this._scaledCharLeft, y * this._scaledCellHeight + this._scaledCharTop); this._ctx.restore(); diff --git a/src/renderer/CharacterJoinerRegistry.test.ts b/src/renderer/CharacterJoinerRegistry.test.ts new file mode 100644 index 0000000000..85d946018f --- /dev/null +++ b/src/renderer/CharacterJoinerRegistry.test.ts @@ -0,0 +1,278 @@ +import { assert } from 'chai'; + +import { LineData, CharData } from '../Types'; +import { MockTerminal, MockBuffer } from '../utils/TestUtils.test'; +import { CircularList } from '../common/CircularList'; + +import { ICharacterJoinerRegistry } from './Types'; +import { CharacterJoinerRegistry } from './CharacterJoinerRegistry'; + +describe('CharacterJoinerRegistry', () => { + let registry: ICharacterJoinerRegistry; + + beforeEach(() => { + const terminal = new MockTerminal(); + terminal.cols = 16; + terminal.buffer = new MockBuffer(); + const lines = new CircularList(7); + lines.set(0, lineData('a -> b -> c -> d')); + lines.set(1, lineData('a -> b => c -> d')); + lines.set(2, [...lineData('a -> b -', 0xFFFFFFFF), ...lineData('> c -> d', 0)]); + lines.set(3, lineData('no joined ranges')); + lines.set(4, []); + lines.set(5, [...lineData('a', 0x11111111), ...lineData(' -> b -> c -> '), ...lineData('d', 0x22222222)]); + lines.set(6, [ + ...lineData('wi'), + [0, '¥', 2, '¥'.charCodeAt(0)], + [0, '', 0, null], + ...lineData('deemo'), + [0, '\xf0\x9f\x98\x81', 1, 128513], + [0, ' ', 1, ' '.charCodeAt(0)], + ...lineData('jiabc') + ]); + (terminal.buffer).setLines(lines); + terminal.buffer.ydisp = 0; + registry = new CharacterJoinerRegistry(terminal); + }); + + it('has no joiners upon creation', () => { + assert.deepEqual(registry.getJoinedCharacters(0), []); + }); + + it('returns ranges matched by the registered joiners', () => { + registry.registerCharacterJoiner(substringJoiner('->')); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[2, 4], [7, 9], [12, 14]] + ); + }); + + it('processes the input using all provided joiners', () => { + registry.registerCharacterJoiner(substringJoiner('->')); + assert.deepEqual( + registry.getJoinedCharacters(1), + [[2, 4], [12, 14]] + ); + + registry.registerCharacterJoiner(substringJoiner('=>')); + assert.deepEqual( + registry.getJoinedCharacters(1), + [[2, 4], [7, 9], [12, 14]] + ); + }); + + it('removes deregistered joiners from future calls', () => { + const joiner1 = registry.registerCharacterJoiner(substringJoiner('->')); + const joiner2 = registry.registerCharacterJoiner(substringJoiner('=>')); + assert.deepEqual( + registry.getJoinedCharacters(1), + [[2, 4], [7, 9], [12, 14]] + ); + + registry.deregisterCharacterJoiner(joiner1); + assert.deepEqual( + registry.getJoinedCharacters(1), + [[7, 9]] + ); + + registry.deregisterCharacterJoiner(joiner2); + assert.deepEqual( + registry.getJoinedCharacters(1), + [] + ); + }); + + it('doesn\'t process joins on differently-styled characters', () => { + registry.registerCharacterJoiner(substringJoiner('->')); + assert.deepEqual( + registry.getJoinedCharacters(2), + [[2, 4], [12, 14]] + ); + }); + + it('returns an empty list of ranges if there is nothing to be joined', () => { + registry.registerCharacterJoiner(substringJoiner('->')); + assert.deepEqual( + registry.getJoinedCharacters(3), + [] + ); + }); + + it('returns an empty list of ranges if the line is empty', () => { + registry.registerCharacterJoiner(substringJoiner('->')); + assert.deepEqual( + registry.getJoinedCharacters(4), + [] + ); + }); + + it('returns false when trying to deregister a joiner that does not exist', () => { + registry.registerCharacterJoiner(substringJoiner('->')); + assert.deepEqual(registry.deregisterCharacterJoiner(123), false); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[2, 4], [7, 9], [12, 14]] + ); + }); + + it('doesn\'t process same-styled ranges that only have one character', () => { + registry.registerCharacterJoiner(substringJoiner('a')); + registry.registerCharacterJoiner(substringJoiner('b')); + registry.registerCharacterJoiner(substringJoiner('d')); + assert.deepEqual( + registry.getJoinedCharacters(5), + [[5, 6]] + ); + }); + + it('handles ranges that extend all the way to the end of the line', () => { + registry.registerCharacterJoiner(substringJoiner('-> d')); + assert.deepEqual( + registry.getJoinedCharacters(2), + [[12, 16]] + ); + }); + + it('handles adjacent ranges', () => { + registry.registerCharacterJoiner(substringJoiner('->')); + registry.registerCharacterJoiner(substringJoiner('> c ')); + assert.deepEqual( + registry.getJoinedCharacters(2), + [[2, 4], [8, 12], [12, 14]] + ); + }); + + it('handles fullwidth characters in the middle of ranges', () => { + registry.registerCharacterJoiner(substringJoiner('wi¥de')); + assert.deepEqual( + registry.getJoinedCharacters(6), + [[0, 6]] + ); + }); + + it('handles fullwidth characters at the end of ranges', () => { + registry.registerCharacterJoiner(substringJoiner('wi¥')); + assert.deepEqual( + registry.getJoinedCharacters(6), + [[0, 4]] + ); + }); + + it('handles emojis in the middle of ranges', () => { + registry.registerCharacterJoiner(substringJoiner('emo\xf0\x9f\x98\x81 ji')); + assert.deepEqual( + registry.getJoinedCharacters(6), + [[6, 13]] + ); + }); + + it('handles emojis at the end of ranges', () => { + registry.registerCharacterJoiner(substringJoiner('emo\xf0\x9f\x98\x81 ')); + assert.deepEqual( + registry.getJoinedCharacters(6), + [[6, 11]] + ); + }); + + it('handles ranges after wide and emoji characters', () => { + registry.registerCharacterJoiner(substringJoiner('abc')); + assert.deepEqual( + registry.getJoinedCharacters(6), + [[13, 16]] + ); + }); + + describe('range merging', () => { + it('inserts a new range before the existing ones', () => { + registry.registerCharacterJoiner(() => [[1, 2], [2, 3]]); + registry.registerCharacterJoiner(() => [[0, 1]]); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[0, 1], [1, 2], [2, 3]] + ); + }); + + it('inserts in between two ranges', () => { + registry.registerCharacterJoiner(() => [[0, 2], [4, 6]]); + registry.registerCharacterJoiner(() => [[2, 4]]); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[0, 2], [2, 4], [4, 6]] + ); + }); + + it('inserts after the last range', () => { + registry.registerCharacterJoiner(() => [[0, 2], [4, 6]]); + registry.registerCharacterJoiner(() => [[6, 8]]); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[0, 2], [4, 6], [6, 8]] + ); + }); + + it('extends the beginning of a range', () => { + registry.registerCharacterJoiner(() => [[0, 2], [4, 6]]); + registry.registerCharacterJoiner(() => [[3, 5]]); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[0, 2], [3, 6]] + ); + }); + + it('extends the end of a range', () => { + registry.registerCharacterJoiner(() => [[0, 2], [4, 6]]); + registry.registerCharacterJoiner(() => [[1, 4]]); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[0, 4], [4, 6]] + ); + }); + + it('extends the last range', () => { + registry.registerCharacterJoiner(() => [[0, 2], [4, 6]]); + registry.registerCharacterJoiner(() => [[5, 7]]); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[0, 2], [4, 7]] + ); + }); + + it('connects two ranges', () => { + registry.registerCharacterJoiner(() => [[0, 2], [4, 6]]); + registry.registerCharacterJoiner(() => [[1, 5]]); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[0, 6]] + ); + }); + + it('connects more than two ranges', () => { + registry.registerCharacterJoiner(() => [[0, 2], [4, 6], [8, 10], [12, 14]]); + registry.registerCharacterJoiner(() => [[1, 10]]); + assert.deepEqual( + registry.getJoinedCharacters(0), + [[0, 10], [12, 14]] + ); + }); + }); +}); + +function lineData(line: string, attr: number = 0): LineData { + return line.split('').map(char => [attr, char, 1, char.charCodeAt(0)]); +} + +function substringJoiner(substring: string): (sequence: string) => [number, number][] { + return (sequence: string): [number, number][] => { + const ranges: [number, number][] = []; + let searchIndex = 0; + let matchIndex = -1; + + while ((matchIndex = sequence.indexOf(substring, searchIndex)) !== -1) { + const matchEndIndex = matchIndex + substring.length; + searchIndex = matchEndIndex; + ranges.push([matchIndex, matchEndIndex]); + } + + return ranges; + }; +} diff --git a/src/renderer/CharacterJoinerRegistry.ts b/src/renderer/CharacterJoinerRegistry.ts new file mode 100644 index 0000000000..723b6f388c --- /dev/null +++ b/src/renderer/CharacterJoinerRegistry.ts @@ -0,0 +1,276 @@ +import { CHAR_DATA_ATTR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from '../Buffer'; +import { ITerminal, LineData } from '../Types'; +import { ICharacterJoinerRegistry, ICharacterJoiner } from './Types'; + +export class CharacterJoinerRegistry implements ICharacterJoinerRegistry { + + private _characterJoiners: ICharacterJoiner[] = []; + private _nextCharacterJoinerId: number = 0; + + constructor(private _terminal: ITerminal) { + } + + public registerCharacterJoiner(handler: (text: string) => [number, number][]): number { + const joiner: ICharacterJoiner = { + id: this._nextCharacterJoinerId++, + handler + }; + + this._characterJoiners.push(joiner); + return joiner.id; + } + + public deregisterCharacterJoiner(joinerId: number): boolean { + for (let i = 0; i < this._characterJoiners.length; i++) { + if (this._characterJoiners[i].id === joinerId) { + this._characterJoiners.splice(i, 1); + return true; + } + } + + return false; + } + + public getJoinedCharacters(row: number): [number, number][] { + if (this._characterJoiners.length === 0) { + return []; + } + + const line = this._terminal.buffer.lines.get(row); + if (line.length === 0) { + return []; + } + + const ranges: [number, number][] = []; + const lineStr = this._terminal.buffer.translateBufferLineToString(row, true); + + // Because some cells can be represented by multiple javascript characters, + // we track the cell and the string indexes separately. This allows us to + // translate the string ranges we get from the joiners back into cell ranges + // for use when rendering + let rangeStartColumn = 0; + let currentStringIndex = 0; + let rangeStartStringIndex = 0; + let rangeAttr = line[0][CHAR_DATA_ATTR_INDEX] >> 9; + + for (let x = 0; x < this._terminal.cols; x++) { + const charData = line[x]; + const chars = charData[CHAR_DATA_CHAR_INDEX]; + 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 (x - rangeStartColumn > 1) { + const joinedRanges = this._getJoinedRanges( + lineStr, + rangeStartStringIndex, + currentStringIndex, + line, + rangeStartColumn + ); + for (let i = 0; i < joinedRanges.length; i++) { + ranges.push(joinedRanges[i]); + } + } + + // Reset our markers for a new range. + rangeStartColumn = x; + rangeStartStringIndex = currentStringIndex; + rangeAttr = attr; + } + + currentStringIndex += chars.length; + } + + // Process any trailing ranges. + if (this._terminal.cols - rangeStartColumn > 1) { + const joinedRanges = this._getJoinedRanges( + lineStr, + rangeStartStringIndex, + currentStringIndex, + line, + rangeStartColumn + ); + for (let i = 0; i < joinedRanges.length; i++) { + ranges.push(joinedRanges[i]); + } + } + + return ranges; + } + + /** + * Given a segment of a line of text, find all ranges of text that should be + * joined in a single rendering unit. Ranges are internally converted to + * column ranges, rather than string ranges. + * @param line String representation of the full line of text + * @param startIndex Start position of the range to search in the string (inclusive) + * @param endIndex End position of the range to search in the string (exclusive) + */ + private _getJoinedRanges(line: string, startIndex: number, endIndex: number, lineData: LineData, startCol: 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 joinedRanges: [number, number][] = this._characterJoiners[0].handler(text); + for (let i = 1; i < this._characterJoiners.length; i++) { + // We merge any overlapping ranges across the different joiners + const joinerRanges = this._characterJoiners[i].handler(text); + for (let j = 0; j < joinerRanges.length; j++) { + CharacterJoinerRegistry._mergeRanges(joinedRanges, joinerRanges[j]); + } + } + this._stringRangesToCellRanges(joinedRanges, lineData, startCol); + return joinedRanges; + } + + /** + * Modifies the provided ranges in-place to adjust for variations between + * string length and cell width so that the range represents a cell range, + * rather than the string range the joiner provides. + * @param ranges String ranges containing start (inclusive) and end (exclusive) index + * @param line Cell data for the relevant line in the terminal + * @param startCol Offset within the line to start from + */ + private _stringRangesToCellRanges(ranges: [number, number][], line: LineData, startCol: number): void { + let currentRangeIndex = 0; + let currentRangeStarted = false; + let currentStringIndex = 0; + let currentRange = ranges[currentRangeIndex]; + + // If we got through all of the ranges, stop searching + if (!currentRange) { + return; + } + + for (let x = startCol; x < this._terminal.cols; x++) { + const charData = line[x]; + const width = charData[CHAR_DATA_WIDTH_INDEX]; + const length = charData[CHAR_DATA_CHAR_INDEX].length; + + // We skip zero-width characters when creating the string to join the text + // so we do the same here + if (width === 0) { + continue; + } + + // Adjust the start of the range + if (!currentRangeStarted && currentRange[0] <= currentStringIndex) { + currentRange[0] = x; + currentRangeStarted = true; + } + + // Adjust the end of the range + if (currentRange[1] <= currentStringIndex) { + currentRange[1] = x; + + // We're finished with this range, so we move to the next one + currentRange = ranges[++currentRangeIndex]; + + // If there are no more ranges left, stop searching + if (!currentRange) { + break; + } + + // Ranges can be on adjacent characters. Because the end index of the + // ranges are exclusive, this means that the index for the start of a + // range can be the same as the end index of the previous range. To + // account for the start of the next range, we check here just in case. + if (currentRange[0] <= currentStringIndex) { + currentRange[0] = x; + currentRangeStarted = true; + } else { + currentRangeStarted = false; + } + } + + // Adjust the string index based on the character length to line up with + // the column adjustment + currentStringIndex += length; + } + + // If there is still a range left at the end, it must extend all the way to + // the end of the line. + if (currentRange) { + currentRange[1] = this._terminal.cols; + } + } + + /** + * 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 + */ + private static _mergeRanges(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/renderer/Renderer.ts b/src/renderer/Renderer.ts index 6a3821042b..406717b557 100644 --- a/src/renderer/Renderer.ts +++ b/src/renderer/Renderer.ts @@ -7,13 +7,14 @@ 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, ICharacterJoinerRegistry } from './Types'; +import { ITerminal, CharacterJoinerHandler } from '../Types'; import { LinkRenderLayer } from './LinkRenderLayer'; import { EventEmitter } from '../EventEmitter'; import { RenderDebouncer } from '../ui/RenderDebouncer'; import { ScreenDprMonitor } from '../ui/ScreenDprMonitor'; import { ITheme } from 'xterm'; +import { CharacterJoinerRegistry } from '../renderer/CharacterJoinerRegistry'; export class Renderer extends EventEmitter implements IRenderer { private _renderDebouncer: RenderDebouncer; @@ -23,6 +24,7 @@ export class Renderer extends EventEmitter implements IRenderer { private _screenDprMonitor: ScreenDprMonitor; private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; + private _characterJoinerRegistry: ICharacterJoinerRegistry; public colorManager: ColorManager; public dimensions: IRenderDimensions; @@ -31,12 +33,13 @@ export class Renderer extends EventEmitter implements IRenderer { super(); const allowTransparency = this._terminal.options.allowTransparency; this.colorManager = new ColorManager(document, allowTransparency); + this._characterJoinerRegistry = new CharacterJoinerRegistry(_terminal); if (theme) { this.colorManager.setTheme(theme); } this._renderLayers = [ - new TextRenderLayer(this._terminal.screenElement, 0, this.colorManager.colors, allowTransparency), + new TextRenderLayer(this._terminal.screenElement, 0, this.colorManager.colors, this._characterJoinerRegistry, allowTransparency), new SelectionRenderLayer(this._terminal.screenElement, 1, this.colorManager.colors), new LinkRenderLayer(this._terminal.screenElement, 2, this.colorManager.colors, this._terminal), new CursorRenderLayer(this._terminal.screenElement, 3, this.colorManager.colors) @@ -67,7 +70,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); this.register({ dispose: () => observer.disconnect() }); } @@ -188,7 +191,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 }); } /** @@ -250,4 +253,12 @@ 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 { + return this._characterJoinerRegistry.registerCharacterJoiner(handler); + } + + public deregisterCharacterJoiner(joinerId: number): boolean { + return this._characterJoinerRegistry.deregisterCharacterJoiner(joinerId); + } } diff --git a/src/renderer/TextRenderLayer.ts b/src/renderer/TextRenderLayer.ts index f48c256140..e57276d7e0 100644 --- a/src/renderer/TextRenderLayer.ts +++ b/src/renderer/TextRenderLayer.ts @@ -4,7 +4,7 @@ */ 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, ICharacterJoinerRegistry } from './Types'; import { CharData, ITerminal } from '../Types'; import { INVERTED_DEFAULT_COLOR } from './atlas/Types'; import { GridCache } from './GridCache'; @@ -22,10 +22,12 @@ export class TextRenderLayer extends BaseRenderLayer { private _characterWidth: number; private _characterFont: string; private _characterOverlapCache: { [key: string]: boolean } = {}; + private _characterJoinerRegistry: ICharacterJoinerRegistry; - constructor(container: HTMLElement, zIndex: number, colors: IColorSet, alpha: boolean) { + constructor(container: HTMLElement, zIndex: number, colors: IColorSet, characterJoinerRegistry: ICharacterJoinerRegistry, alpha: boolean) { super(container, 'text', zIndex, alpha, colors); this._state = new GridCache(); + this._characterJoinerRegistry = characterJoinerRegistry; } public resize(terminal: ITerminal, dim: IRenderDimensions): void { @@ -52,9 +54,10 @@ export class TextRenderLayer extends BaseRenderLayer { terminal: ITerminal, firstRow: number, lastRow: number, + joinerRegistry: ICharacterJoinerRegistry | null, callback: ( code: number, - char: string, + chars: string, width: number, x: number, y: number, @@ -66,22 +69,54 @@ 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); + const joinedRanges = joinerRegistry ? joinerRegistry.getJoinedCharacters(row) : []; for (let x = 0; x < terminal.cols; x++) { const charData = line[x]; - const code: number = charData[CHAR_DATA_CODE_INDEX]; - const char: string = charData[CHAR_DATA_CHAR_INDEX]; + let code: number = charData[CHAR_DATA_CODE_INDEX]; + + // Can either represent character(s) for a single cell or multiple cells + // if indicated by a character joiner. + let chars: string = charData[CHAR_DATA_CHAR_INDEX]; const attr: number = charData[CHAR_DATA_ATTR_INDEX]; let width: number = charData[CHAR_DATA_WIDTH_INDEX]; + // If true, indicates that the current character(s) to draw were joined. + let isJoined = false; + let lastCharX = x; + // The character to the left is a wide character, drawing is owned by // the char at x-1 if (width === 0) { continue; } - // 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)) { + // 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. + if (joinedRanges.length > 0 && x === joinedRanges[0][0]) { + isJoined = true; + const range = joinedRanges.shift(); + + // We already know the exact start and end column of the joined range, + // so we get the string and width representing it directly + chars = terminal.buffer.translateBufferLineToString( + row, + true, + range[0], + range[1] + ); + width = range[1] - range[0]; + code = Infinity; + + // Skip over the cells occupied by this range in the loop + lastCharX = range[1] - 1; + } + + // If the character is an overlapping char and the character to the + // right is a space, take ownership of the cell to the right. We skip + // this check for joined characters because their rendering likely won't + // yield the same result as rendering the last character individually. + if (!isJoined && this._isOverlapping(charData)) { // If the character is overlapping, we want to force a re-render on every // frame. This is specifically to work around the case where two // overlaping chars `a` and `b` are adjacent, the cursor is moved to b and a @@ -89,7 +124,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 +151,18 @@ export class TextRenderLayer extends BaseRenderLayer { } } - callback(code, char, width, x, y, fg, bg, flags); + callback( + code, + chars, + width, + x, + y, + fg, + bg, + flags + ); + + x = lastCharX; } } } @@ -134,7 +180,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, null, (code, chars, 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 +222,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, this._characterJoinerRegistry, (code, chars, width, x, y, fg, bg, flags) => { if (flags & FLAGS.INVISIBLE) { return; } @@ -190,11 +236,11 @@ 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( - terminal, char, code, + this.drawChars( + terminal, chars, code, width, x, y, fg, bg, !!(flags & FLAGS.BOLD), !!(flags & FLAGS.DIM), !!(flags & FLAGS.ITALIC) diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts index 56cfa2fc45..4006f28c36 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, IDisposable } from 'xterm'; import { IColorSet } from '../shared/Types'; @@ -40,6 +40,8 @@ export interface IRenderer extends IEventEmitter, IDisposable { onOptionsChanged(): void; clear(): void; refreshRows(start: number, end: number): void; + registerCharacterJoiner(handler: CharacterJoinerHandler): number; + deregisterCharacterJoiner(joinerId: number): boolean; } export interface IColorManager { @@ -101,6 +103,16 @@ export interface IRenderLayer { */ onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number], columnSelectMode: boolean): 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. */ @@ -111,3 +123,14 @@ export interface IRenderLayer { */ reset(terminal: ITerminal): void; } + +export interface ICharacterJoiner { + id: number; + handler: CharacterJoinerHandler; +} + +export interface ICharacterJoinerRegistry { + registerCharacterJoiner(handler: (text: string) => [number, number][]): number; + deregisterCharacterJoiner(joinerId: number): boolean; + getJoinedCharacters(row: number): [number, number][]; +} diff --git a/src/renderer/atlas/DynamicCharAtlas.ts b/src/renderer/atlas/DynamicCharAtlas.ts index bf2d6f90f4..e919e362b7 100644 --- a/src/renderer/atlas/DynamicCharAtlas.ts +++ b/src/renderer/atlas/DynamicCharAtlas.ts @@ -36,7 +36,7 @@ interface IGlyphCacheValue { function getGlyphCacheKey(glyph: IGlyphIdentifier): string { const styleFlags = (glyph.bold ? 0 : 4) + (glyph.dim ? 0 : 2) + (glyph.italic ? 0 : 1); - return `${glyph.bg}_${glyph.fg}_${styleFlags}${glyph.char}`; + return `${glyph.bg}_${glyph.fg}_${styleFlags}${glyph.chars}`; } export default class DynamicCharAtlas extends BaseCharAtlas { @@ -216,7 +216,7 @@ export default class DynamicCharAtlas extends BaseCharAtlas { this._tmpCtx.globalAlpha = DIM_OPACITY; } // Draw the character - this._tmpCtx.fillText(glyph.char, 0, 0); + this._tmpCtx.fillText(glyph.chars, 0, 0); this._tmpCtx.restore(); // clear the background from the character to avoid issues with drawing over the previous diff --git a/src/renderer/atlas/Types.ts b/src/renderer/atlas/Types.ts index 46e4c9aa80..6fb3c5d16c 100644 --- a/src/renderer/atlas/Types.ts +++ b/src/renderer/atlas/Types.ts @@ -7,7 +7,7 @@ export const INVERTED_DEFAULT_COLOR = -1; export const DIM_OPACITY = 0.5; export interface IGlyphIdentifier { - char: string; + chars: string; code: number; bg: number; fg: number; diff --git a/src/renderer/dom/DomRenderer.ts b/src/renderer/dom/DomRenderer.ts index 026381ff39..c32d663444 100644 --- a/src/renderer/dom/DomRenderer.ts +++ b/src/renderer/dom/DomRenderer.ts @@ -4,7 +4,7 @@ */ import { IRenderer, IRenderDimensions, IColorSet } from '../Types'; -import { ITerminal } from '../../Types'; +import { ITerminal, CharacterJoinerHandler } from '../../Types'; import { ITheme } from 'xterm'; import { EventEmitter } from '../../EventEmitter'; import { ColorManager } from '../ColorManager'; @@ -319,4 +319,7 @@ export class DomRenderer extends EventEmitter implements IRenderer { private get _terminalSelector(): string { return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`; } + + public registerCharacterJoiner(handler: CharacterJoinerHandler): number { return -1; } + public deregisterCharacterJoiner(joinerId: number): boolean { return false; } } diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index 59b55cf32d..78a1942b07 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'; @@ -156,6 +156,8 @@ export class MockTerminal implements ITerminal { } return line; } + registerCharacterJoiner(handler: CharacterJoinerHandler): number { return 0; } + deregisterCharacterJoiner(joinerId: number): void { } } export class MockCharMeasure implements ICharMeasure { @@ -344,6 +346,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 2bb70815c4..b4df921032 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -470,6 +470,44 @@ 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 and graphemes, among other things. + * + * Each registered character joiner is called with a string of text + * representing a portion of a line in the terminal that can be rendered as + * a single unit. The joiner must return a sorted array, where each entry is + * itself an array of length two, containing the start (inclusive) and end + * (exclusive) index of a substring of the input that should be rendered as + * a single unit. When multiple joiners are provided, the results of each + * are collected. If there are any overlapping substrings between them, they + * are combined into one larger unit that is drawn together. + * + * All character joiners that are registered get called every time a line is + * rendered in the terminal, so it is essential for the handler function to + * run as quickly as possible to avoid slowdowns when rendering. Similarly, + * joiners should strive to return the smallest possible substrings to + * render together, since they aren't drawn as optimally as individual + * characters. + * + * 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.