From 370bad1380ecc0c54577177ab6606a5e86f7ec59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 28 Jul 2023 17:43:53 +0200 Subject: [PATCH] better caching: - own cache class with flat and holey cache - use letter spacing - on-demand measuring --- src/browser/renderer/dom/DomRenderer.ts | 130 +++++------------- .../dom/DomRendererRowFactory.test.ts | 129 +++++++++-------- .../renderer/dom/DomRendererRowFactory.ts | 79 ++++------- src/browser/renderer/dom/SpacingCache.ts | 130 ++++++++++++++++++ 4 files changed, 260 insertions(+), 208 deletions(-) create mode 100644 src/browser/renderer/dom/SpacingCache.ts diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index a6eeae65..31d75fcc 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -4,6 +4,7 @@ */ import { BOLD_CLASS, CURSOR_BLINK_CLASS, CURSOR_CLASS, CURSOR_STYLE_BAR_CLASS, CURSOR_STYLE_BLOCK_CLASS, CURSOR_STYLE_UNDERLINE_CLASS, DIM_CLASS, DomRendererRowFactory, ITALIC_CLASS } from 'browser/renderer/dom/DomRendererRowFactory'; +import { SpacingCache } from 'browser/renderer/dom/SpacingCache'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/shared/Constants'; import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types'; @@ -13,7 +14,7 @@ import { color } from 'common/Color'; import { EventEmitter } from 'common/EventEmitter'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IInstantiationService, IOptionsService } from 'common/services/Services'; -import { IdleTaskQueue } from 'common/TaskQueue'; + const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-'; const ROW_CONTAINER_CLASS = 'xterm-rows'; @@ -42,14 +43,10 @@ export class DomRenderer extends Disposable implements IRenderer { private _terminalClass: number = nextTerminalId++; private _themeStyleElement!: HTMLStyleElement; - private _dimensionsStyleElement!: HTMLStyleElement; private _rowContainer: HTMLElement; private _rowElements: HTMLElement[] = []; private _selectionContainer: HTMLElement; - private _linkState = new Uint8Array(3); - private _fontMetrics: Uint8Array = new Uint8Array(FontMetrics.MAX); - private _metricsQueue = new IdleTaskQueue(); - private _metricsPos: number = FontMetrics.START; + private _spacingCache: SpacingCache; public dimensions: IRenderDimensions; @@ -101,59 +98,11 @@ export class DomRenderer extends Disposable implements IRenderer { this._rowContainer.remove(); this._selectionContainer.remove(); this._themeStyleElement.remove(); - this._dimensionsStyleElement.remove(); + this._spacingCache.dispose(); })); - this._cacheMetrics(); - } - - private _batchedMetrics(): boolean { - const parent = this._screenElement.querySelector('.xterm-helpers'); - if (!parent) { - this._metricsPos = FontMetrics.START; - return false; - } - - const container = document.createElement('div'); - container.setAttribute('aria-hidden', 'true'); - container.style.whiteSpace = 'pre'; - container.style.overflow = 'hidden'; - container.style.fontFamily = this._optionsService.rawOptions.fontFamily; - container.style.fontSize = `${this._optionsService.rawOptions.fontSize}px`; - - const cellWidth = this.dimensions.css.cell.width; - const lower = 10 * cellWidth * (1 - FontMetrics.THRESHOLD); - const upper = 10 * cellWidth * (1 + FontMetrics.THRESHOLD); - const end = Math.min(this._metricsPos + FontMetrics.BATCH_SIZE, FontMetrics.MAX); - - for (let i = this._metricsPos; i < end; ++i) { - const el = document.createElement('span'); - el.classList.add('xterm-char-measure-element'); - el.textContent = String.fromCharCode(i).repeat(10); - container.appendChild(el); - } - parent.appendChild(container); - - const collection = container.children; - for (let i = 0; i < collection.length; ++i) { - const width = collection[i].getBoundingClientRect().width; - this._fontMetrics[i + this._metricsPos] = +(width < lower || width > upper); - } - container.remove(); - - this._metricsPos = end; - if (this._metricsPos >= FontMetrics.MAX) { - this._metricsPos = FontMetrics.START; - return false; - } - return true; - } - - private _cacheMetrics(): void { - this._metricsQueue.clear(); - this._fontMetrics.fill(0xFF); - this._metricsPos = FontMetrics.START; - this._metricsQueue.enqueue(() => this._batchedMetrics()); + this._spacingCache = new SpacingCache(document); + this._spacingCache.setFont(this._optionsService.rawOptions.fontFamily, this._optionsService.rawOptions.fontSize); } private _updateDimensions(): void { @@ -179,22 +128,6 @@ export class DomRenderer extends Disposable implements IRenderer { element.style.overflow = 'hidden'; } - if (!this._dimensionsStyleElement) { - this._dimensionsStyleElement = document.createElement('style'); - this._screenElement.appendChild(this._dimensionsStyleElement); - } - - const styles = - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` + - ` display: inline-block;` + - ` height: 100%;` + - ` vertical-align: top;` + - ` width: ${this.dimensions.css.cell.width}px;` + - ` white-space: pre` + - `}`; - - this._dimensionsStyleElement.textContent = styles; - this._selectionContainer.style.height = this._viewportElement.style.height; this._screenElement.style.width = `${this.dimensions.css.canvas.width}px`; this._screenElement.style.height = `${this.dimensions.css.canvas.height}px`; @@ -212,6 +145,7 @@ export class DomRenderer extends Disposable implements IRenderer { ` color: ${colors.foreground.css};` + ` font-family: ${this._optionsService.rawOptions.fontFamily};` + ` font-size: ${this._optionsService.rawOptions.fontSize}px;` + + ` white-space: pre` + `}`; styles += `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` + @@ -409,7 +343,8 @@ export class DomRenderer extends Disposable implements IRenderer { this._updateDimensions(); // Refresh CSS this._injectCss(this._themeService.colors); - this._cacheMetrics(); + // update spacing cache + this._spacingCache.setFont(this._optionsService.rawOptions.fontFamily, this._optionsService.rawOptions.fontSize); } public clear(): void { @@ -426,26 +361,31 @@ export class DomRenderer extends Disposable implements IRenderer { } public renderRows(start: number, end: number): void { - const cursorAbsoluteY = this._bufferService.buffer.ybase + this._bufferService.buffer.y; - const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1); + const buffer = this._bufferService.buffer; + const cursorAbsoluteY = buffer.ybase + buffer.y; + const cursorX = Math.min(buffer.x, this._bufferService.cols - 1); const cursorBlink = this._optionsService.rawOptions.cursorBlink; + const cursorStyle = this._optionsService.rawOptions.cursorStyle; for (let y = start; y <= end; y++) { + const row = y + buffer.ydisp; const rowElement = this._rowElements[y]; - const row = y + this._bufferService.buffer.ydisp; - const lineData = this._bufferService.buffer.lines.get(row); - const cursorStyle = this._optionsService.rawOptions.cursorStyle; + const lineData = buffer.lines.get(row); + if (!rowElement || !lineData) { + break; + } rowElement.replaceChildren( this._rowFactory.createRow( - lineData!, + lineData, row, row === cursorAbsoluteY, cursorStyle, cursorX, cursorBlink, this.dimensions.css.cell.width, - this._fontMetrics, - this._linkState + this._spacingCache, + -1, + -1 ) ); } @@ -471,39 +411,35 @@ export class DomRenderer extends Disposable implements IRenderer { y = Math.max(Math.min(y, maxY), 0); y2 = Math.max(Math.min(y2, maxY), 0); - const cursorAbsoluteY = this._bufferService.buffer.ybase + this._bufferService.buffer.y; - const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1); + cols = Math.min(cols, this._bufferService.cols); + const buffer = this._bufferService.buffer; + const cursorAbsoluteY = buffer.ybase + buffer.y; + const cursorX = Math.min(buffer.x, cols - 1); const cursorBlink = this._optionsService.rawOptions.cursorBlink; const cursorStyle = this._optionsService.rawOptions.cursorStyle; - cols = Math.min(cols, this._bufferService.cols); // refresh rows within link range - this._linkState[0] = +enabled; for (let i = y; i <= y2; ++i) { + const row = i + buffer.ydisp; const rowElement = this._rowElements[i]; - if (!rowElement) { + const bufferline = buffer.lines.get(row); + if (!rowElement || !bufferline) { break; } - if (enabled) { - this._linkState[1] = i === y ? x : 0; - this._linkState[2] = (i === y2 ? x2 : cols) - 1; - } - const row = i + this._bufferService.buffer.ydisp; - const lineData = this._bufferService.buffer.lines.get(row); rowElement.replaceChildren( this._rowFactory.createRow( - lineData!, + bufferline, row, row === cursorAbsoluteY, cursorStyle, cursorX, cursorBlink, this.dimensions.css.cell.width, - this._fontMetrics, - this._linkState + this._spacingCache, + enabled ? (i === y ? x : 0) : -1, + enabled ? ((i === y2 ? x2 : cols) - 1) : -1 ) ); } - this._linkState[0] = 0; } } diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 6a454d37..64f4b198 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -11,13 +11,21 @@ import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; import { MockCoreService, MockDecorationService, MockOptionsService } from 'common/TestUtils.test'; -import { css } from 'common/Color'; import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test'; import { FontMetrics } from 'browser/renderer/dom/DomRenderer'; +import { FontVariant, SpacingCache } from 'browser/renderer/dom/SpacingCache'; + +class EmptySpacingCache extends SpacingCache { + public spacing: {[key: string]: number} = {}; + public get(c: string, pixelWidth: number, variant: FontVariant): number { + if (this.spacing[c] !== undefined) { + return this.spacing[c]; + } + return 0; + } +} +const EMPTY_SPACING = new EmptySpacingCache(new jsdom.JSDOM('').window.document); -const EMPTY_METRICS = new Uint8Array(FontMetrics.MAX); -EMPTY_METRICS.fill(0xFF); -const EMPTY_LINKSTATE = new Uint8Array(3); describe('DomRendererRowFactory', () => { let dom: jsdom.JSDOM; @@ -40,7 +48,7 @@ describe('DomRendererRowFactory', () => { describe('createRow', () => { it('should not create anything for an empty row', () => { - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), '' ); @@ -50,15 +58,15 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)])); // There should be no element for the following "empty" cell lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '', 0, 0])); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - '' + '' ); }); it('should add class for cursor and cursor style', () => { for (const style of ['block', 'bar', 'underline']) { - const fragment = rowFactory.createRow(lineData, 0, true, style, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, true, style, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), ` ` ); @@ -66,7 +74,7 @@ describe('DomRendererRowFactory', () => { }); it('should add class for cursor blink', () => { - const fragment = rowFactory.createRow(lineData, 0, true, 'block', 0, true, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, true, 'block', 0, true, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), ` ` ); @@ -77,7 +85,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BOLD; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -87,7 +95,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.ITALIC; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -97,7 +105,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.DIM; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -110,7 +118,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.SINGLE; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -121,7 +129,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.DOUBLE; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -132,7 +140,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.CURLY; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -143,7 +151,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.DOTTED; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -154,7 +162,7 @@ describe('DomRendererRowFactory', () => { cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED; cell.extended.underlineStyle = UnderlineStyle.DASHED; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -165,7 +173,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.OVERLINE; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -175,7 +183,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.STRIKETHROUGH; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -188,7 +196,7 @@ describe('DomRendererRowFactory', () => { cell.fg &= ~Attributes.PCOLOR_MASK; cell.fg |= i; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), `a` ); @@ -202,7 +210,7 @@ describe('DomRendererRowFactory', () => { cell.bg &= ~Attributes.PCOLOR_MASK; cell.bg |= i; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), `a` ); @@ -214,7 +222,7 @@ describe('DomRendererRowFactory', () => { cell.fg |= Attributes.CM_P16 | 2 | FgFlags.INVERSE; cell.bg |= Attributes.CM_P16 | 1; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -225,7 +233,7 @@ describe('DomRendererRowFactory', () => { cell.fg |= FgFlags.INVERSE; cell.bg |= Attributes.CM_P16 | 1; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -235,7 +243,7 @@ describe('DomRendererRowFactory', () => { const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); cell.fg |= Attributes.CM_P16 | 1 | FgFlags.INVERSE; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -248,7 +256,7 @@ describe('DomRendererRowFactory', () => { cell.fg &= ~Attributes.PCOLOR_MASK; cell.fg |= i; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), `a` ); @@ -260,7 +268,7 @@ describe('DomRendererRowFactory', () => { cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3; cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -271,7 +279,7 @@ describe('DomRendererRowFactory', () => { cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3 | FgFlags.INVERSE; cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6; lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'a' ); @@ -283,7 +291,7 @@ describe('DomRendererRowFactory', () => { lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); rowFactory.handleSelectionChanged([1, 0], [2, 0], false); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), 'ab' ); @@ -291,7 +299,7 @@ describe('DomRendererRowFactory', () => { it('should force whitespace cells to be rendered above the background', () => { lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); rowFactory.handleSelectionChanged([0, 0], [2, 0], false); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_METRICS, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), ' a' ); @@ -299,53 +307,54 @@ describe('DomRendererRowFactory', () => { }); }); - describe.only('createRow with merged spans', () => { + describe('createRow with merged spans', () => { // for test purpose assume all in codepoints 0..255 are merging - const ALL_MERGING = new Uint8Array(FontMetrics.MAX); + // const ALL_MERGING = new Uint8Array(FontMetrics.MAX); beforeEach(() => { lineData = createEmptyLineData(10); }); it('should not create anything for an empty row', () => { - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), '' ); }); - it('can merge codepoints in FontMetrics range', () => { + it('can merge codepoints for equal spacing', () => { lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)])); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - 'abc' + 'abc' ); }); - it('should not merge codepoints outside of FontMetrics range', () => { + it('should not merge codepoints with different spacing', () => { + EMPTY_SPACING.spacing['€'] = 3; lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)])); lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)])); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - 'ac' + 'ac' ); }); it('should not merge on FG change', () => { - const a_color1 = CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]); - a_color1.fg |= Attributes.CM_P16 | 1; - const b_color2 = CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]); - b_color2.fg |= Attributes.CM_P16 | 2; - lineData.setCell(0, a_color1); - lineData.setCell(1, a_color1); - lineData.setCell(2, b_color2); - lineData.setCell(3, b_color2); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + const aColor1 = CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]); + aColor1.fg |= Attributes.CM_P16 | 1; + const bColor2 = CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]); + bColor2.fg |= Attributes.CM_P16 | 2; + lineData.setCell(0, aColor1); + lineData.setCell(1, aColor1); + lineData.setCell(2, bColor2); + lineData.setCell(3, bColor2); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - 'aabb' + 'aabb' ); }); @@ -355,9 +364,9 @@ describe('DomRendererRowFactory', () => { lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'X', 1, 'X'.charCodeAt(0)])); lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)])); - const fragment = rowFactory.createRow(lineData, 0, true, undefined, 2, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, true, undefined, 2, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - 'aaXbb' + 'aaXbb' ); }); @@ -368,35 +377,35 @@ describe('DomRendererRowFactory', () => { nullCell.bg = Attributes.CM_P16 | 2; lineData.setCell(3, nullCell); lineData.setCell(4, nullCell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - ' ' + ' ' ); }); - it('should contain px value in BCE for multiple cells', () => { + it('should handle BCE for multiple cells', () => { const nullCell = lineData.loadCell(0, new CellData()); nullCell.bg = Attributes.CM_P16 | 1; lineData.setCell(0, nullCell); - let fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + let fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), ' ' ); lineData.setCell(1, nullCell); - fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - ' ' + ' ' ); lineData.setCell(2, nullCell); lineData.setCell(3, nullCell); - fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - ' ' + ' ' ); lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)])); - fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, ALL_MERGING, EMPTY_LINKSTATE); + fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_SPACING, -1, -1); assert.equal(getFragmentHtml(fragment), - ' a' + ' a' ); }); diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 6b221e45..abe48e20 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -1,11 +1,11 @@ /** - * Copyright (c) 2018 The xterm.js authors. All rights reserved. + * Copyright (c) 2018, 2023 The xterm.js authors. All rights reserved. * @license MIT */ import { IBufferLine, ICellData, IColor } from 'common/Types'; import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/shared/Constants'; -import { NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Attributes, NULL_CELL_WIDTH } from 'common/buffer/Constants'; +import { WHITESPACE_CELL_CHAR, Attributes } from 'common/buffer/Constants'; import { CellData } from 'common/buffer/CellData'; import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { color, rgba } from 'common/Color'; @@ -13,6 +13,7 @@ import { ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'bro import { JoinedCellData } from 'browser/services/CharacterJoinerService'; import { excludeFromContrastRatioDemands } from 'browser/renderer/shared/RendererUtils'; import { AttributeData } from 'common/buffer/AttributeData'; +import { SpacingCache } from 'browser/renderer/dom/SpacingCache'; export const BOLD_CLASS = 'xterm-bold'; export const DIM_CLASS = 'xterm-dim'; @@ -57,8 +58,9 @@ export class DomRendererRowFactory { cursorX: number, cursorBlink: boolean, cellWidth: number, - metrics: Uint8Array, - linkState: Uint8Array + spacingCache: SpacingCache, + linkStart: number, + linkEnd: number ): DocumentFragment { const fragment = this._document.createDocumentFragment(); @@ -77,8 +79,10 @@ export class DomRendererRowFactory { let oldFg = 0; let oldExt = 0; let oldLinkHover: number | boolean = false; + let oldSpacing = 0; + let spacing = 0; - const isHover = linkState[0]; + const hasHover = linkStart !== -1 && linkEnd !== -1; for (let x = 0; x < lineLength; x++) { lineData.loadCell(x, this._workCell); @@ -118,11 +122,13 @@ export class DomRendererRowFactory { const isInSelection = this._isCellInSelection(x, row); const isCursorCell = isCursorRow && x === cursorX; - const cc = cell.getCode(); - const isNull = cc === NULL_CELL_CODE && width === NULL_CELL_WIDTH; - const isCombined = cell.isCombined(); - const isLinkHover = isHover && x >= linkState[1] && x <= linkState[2]; - const isBoldOrItalic = cell.isBold() && cell.isItalic(); + const isLinkHover = hasHover && x >= linkStart && x <= linkEnd; + + let chars = cell.getChars() || WHITESPACE_CELL_CHAR; + if (chars === ' ' && (cell.isUnderline() || cell.isOverline())) { + chars = '\xa0'; + } + spacing = spacingCache.get(chars, width * cellWidth, 0); if (!charElement) { charElement = this._document.createElement('span'); @@ -130,37 +136,27 @@ export class DomRendererRowFactory { /** * chars can only be merged on existing span if: * - existing span only contains mergeable chars (cellAmount != 0) - * - glyph is within metrics limits (width === 1 && metrics[cc] == 0) * - fg/bg/ul did not change * - char not part of a selection - * - char is not cursor * - underline from hover state did not change + * - cell content renders to same letter-spacing + * - char is not cursor */ if ( cellAmount - && (isNull || (width === 1 && !isCombined && cc < metrics.length && !metrics[cc])) && cell.bg === oldBg && cell.fg === oldFg && cell.extended.ext === oldExt && !isInSelection - && !isCursorCell && isLinkHover === oldLinkHover + && spacing === oldSpacing + && !isCursorCell + && !isJoined ) { - let c = cell.isInvisible() ? WHITESPACE_CELL_CHAR : (cell.getChars() || WHITESPACE_CELL_CHAR); - if (c === ' ' && (cell.isUnderline() || cell.isOverline())) { - c = '\xa0'; - } - text += c; + text += chars; cellAmount++; - oldBg = cell.bg; - oldFg = cell.fg; - oldExt = cell.extended.ext; - oldLinkHover = isLinkHover; continue; } else { if (cellAmount) { charElement.textContent = text; - if (cellAmount > 1) { - charElement.style.width = `${cellWidth * cellAmount}px`; - } } charElement = this._document.createElement('span'); cellAmount = 0; @@ -171,16 +167,9 @@ export class DomRendererRowFactory { oldFg = cell.fg; oldExt = cell.extended.ext; oldLinkHover = isLinkHover; - - if (width > 1) { - charElement.style.width = `${cellWidth * width}px`; - } + oldSpacing = spacing; if (isJoined) { - // Ligatures in the DOM renderer must use display inline, as they may not show with - // inline-block if they are outside the bounds of the element - charElement.style.display = 'inline'; - // The DOM renderer colors the background of the cursor but for ligatures all cells are // joined. The workaround here is to show a cursor around the whole ligature so it shows up, // the cursor looks the same when on any character of the ligature though @@ -377,34 +366,22 @@ export class DomRendererRowFactory { } } - - // account first char for later merge if it meets the start conditions - if ( - (isNull || (width === 1 && !isCombined && cc < metrics.length && !metrics[cc])) - && !isBoldOrItalic - && !isInSelection - && !isCursorCell - ) { + if (!isCursorCell && !isInSelection && !isJoined) { cellAmount++; } else { - // every non-mergeable char gets directly written to its own span charElement.textContent = text; } + if (spacing) { + charElement.style.letterSpacing = `${spacing}px`; + } fragment.appendChild(charElement); x = lastCharX; } - // postfix width and text of last merged span + // postfix text of last merged span if (charElement && cellAmount) { charElement.textContent = text; - /* - * optimization: if the last merged span has no BG color set, use faster "width: auto", - * else use correct px value for aligned BG coloring and BCE - */ - if (cellAmount > 1) { - charElement.style.width = (oldBg & Attributes.CM_MASK) ? `${cellWidth * cellAmount}px`: 'auto'; - } } return fragment; diff --git a/src/browser/renderer/dom/SpacingCache.ts b/src/browser/renderer/dom/SpacingCache.ts new file mode 100644 index 00000000..ca239cb1 --- /dev/null +++ b/src/browser/renderer/dom/SpacingCache.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2023 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IDisposable } from 'common/Types'; + + +export const enum FontVariant { + REGULAR = 0, + ITALIC = 1, + BOLD = 2, + BOLD_ITALIC = 3 +} + + +const enum CacheSettings { + FLAT_UNSET = -9999, // sentinel for unset values in flat cache + FLAT_SIZE = 256, // codepoint upper bound to handle in flat cache + REPEAT = 32 // char repeat for measuring +} + + +export class SpacingCache implements IDisposable { + // flat cache for regular + private _flat = new Float32Array(CacheSettings.FLAT_SIZE); + // holey cache for bold, italic and bold&italic for any string + private _holey = new Map(); + + private _font = ''; + private _fontSize = 0; + private _container: HTMLDivElement; + private _measureElements: HTMLSpanElement[] = []; + + constructor( + private readonly _document: Document + ) { + this._container = _document.createElement('div'); + this._container.style.position = 'absolute'; + this._container.style.top = '-50000px'; + this._container.style.width = '50000px'; + this._container.style.whiteSpace = 'pre'; + + const regular = _document.createElement('span'); + + const bold = _document.createElement('span'); + bold.style.fontWeight = 'bold'; + + const italic = _document.createElement('span'); + italic.style.fontStyle = 'italic'; + + const boldItalic = _document.createElement('span'); + boldItalic.style.fontWeight = 'bold'; + boldItalic.style.fontStyle = 'italic'; + + this._measureElements = [regular, bold, italic, boldItalic]; + this._container.appendChild(regular); + this._container.appendChild(bold); + this._container.appendChild(italic); + this._container.appendChild(boldItalic); + + _document.body.appendChild(this._container); + + this.clear(); + } + + public dispose(): void { + this._container.remove(); + this._measureElements.length = 0; + this._holey.clear(); + } + + /** + * Clear the spacing cache. + */ + public clear(): void { + this._flat.fill(CacheSettings.FLAT_UNSET); + this._holey.clear(); + } + + /** + * Set the font for measuring. + * Must be called for any fontFamily or fontSize changes. + * Also clears the cache. + */ + public setFont(font: string, fontSize: number): void { + if (font !== this._font || fontSize !== this._fontSize) { + this._font = font; + this._fontSize = fontSize; + this.clear(); + + this._container.style.fontFamily = this._font; + this._container.style.fontSize = `${this._fontSize}px`; + } + } + + /** + * Get the letter-spacing value for cell content `c`. + * `c` should be the cell content obtained from `cell.getChars()`. + * `pixelWidth` is the standard width the cell should render with + * and can be calculated by `cell.getWidth() * cellWidth`. + * `variant` denotes the font variant to be used (0-regular, 1-bold, 2-italic, 3-bold&italic). + * + * Returns the letter-spacing value, so that `c` renders aligned to `pixelWidth`. + */ + public get(c: string, pixelWidth: number, variant: FontVariant): number { + let cp = 0; + if (!variant && c.length === 1 && (cp = c.charCodeAt(0)) < CacheSettings.FLAT_SIZE) { + return this._flat[cp] !== CacheSettings.FLAT_UNSET + ? this._flat[cp] + : (this._flat[cp] = pixelWidth - this._measure(c, 0)); + } + let key = c; + if (variant & FontVariant.BOLD) key += 'B'; + if (variant & FontVariant.ITALIC) key += 'I'; + let spacing = this._holey.get(key); + if (spacing === undefined) { + spacing = pixelWidth - this._measure(c, variant); + this._holey.set(key, spacing); + } + return spacing; + } + + private _measure(c: string, variant: FontVariant): number { + const el = this._measureElements[variant]; + el.textContent = c.repeat(CacheSettings.REPEAT); + const width = el.getBoundingClientRect().width / CacheSettings.REPEAT; + return width; + } +}