From b03a25f4c7df82f6a64408717aa35a2b69333c74 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 2 Apr 2021 11:15:57 -0700 Subject: [PATCH 1/2] Webgl ligature support Fixes #3094 --- addons/xterm-addon-webgl/src/WebglAddon.ts | 5 +- addons/xterm-addon-webgl/src/WebglRenderer.ts | 94 ++++++++++++++++++- .../src/atlas/WebglCharAtlas.ts | 24 +++-- src/browser/renderer/TextRenderLayer.ts | 1 - .../renderer/dom/DomRendererRowFactory.ts | 2 - 5 files changed, 109 insertions(+), 17 deletions(-) diff --git a/addons/xterm-addon-webgl/src/WebglAddon.ts b/addons/xterm-addon-webgl/src/WebglAddon.ts index c07bc0d7..91fa7968 100644 --- a/addons/xterm-addon-webgl/src/WebglAddon.ts +++ b/addons/xterm-addon-webgl/src/WebglAddon.ts @@ -5,7 +5,7 @@ import { Terminal, ITerminalAddon, IEvent } from 'xterm'; import { WebglRenderer } from './WebglRenderer'; -import { IRenderService } from 'browser/services/Services'; +import { ICharacterJoinerService, IRenderService } from 'browser/services/Services'; import { IColorSet } from 'browser/Types'; import { EventEmitter } from 'common/EventEmitter'; @@ -25,8 +25,9 @@ export class WebglAddon implements ITerminalAddon { } this._terminal = terminal; const renderService: IRenderService = (terminal)._core._renderService; + const characterJoinerService: ICharacterJoinerService = (terminal)._core._characterJoinerService; const colors: IColorSet = (terminal)._core._colorManager.colors; - this._renderer = new WebglRenderer(terminal, colors, this._preserveDrawingBuffer); + this._renderer = new WebglRenderer(terminal, colors, characterJoinerService, this._preserveDrawingBuffer); this._renderer.onContextLoss(() => this._onContextLoss.fire()); renderService.setRenderer(this._renderer); } diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index 08f83b52..ff159f70 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -12,7 +12,7 @@ import { RectangleRenderer } from './RectangleRenderer'; import { IWebGL2RenderingContext } from './Types'; import { RenderModel, COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel'; import { Disposable } from 'common/Lifecycle'; -import { NULL_CELL_CODE } from 'common/buffer/Constants'; +import { Content, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; import { Terminal, IEvent } from 'xterm'; import { IRenderLayer } from './renderLayer/Types'; import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/Types'; @@ -20,6 +20,9 @@ import { ITerminal, IColorSet } from 'browser/Types'; import { EventEmitter } from 'common/EventEmitter'; import { CellData } from 'common/buffer/CellData'; import { addDisposableDomListener } from 'browser/Lifecycle'; +import { ICharacterJoinerService } from 'browser/services/Services'; +import { CharData, ICellData } from 'common/Types'; +import { AttributeData } from 'common/buffer/AttributeData'; export class WebglRenderer extends Disposable implements IRenderer { private _renderLayers: IRenderLayer[]; @@ -48,6 +51,7 @@ export class WebglRenderer extends Disposable implements IRenderer { constructor( private _terminal: Terminal, private _colors: IColorSet, + private readonly _characterJoinerService: ICharacterJoinerService, preserveDrawingBuffer?: boolean ) { super(); @@ -288,16 +292,41 @@ export class WebglRenderer extends Disposable implements IRenderer { private _updateModel(start: number, end: number): void { const terminal = this._core; + let cell: ICellData = this._workCell; for (let y = start; y <= end; y++) { const row = y + terminal.buffer.ydisp; const line = terminal.buffer.lines.get(row)!; this._model.lineLengths[y] = 0; + const joinedRanges = this._characterJoinerService.getJoinedCharacters(row); for (let x = 0; x < terminal.cols; x++) { - line.loadCell(x, this._workCell); + line.loadCell(x, cell); - const chars = this._workCell.getChars(); - let code = this._workCell.getCode(); + // If true, indicates that the current character(s) to draw were joined. + let isJoined = false; + let lastCharX = x; + + // 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 + cell = new JoinedCellData( + cell, + line!.translateToString(true, range[0], range[1]), + range[1] - range[0] + ); + + // Skip over the cells occupied by this range in the loop + lastCharX = range[1] - 1; + } + + const chars = cell.getChars(); + let code = cell.getCode(); const i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; if (code !== NULL_CELL_CODE) { @@ -321,7 +350,18 @@ export class WebglRenderer extends Disposable implements IRenderer { this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._workCell.bg; this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._workCell.fg; + console.log('updateCell', x, y, code, chars); this._glyphRenderer.updateCell(x, y, code, this._workCell.bg, this._workCell.fg, chars); + + if (isJoined) { + // Restore work cell + cell = this._workCell; + + // Null out non-first cells + for (x++; x < lastCharX; x++) { + this._glyphRenderer.updateCell(x, y, NULL_CELL_CODE, 0, 0, NULL_CELL_CHAR); + } + } } } this._rectangleRenderer.updateBackgrounds(this._model); @@ -438,3 +478,49 @@ export class WebglRenderer extends Disposable implements IRenderer { this.dimensions.actualCellWidth = this.dimensions.scaledCellWidth / this._devicePixelRatio; } } + +// TODO: Share impl with core +export class JoinedCellData extends AttributeData implements ICellData { + private _width: number; + // .content carries no meaning for joined CellData, simply nullify it + // thus we have to overload all other .content accessors + public content: number = 0; + public fg: number; + public bg: number; + public combinedData: string = ''; + + constructor(firstCell: ICellData, chars: string, width: number) { + super(); + this.fg = firstCell.fg; + this.bg = firstCell.bg; + this.combinedData = chars; + this._width = width; + } + + public isCombined(): number { + // always mark joined cell data as combined + return Content.IS_COMBINED_MASK; + } + + public getWidth(): number { + return this._width; + } + + public getChars(): string { + return this.combinedData; + } + + public getCode(): number { + // code always gets the highest possible fake codepoint (read as -1) + // this is needed as code is used by caches as identifier + return 0x1FFFFF; + } + + public setFromCharData(value: CharData): void { + throw new Error('not implemented'); + } + + public getAsCharData(): CharData { + return [this.fg, this.getChars(), this.getWidth(), this.getCode()]; + } +} diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index dfefb339..61a7ae4d 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -83,7 +83,7 @@ export class WebglCharAtlas implements IDisposable { this._cacheCtx = throwIfFalsy(this.cacheCanvas.getContext('2d', { alpha: true })); this._tmpCanvas = document.createElement('canvas'); - this._tmpCanvas.width = this._config.scaledCharWidth * 2 + TMP_CANVAS_GLYPH_PADDING * 2; + this._tmpCanvas.width = this._config.scaledCharWidth * 4 + TMP_CANVAS_GLYPH_PADDING * 2; this._tmpCanvas.height = this._config.scaledCharHeight + TMP_CANVAS_GLYPH_PADDING * 2; this._tmpCtx = throwIfFalsy(this._tmpCanvas.getContext('2d', { alpha: this._config.allowTransparency })); } @@ -317,6 +317,14 @@ export class WebglCharAtlas implements IDisposable { this.hasCanvasChanged = true; + // Allow 1 cell width per character, with a minimum of 2 (CJK), plus some padding. This is used + // to draw the glyph to the canvas as well as to restrict the bounding box search to ensure + // giant ligatures (eg. =====>) don't impact overall performance. + const allowedWidth = this._config.scaledCharWidth * Math.max(chars.length, 2) + TMP_CANVAS_GLYPH_PADDING * 2; + console.log('allowedWidth', allowedWidth); + if (this._tmpCanvas.width < allowedWidth) { + this._tmpCanvas.width = allowedWidth; + } this._tmpCtx.save(); this._workAttributeData.fg = fg; @@ -405,7 +413,7 @@ export class WebglCharAtlas implements IDisposable { return NULL_RASTERIZED_GLYPH; } - const rasterizedGlyph = this._findGlyphBoundingBox(imageData, this._workBoundingBox, isPowerlineGlyph); + const rasterizedGlyph = this._findGlyphBoundingBox(imageData, this._workBoundingBox, allowedWidth, isPowerlineGlyph); const clippedImageData = this._clipImageData(imageData, this._workBoundingBox); // Check if there is enough room in the current row and go to next if needed @@ -438,14 +446,14 @@ export class WebglCharAtlas implements IDisposable { * @param imageData The image data to read. * @param boundingBox An IBoundingBox to put the clipped bounding box values. */ - private _findGlyphBoundingBox(imageData: ImageData, boundingBox: IBoundingBox, restrictedGlyph: boolean): IRasterizedGlyph { + private _findGlyphBoundingBox(imageData: ImageData, boundingBox: IBoundingBox, allowedWidth: number, restrictedGlyph: boolean): IRasterizedGlyph { boundingBox.top = 0; const height = restrictedGlyph ? this._config.scaledCharHeight : this._tmpCanvas.height; - const width = restrictedGlyph ? this._config.scaledCharWidth : this._tmpCanvas.width; + const width = restrictedGlyph ? this._config.scaledCharWidth : allowedWidth; let found = false; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const alphaOffset = y * width * 4 + x * 4 + 3; + const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; if (imageData.data[alphaOffset] !== 0) { boundingBox.top = y; found = true; @@ -460,7 +468,7 @@ export class WebglCharAtlas implements IDisposable { found = false; for (let x = 0; x < width; x++) { for (let y = 0; y < height; y++) { - const alphaOffset = y * width * 4 + x * 4 + 3; + const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; if (imageData.data[alphaOffset] !== 0) { boundingBox.left = x; found = true; @@ -475,7 +483,7 @@ export class WebglCharAtlas implements IDisposable { found = false; for (let x = width - 1; x >= 0; x--) { for (let y = 0; y < height; y++) { - const alphaOffset = y * width * 4 + x * 4 + 3; + const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; if (imageData.data[alphaOffset] !== 0) { boundingBox.right = x; found = true; @@ -490,7 +498,7 @@ export class WebglCharAtlas implements IDisposable { found = false; for (let y = height - 1; y >= 0; y--) { for (let x = 0; x < width; x++) { - const alphaOffset = y * width * 4 + x * 4 + 3; + const alphaOffset = y * this._tmpCanvas.width * 4 + x * 4 + 3; if (imageData.data[alphaOffset] !== 0) { boundingBox.bottom = y; found = true; diff --git a/src/browser/renderer/TextRenderLayer.ts b/src/browser/renderer/TextRenderLayer.ts index 48bf848e..ded6c9c6 100644 --- a/src/browser/renderer/TextRenderLayer.ts +++ b/src/browser/renderer/TextRenderLayer.ts @@ -99,7 +99,6 @@ export class TextRenderLayer extends BaseRenderLayer { // We already know the exact start and end column of the joined range, // so we get the string and width representing it directly - cell = new JoinedCellData( this._workCell, line!.translateToString(true, range[0], range[1]), diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index a052db54..eb2dd1fc 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -42,7 +42,6 @@ export class DomRendererRowFactory { const fragment = this._document.createDocumentFragment(); const joinedRanges = this._characterJoinerService.getJoinedCharacters(row); - console.log('joinedRanges', joinedRanges.map(e => e[0] + '->' + e[1]).join(',')); // Find the line length first, this prevents the need to output a bunch of // empty cells at the end. This cannot easily be integrated into the main // loop below because of the colCount feature (which can be removed after we @@ -79,7 +78,6 @@ export class DomRendererRowFactory { // We already know the exact start and end column of the joined range, // so we get the string and width representing it directly - cell = new JoinedCellData( this._workCell, lineData.translateToString(true, range[0], range[1]), From 00fa7bec42b090642197b1169db53668568ffb10 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 2 Apr 2021 11:32:02 -0700 Subject: [PATCH 2/2] Null out secondary ligature chars in cache --- addons/xterm-addon-webgl/src/WebglRenderer.ts | 15 +++++++++------ .../xterm-addon-webgl/src/atlas/WebglCharAtlas.ts | 1 - 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index ff159f70..3d25e5b0 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -335,8 +335,8 @@ export class WebglRenderer extends Disposable implements IRenderer { // Nothing has changed, no updates needed if (this._model.cells[i] === code && - this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._workCell.bg && - this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._workCell.fg) { + this._model.cells[i + RENDER_MODEL_BG_OFFSET] === cell.bg && + this._model.cells[i + RENDER_MODEL_FG_OFFSET] === cell.fg) { continue; } @@ -347,11 +347,10 @@ export class WebglRenderer extends Disposable implements IRenderer { // Cache the results in the model this._model.cells[i] = code; - this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._workCell.bg; - this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._workCell.fg; + this._model.cells[i + RENDER_MODEL_BG_OFFSET] = cell.bg; + this._model.cells[i + RENDER_MODEL_FG_OFFSET] = cell.fg; - console.log('updateCell', x, y, code, chars); - this._glyphRenderer.updateCell(x, y, code, this._workCell.bg, this._workCell.fg, chars); + this._glyphRenderer.updateCell(x, y, code, cell.bg, cell.fg, chars); if (isJoined) { // Restore work cell @@ -359,7 +358,11 @@ export class WebglRenderer extends Disposable implements IRenderer { // Null out non-first cells for (x++; x < lastCharX; x++) { + const j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; this._glyphRenderer.updateCell(x, y, NULL_CELL_CODE, 0, 0, NULL_CELL_CHAR); + this._model.cells[j] = NULL_CELL_CODE; + this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._workCell.bg; + this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._workCell.fg; } } } diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index 61a7ae4d..5e1ad195 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -321,7 +321,6 @@ export class WebglCharAtlas implements IDisposable { // to draw the glyph to the canvas as well as to restrict the bounding box search to ensure // giant ligatures (eg. =====>) don't impact overall performance. const allowedWidth = this._config.scaledCharWidth * Math.max(chars.length, 2) + TMP_CANVAS_GLYPH_PADDING * 2; - console.log('allowedWidth', allowedWidth); if (this._tmpCanvas.width < allowedWidth) { this._tmpCanvas.width = allowedWidth; }