diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts
index 7fd7352b..455a401d 100644
--- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts
+++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts
@@ -12,18 +12,10 @@ import { IBufferLine } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { MockCoreService, MockDecorationService, MockOptionsService } from 'common/TestUtils.test';
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test';
-import { WidthCache } from 'browser/renderer/dom/WidthCache';
+import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
-class EmptyWidthCache extends WidthCache {
- public widths: {[key: string]: number} = {};
- public get(c: string, bold: boolean | number, italic: boolean | number): number {
- if (this.widths[c] !== undefined) {
- return this.widths[c];
- }
- return 5; // 5 is default width below in tests
- }
-}
-const EMPTY_WIDTH = new EmptyWidthCache(new jsdom.JSDOM('').window.document);
+
+const EMPTY_WIDTH = new TestWidthCache(new jsdom.JSDOM('').window.document);
describe('DomRendererRowFactory', () => {
@@ -54,7 +46,7 @@ describe('DomRendererRowFactory', () => {
});
it('should set correct attributes for double width characters', () => {
- EMPTY_WIDTH.widths['語'] = 10;
+ EMPTY_WIDTH.widths['語'] = [10, 10, 10, 10];
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]));
@@ -333,7 +325,7 @@ describe('DomRendererRowFactory', () => {
});
it('should not merge codepoints with different spacing', () => {
- EMPTY_WIDTH.widths['€'] = 2;
+ EMPTY_WIDTH.widths['€'] = [2, 2, 2, 2];
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)]));
@@ -409,6 +401,56 @@ describe('DomRendererRowFactory', () => {
);
});
+ it('should apply correct positive or negative spacing', () => {
+ EMPTY_WIDTH.widths['€'] = [2, 2, 2, 2]; // too small, should add 3px
+ EMPTY_WIDTH.widths['語'] = [10, 10, 10, 10]; // exact match for its width, should merge
+ EMPTY_WIDTH.widths['𝄞'] = [7, 7, 7, 7]; // too wide, should subtract -2px
+ 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)]));
+ lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, '語', 2, 'c'.charCodeAt(0)]));
+ lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, '𝄞', 1, 'c'.charCodeAt(0)]));
+ const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
+ assert.equal(extractHtml(spans),
+ 'a€c語𝄞'
+ );
+ });
+
+ it('should not merge across link borders', () => {
+ lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
+ lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
+ lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
+ lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
+ lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
+ lineData.setCell(5, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
+ lineData.setCell(6, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
+ const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, 2, 4);
+ assert.equal(extractHtml(spans),
+ 'aaxxxbb'
+ );
+ });
+
+ it('empty cells included in link underline', () => {
+ lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
+ lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
+ lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
+ lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
+ const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, 2, 4);
+ assert.equal(extractHtml(spans),
+ 'aax x'
+ );
+ });
+
+ it('link range gets capped to actual line borders', () => {
+ for (let i = 0; i < 10; ++i) {
+ lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
+ }
+ const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -100, 100);
+ assert.equal(extractHtml(spans),
+ 'aaaaaaaaaa'
+ );
+ });
+
});
function extractHtml(spans: HTMLSpanElement[]): string {
diff --git a/src/browser/renderer/dom/WidthCache.test.ts b/src/browser/renderer/dom/WidthCache.test.ts
new file mode 100644
index 00000000..8efc6ff4
--- /dev/null
+++ b/src/browser/renderer/dom/WidthCache.test.ts
@@ -0,0 +1,127 @@
+/**
+ * Copyright (c) 2023 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+
+import * as assert from 'assert';
+import { WidthCache, WidthCacheSettings } from 'browser/renderer/dom/WidthCache';
+import jsdom = require('jsdom');
+
+
+export class TestWidthCache extends WidthCache {
+ public get flat(): Float32Array {
+ return (this as any)._flat;
+ }
+ public get holey(): Map | undefined {
+ return (this as any)._holey;
+ }
+
+ public widths: {[key: string]: [number, number, number, number]} = {};
+ protected _measure(c: string, variant: number): number {
+ if (this.widths[c] !== undefined) {
+ return this.widths[c][variant];
+ }
+ return 5; // 5 is default width in tests in DomRendererRowFactory.test.ts
+ }
+}
+
+
+function castf32(v: number): number {
+ const buffer = new Float32Array(1);
+ buffer[0] = v;
+ return buffer[0];
+}
+
+
+describe('WidthCache', () => {
+ let wc: TestWidthCache;
+ beforeEach(() => {
+ wc = new TestWidthCache(new jsdom.JSDOM('').window.document);
+ wc.setFont('monospace', 15, 'normal', 'bold');
+ });
+ describe('cache invalidation', () => {
+ beforeEach(() => {
+ wc.flat.fill(1.23);
+ wc.holey?.set('a', 2.34);
+ });
+ it('can cache values', () => {
+ assert.deepStrictEqual(wc.flat[0], castf32(1.23));
+ assert.deepStrictEqual(wc.holey?.get('a'), 2.34);
+ assert.deepStrictEqual(wc.holey?.size, 1);
+ });
+ it('clear resets cache entries', () => {
+ wc.clear();
+ assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
+ assert.deepStrictEqual(wc.holey?.get('a'), undefined);
+ assert.deepStrictEqual(wc.holey?.size, 0);
+ });
+ it('setFont with changed font name', () => {
+ wc.setFont('Arial', 15, 'normal', 'bold');
+ assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
+ assert.deepStrictEqual(wc.holey?.get('a'), undefined);
+ assert.deepStrictEqual(wc.holey?.size, 0);
+ });
+ it('setFont with changed font size', () => {
+ wc.setFont('monospace', 14, 'normal', 'bold');
+ assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
+ assert.deepStrictEqual(wc.holey?.get('a'), undefined);
+ assert.deepStrictEqual(wc.holey?.size, 0);
+ });
+ it('setFont with changed weight', () => {
+ wc.setFont('monospace', 15, '100', 'bold');
+ assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
+ assert.deepStrictEqual(wc.holey?.get('a'), undefined);
+ assert.deepStrictEqual(wc.holey?.size, 0);
+ });
+ it('setFont with changed weightBold', () => {
+ wc.setFont('monospace', 15, 'normal', '900');
+ assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
+ assert.deepStrictEqual(wc.holey?.get('a'), undefined);
+ assert.deepStrictEqual(wc.holey?.size, 0);
+ });
+ it('setFont with unchanged settings does not cache entries', () => {
+ wc.setFont('monospace', 15, 'normal', 'bold');
+ assert.deepStrictEqual(wc.flat[0], castf32(1.23));
+ assert.deepStrictEqual(wc.holey?.get('a'), 2.34);
+ assert.deepStrictEqual(wc.holey?.size, 1);
+ });
+ });
+ describe('get', () => {
+ it('store regular < WidthCacheSettings.FLAT_SIZE in flat', () => {
+ for (let i = 0; i < WidthCacheSettings.FLAT_SIZE + 10; ++i) {
+ const width = wc.get(String.fromCharCode(i), false, false);
+ assert.deepStrictEqual(width, 5);
+ if (i < WidthCacheSettings.FLAT_SIZE) {
+ assert.deepStrictEqual(wc.flat[i], 5);
+ assert.deepStrictEqual(wc.holey?.get(String.fromCharCode(i)), undefined);
+ } else {
+ assert.deepStrictEqual(wc.holey?.get(String.fromCharCode(i)), 5);
+ }
+ }
+ });
+ it('stores bold & italic in holey', () => {
+ // bold
+ let width = wc.get('b', true, false);
+ assert.deepStrictEqual(width, 5);
+ assert.deepStrictEqual(wc.holey?.get('bB'), 5);
+ // italic
+ width = wc.get('i', false, true);
+ assert.deepStrictEqual(width, 5);
+ assert.deepStrictEqual(wc.holey?.get('iI'), 5);
+ // bold&italic
+ width = wc.get('x', true, true);
+ assert.deepStrictEqual(width, 5);
+ assert.deepStrictEqual(wc.holey?.get('xBI'), 5);
+ });
+ it('can store any string', () => {
+ // regular
+ let width = wc.get('foo', false, false);
+ assert.deepStrictEqual(width, 5);
+ assert.deepStrictEqual(wc.holey?.get('foo'), 5);
+ // bold&italic
+ width = wc.get('bar&baz', true, true);
+ assert.deepStrictEqual(width, 5);
+ assert.deepStrictEqual(wc.holey?.get('bar&bazBI'), 5);
+ });
+ });
+});
diff --git a/src/browser/renderer/dom/WidthCache.ts b/src/browser/renderer/dom/WidthCache.ts
index 45a9d094..ad8a5725 100644
--- a/src/browser/renderer/dom/WidthCache.ts
+++ b/src/browser/renderer/dom/WidthCache.ts
@@ -7,7 +7,7 @@ import { IDisposable } from 'common/Types';
import { FontWeight } from 'common/services/Services';
-const enum CacheSettings {
+export const enum WidthCacheSettings {
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
@@ -20,12 +20,12 @@ export class WidthCache implements IDisposable {
// It has a small memory footprint (only 1MB for full BMP caching),
// still the sweet spot is not reached before touching 32k different codepoints,
// thus we store the remaining <<20% of terminal data in a holey structure.
- private _flat = new Float32Array(CacheSettings.FLAT_SIZE);
+ protected _flat = new Float32Array(WidthCacheSettings.FLAT_SIZE);
// holey cache for bold, italic and bold&italic for any string
// FIXME: can grow really big over time (~8.5 MB for full BMP caching),
// so a shared API across terminals is needed
- private _holey: Map | undefined;
+ protected _holey: Map | undefined;
private _font = '';
private _fontSize = 0;
@@ -80,7 +80,7 @@ export class WidthCache implements IDisposable {
* Clear the width cache.
*/
public clear(): void {
- this._flat.fill(CacheSettings.FLAT_UNSET);
+ this._flat.fill(WidthCacheSettings.FLAT_UNSET);
// .clear() has some overhead, re-assign instead (>3 times faster)
this._holey = new Map();
}
@@ -121,8 +121,8 @@ export class WidthCache implements IDisposable {
*/
public get(c: string, bold: boolean | number, italic: boolean | number): number {
let cp = 0;
- if (!bold && !italic && c.length === 1 && (cp = c.charCodeAt(0)) < CacheSettings.FLAT_SIZE) {
- return this._flat[cp] !== CacheSettings.FLAT_UNSET
+ if (!bold && !italic && c.length === 1 && (cp = c.charCodeAt(0)) < WidthCacheSettings.FLAT_SIZE) {
+ return this._flat[cp] !== WidthCacheSettings.FLAT_UNSET
? this._flat[cp]
: (this._flat[cp] = this._measure(c, 0));
}
@@ -140,9 +140,9 @@ export class WidthCache implements IDisposable {
return width;
}
- private _measure(c: string, variant: number): number {
+ protected _measure(c: string, variant: number): number {
const el = this._measureElements[variant];
- el.textContent = c.repeat(CacheSettings.REPEAT);
- return el.getBoundingClientRect().width / CacheSettings.REPEAT;
+ el.textContent = c.repeat(WidthCacheSettings.REPEAT);
+ return el.getBoundingClientRect().width / WidthCacheSettings.REPEAT;
}
}