Merge pull request #4605 from jerch/faster_dom

Faster DOM renderer
This commit is contained in:
Daniel Imms
2023-08-01 12:32:56 -07:00
committed by GitHub
11 changed files with 842 additions and 231 deletions
@@ -56,35 +56,35 @@ describe('WebLinksAddon', () => {
it('all half width', async () => {
setupCustom();
await writeSync(page, 'aaa http://example.com aaa http://example.com aaa');
await resetAndHover(5, 1);
await resetAndHover(5, 0);
await evalLinkStateData('http://example.com', { start: { x: 5, y: 1 }, end: { x: 22, y: 1 } });
await resetAndHover(1, 2);
await resetAndHover(1, 1);
await evalLinkStateData('http://example.com', { start: { x: 28, y: 1 }, end: { x: 5, y: 2 } });
});
it('url after full width', async () => {
setupCustom();
await writeSync(page, '¥¥¥ http://example.com ¥¥¥ http://example.com aaa');
await resetAndHover(8, 1);
await resetAndHover(8, 0);
await evalLinkStateData('http://example.com', { start: { x: 8, y: 1 }, end: { x: 25, y: 1 } });
await resetAndHover(1, 2);
await resetAndHover(1, 1);
await evalLinkStateData('http://example.com', { start: { x: 34, y: 1 }, end: { x: 11, y: 2 } });
});
it('full width within url and before', async () => {
setupCustom();
await writeSync(page, '¥¥¥ https://ko.wikipedia.org/wiki/위키백과:대문 aaa https://ko.wikipedia.org/wiki/위키백과:대문 ¥¥¥');
await resetAndHover(8, 1);
await resetAndHover(8, 0);
await evalLinkStateData('https://ko.wikipedia.org/wiki/위키백과:대문', { start: { x: 8, y: 1 }, end: { x: 11, y: 2 } });
await resetAndHover(1, 2);
await resetAndHover(1, 1);
await evalLinkStateData('https://ko.wikipedia.org/wiki/위키백과:대문', { start: { x: 8, y: 1 }, end: { x: 11, y: 2 } });
await resetAndHover(17, 2);
await resetAndHover(17, 1);
await evalLinkStateData('https://ko.wikipedia.org/wiki/위키백과:대문', { start: { x: 17, y: 2 }, end: { x: 19, y: 3 } });
});
it('name + password url after full width and combining', async () => {
setupCustom();
await writeSync(page, '¥¥¥cafe\u0301 http://test:password@example.com/some_path');
await resetAndHover(12, 1);
await resetAndHover(12, 0);
await evalLinkStateData('http://test:password@example.com/some_path', { start: { x: 12, y: 1 }, end: { x: 13, y: 2 } });
await resetAndHover(13, 2);
await resetAndHover(5, 1);
await evalLinkStateData('http://test:password@example.com/some_path', { start: { x: 12, y: 1 }, end: { x: 13, y: 2 } });
});
});
@@ -101,35 +101,36 @@ async function testHostName(hostname: string): Promise<void> {
`\\'http://${hostname}/\\'\\r\\n` +
`http://${hostname}/subpath/+/id`;
await writeSync(page, data);
await pollForLinkAtCell(3, 1, `http://${hostname}`);
await pollForLinkAtCell(3, 2, `http://${hostname}/a~b#c~d?e~f`);
await pollForLinkAtCell(3, 0, `http://${hostname}`);
await pollForLinkAtCell(3, 1, `http://${hostname}/a~b#c~d?e~f`);
await pollForLinkAtCell(3, 2, `http://${hostname}/colon:test`);
await pollForLinkAtCell(3, 3, `http://${hostname}/colon:test`);
await pollForLinkAtCell(3, 4, `http://${hostname}/colon:test`);
await pollForLinkAtCell(2, 4, `http://${hostname}/`);
await pollForLinkAtCell(2, 5, `http://${hostname}/`);
await pollForLinkAtCell(2, 6, `http://${hostname}/`);
await pollForLinkAtCell(1, 7, `http://${hostname}/subpath/+/id`);
await pollForLinkAtCell(1, 6, `http://${hostname}/subpath/+/id`);
}
async function pollForLinkAtCell(col: number, row: number, value: string): Promise<void> {
const rowSelector = `.xterm-rows > :nth-child(${row})`;
// Ensure the hover element exists before trying to hover it
await pollFor(page, `!!document.querySelector('${rowSelector} > :nth-child(${col})')`, true);
await pollFor(page, `document.querySelectorAll('${rowSelector} > span[style]').length >= ${value.length}`, true, async () => page.hover(`${rowSelector} > :nth-child(${col})`));
assert.equal(await page.evaluate(`Array.prototype.reduce.call(document.querySelectorAll('${rowSelector} > span[style]'), (a, b) => a + b.textContent, '');`), value);
await page.mouse.move(...(await cellPos(col, row)));
await pollFor(page, `!!Array.from(document.querySelectorAll('.xterm-rows > :nth-child(${row+1}) > span[style]')).filter(el => el.style.textDecoration == 'underline').length`, true);
const text = await page.evaluate(`Array.from(document.querySelectorAll('.xterm-rows > :nth-child(${row+1}) > span[style]')).filter(el => el.style.textDecoration == 'underline').map(el => el.textContent).join('');`);
assert.deepEqual(text, value);
}
async function setupCustom(): Promise<void> {
await openTerminal(page, { cols: 40 });
await page.evaluate(`window._linkStateData = {};
await page.evaluate(`window._linkStateData = {uri:''};
window._linkaddon = new window.WebLinksAddon();
window._linkaddon._options.hover = (event, uri, range) => { window._linkStateData = { uri, range }; };
window.term.loadAddon(window._linkaddon);`);
}
async function resetAndHover(col: number, row: number): Promise<void> {
await page.evaluate(`window._linkStateData = {};`);
const rowSelector = `.xterm-rows > :nth-child(${row})`;
await page.hover(`${rowSelector} > :nth-child(${col})`);
await page.mouse.move(0, 0);
await page.evaluate(`window._linkStateData = {uri:''};`);
await new Promise(r => setTimeout(r, 200));
await page.mouse.move(...(await cellPos(col, row)));
await pollFor(page, `!!window._linkStateData.uri.length`, true);
}
async function evalLinkStateData(uri: string, range: any): Promise<void> {
@@ -137,3 +138,14 @@ async function evalLinkStateData(uri: string, range: any): Promise<void> {
assert.equal(data.uri, uri);
assert.deepEqual(data.range, range);
}
async function cellPos(col: number, row: number): Promise<[number, number]> {
const coords: any = await page.evaluate(`
(function() {
const rect = window.term.element.getBoundingClientRect();
const dim = term._core._renderService.dimensions;
return {left: rect.left, top: rect.top, bottom: rect.bottom, right: rect.right, width: dim.css.cell.width, height: dim.css.cell.height};
})();
`);
return [col * coords.width + coords.left + 2, row * coords.height + coords.top + 2];
}
+17 -1
View File
@@ -236,6 +236,7 @@ if (document.location.pathname === '/test') {
document.getElementById('add-decoration').addEventListener('click', addDecoration);
document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler);
document.getElementById('weblinks-test').addEventListener('click', testWeblinks);
document.getElementById('bce').addEventListener('click', coloredErase);
addVtButtons();
initImageAddonExposed();
}
@@ -1212,11 +1213,26 @@ ipv6 https://[::1]/with/some?vars=and&a#hash aaa
stop at final '.': This is a sentence with an url to http://example.com.
stop at final '?': Is this the right url http://example.com/?
stop at final '?': Maybe this one http://example.com/with?arguments=false?
`;
`;
term.write(linkExamples.split('\n').join('\r\n'));
}
function coloredErase(): void {
const data = `
Test BG-colored Erase (BCE):
The color block in the following lines should look identical.
For newly created rows at the bottom the last color should be applied
for all cells to the right.
def 41 42 43 44 45 46 47\x1b[47m
\x1b[m \x1b[41m \x1b[42m \x1b[43m \x1b[44m \x1b[45m \x1b[46m \x1b[47m
\x1b[m\x1b[5X\x1b[41m\x1b[5C\x1b[5X\x1b[42m\x1b[5C\x1b[5X\x1b[43m\x1b[5C\x1b[5X\x1b[44m\x1b[5C\x1b[5X\x1b[45m\x1b[5C\x1b[5X\x1b[46m\x1b[5C\x1b[5X\x1b[47m\x1b[5C\x1b[5X\x1b[m
`;
term.write(data.split('\n').join('\r\n'));
}
function initImageAddonExposed(): void {
const DEFAULT_OPTIONS: IImageAddonOptions = (addons.image.instance as any)._defaultOpts;
const limitStorageElement = document.querySelector<HTMLInputElement>('#image-storagelimit');
+1
View File
@@ -96,6 +96,7 @@
<dd><button id="sgr-test" title="Write text with SGR attribute">SGR test</button></dd>
<dd><button id="ansi-colors" title="Write a wide range of ansi colors">Ansi colors test</button></dd>
<dd><button id="osc-hyperlinks" title="Write some OSC 8 hyperlinks">Ansi hyperlinks test</button></dd>
<dd><button id="bce" title="Test colored erase">Colored Erase (BCE)</button></dd>
<dt>Decorations</dt>
<dd><button id="add-decoration" title="Add a decoration to the terminal">Decoration</button></dd>
+111 -57
View File
@@ -3,7 +3,8 @@
* @license MIT
*/
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 { DomRendererRowFactory, RowCss } from 'browser/renderer/dom/DomRendererRowFactory';
import { WidthCache } from 'browser/renderer/dom/WidthCache';
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';
@@ -15,6 +16,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, IInstantiationService, IOptionsService } from 'common/services/Services';
import { createStyle, IStyleSheet } from './StyleSheet';
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
const ROW_CONTAINER_CLASS = 'xterm-rows';
const FG_CLASS_PREFIX = 'xterm-fg-';
@@ -24,6 +26,7 @@ const SELECTION_CLASS = 'xterm-selection';
let nextTerminalId = 1;
/**
* A fallback renderer for when canvas is slow. This is not meant to be
* particularly fast or feature complete, more just stable and usable for when
@@ -38,7 +41,7 @@ export class DomRenderer extends Disposable implements IRenderer {
private _rowContainer: HTMLElement;
private _rowElements: HTMLElement[] = [];
private _selectionContainer: HTMLElement;
private _cellToRowElements: Int16Array[] = [];
private _widthCache: WidthCache;
public dimensions: IRenderDimensions;
@@ -91,7 +94,17 @@ export class DomRenderer extends Disposable implements IRenderer {
this._selectionContainer.remove();
this._themeStyle.dispose();
this._dimensionsStyle.dispose();
this._widthCache.dispose();
}));
this._widthCache = new WidthCache(document);
this._widthCache.setFont(
this._optionsService.rawOptions.fontFamily,
this._optionsService.rawOptions.fontSize,
this._optionsService.rawOptions.fontWeight,
this._optionsService.rawOptions.fontWeightBold
);
this._setDefaultSpacing();
}
private _updateDimensions(): void {
@@ -123,10 +136,9 @@ export class DomRenderer extends Disposable implements IRenderer {
const styles =
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
` display: inline-block;` +
` display: inline-block;` + // TODO: find workaround for inline-block (creates ~20% render penalty)
` height: 100%;` +
` vertical-align: top;` +
` width: ${this.dimensions.css.cell.width}px` +
`}`;
this._dimensionsStyle.setCss(styles);
@@ -147,6 +159,8 @@ export class DomRenderer extends Disposable implements IRenderer {
` color: ${colors.foreground.css};` +
` font-family: ${this._optionsService.rawOptions.fontFamily};` +
` font-size: ${this._optionsService.rawOptions.fontSize}px;` +
` font-kerning: none;` +
` white-space: pre` +
`}`;
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` +
@@ -154,13 +168,13 @@ export class DomRenderer extends Disposable implements IRenderer {
`}`;
// Text styles
styles +=
`${this._terminalSelector} span:not(.${BOLD_CLASS}) {` +
`${this._terminalSelector} span:not(.${RowCss.BOLD_CLASS}) {` +
` font-weight: ${this._optionsService.rawOptions.fontWeight};` +
`}` +
`${this._terminalSelector} span.${BOLD_CLASS} {` +
`${this._terminalSelector} span.${RowCss.BOLD_CLASS} {` +
` font-weight: ${this._optionsService.rawOptions.fontWeightBold};` +
`}` +
`${this._terminalSelector} span.${ITALIC_CLASS} {` +
`${this._terminalSelector} span.${RowCss.ITALIC_CLASS} {` +
` font-style: italic;` +
`}`;
// Blink animation
@@ -177,33 +191,33 @@ export class DomRenderer extends Disposable implements IRenderer {
` color: ${colors.cursorAccent.css};` +
` }` +
` 50% {` +
` background-color: ${colors.cursorAccent.css};` +
` background-color: inherit;` +
` color: ${colors.cursor.css};` +
` }` +
`}`;
// Cursor
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} ,` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS}.${CURSOR_STYLE_BAR_CLASS} ,` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS}.${CURSOR_STYLE_UNDERLINE_CLASS} ` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} ,` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} ,` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} ` +
`{` +
` outline: 1px solid ${colors.cursor.css};` +
` outline-offset: -1px;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}:not(.${CURSOR_STYLE_BLOCK_CLASS}) {` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}:not(.${RowCss.CURSOR_STYLE_BLOCK_CLASS}) {` +
` animation: blink_box_shadow` + `_` + this._terminalClass + ` 1s step-end infinite;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` animation: blink_block` + `_` + this._terminalClass + ` 1s step-end infinite;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` background-color: ${colors.cursor.css};` +
` color: ${colors.cursorAccent.css};` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BAR_CLASS} {` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${colors.cursor.css} inset;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_UNDERLINE_CLASS} {` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
` border-bottom: 1px ${colors.cursor.css};` +
` border-bottom-style: solid;` +
` height: calc(100% - 1px);` +
@@ -229,19 +243,36 @@ export class DomRenderer extends Disposable implements IRenderer {
for (const [i, c] of colors.ansi.entries()) {
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
`${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
}
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
`${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
this._themeStyle.setCss(styles);
}
/**
* default letter spacing
* Due to rounding issues in dimensions dpr calc glyph might render
* slightly too wide or too narrow. The method corrects the stacking offsets
* by applying a default letter-spacing for all chars.
* The value gets passed to the row factory to avoid setting this value again
* (render speedup is roughly 10%).
*/
private _setDefaultSpacing(): void {
// measure same char as in CharSizeService to get the base deviation
const spacing = this.dimensions.css.cell.width - this._widthCache.get('W', false, false);
this._rowContainer.style.letterSpacing = `${spacing}px`;
this._rowFactory.defaultSpacing = spacing;
}
public handleDevicePixelRatioChange(): void {
this._updateDimensions();
this._widthCache.clear();
this._setDefaultSpacing();
}
private _refreshRowElements(cols: number, rows: number): void {
@@ -264,6 +295,8 @@ export class DomRenderer extends Disposable implements IRenderer {
public handleCharSizeChanged(): void {
this._updateDimensions();
this._widthCache.clear();
this._setDefaultSpacing();
}
public handleBlur(): void {
@@ -276,10 +309,7 @@ export class DomRenderer extends Disposable implements IRenderer {
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
// Remove all selections
while (this._selectionContainer.children.length) {
this._selectionContainer.removeChild(this._selectionContainer.children[0]);
}
this._selectionContainer.replaceChildren();
this._rowFactory.handleSelectionChanged(start, end, columnSelectMode);
this.renderRows(0, this._bufferService.rows - 1);
@@ -349,6 +379,14 @@ export class DomRenderer extends Disposable implements IRenderer {
this._updateDimensions();
// Refresh CSS
this._injectCss(this._themeService.colors);
// update spacing cache
this._widthCache.setFont(
this._optionsService.rawOptions.fontFamily,
this._optionsService.rawOptions.fontSize,
this._optionsService.rawOptions.fontWeight,
this._optionsService.rawOptions.fontWeightBold
);
this._setDefaultSpacing();
}
public clear(): void {
@@ -365,19 +403,33 @@ 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;
if (!this._cellToRowElements[y] || this._cellToRowElements[y].length !== this._bufferService.cols) {
this._cellToRowElements[y] = new Int16Array(this._bufferService.cols);
const lineData = buffer.lines.get(row);
if (!rowElement || !lineData) {
break;
}
rowElement.replaceChildren(this._rowFactory.createRow(lineData!, row, row === cursorAbsoluteY, cursorStyle, cursorX, cursorBlink, this.dimensions.css.cell.width, this._bufferService.cols, this._cellToRowElements[y]));
rowElement.replaceChildren(
...this._rowFactory.createRow(
lineData,
row,
row === cursorAbsoluteY,
cursorStyle,
cursorX,
cursorBlink,
this.dimensions.css.cell.width,
this._widthCache,
-1,
-1
)
);
}
}
@@ -409,40 +461,42 @@ export class DomRenderer extends Disposable implements IRenderer {
* - (async) link handler race condition: new buffer metrics, but still on old render state
* - (async) render update: brings term metrics and render state back in sync
*/
// clip coords into viewport
if (y < 0) x = 0;
if (y2 < 0) x2 = 0;
// avoid out-of-sync y-values, simply clamp into valid area
const maxY = this._cellToRowElements.length - 1;
const maxY = this._bufferService.rows - 1;
y = Math.max(Math.min(y, maxY), 0);
y2 = Math.max(Math.min(y2, maxY), 0);
const elemY = this._cellToRowElements[y];
const elemY2 = this._cellToRowElements[y2];
if (x >= elemY.length || x2 >= elemY2.length) {
// avoid out-of-sync x-values
// simply exit early, gets fixed by the next render update
return;
}
x = elemY[x];
x2 = elemY2[x2];
if (x === -1 || x2 === -1) {
return;
}
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;
while (x !== x2 || y !== y2) {
const row = this._rowElements[y];
if (!row) {
return;
}
const span = row.children[x] as HTMLElement;
if (span) {
span.style.textDecoration = enabled ? 'underline' : 'none';
}
if (++x >= cols) {
x = 0;
y++;
// refresh rows within link range
for (let i = y; i <= y2; ++i) {
const row = i + buffer.ydisp;
const rowElement = this._rowElements[i];
const bufferline = buffer.lines.get(row);
if (!rowElement || !bufferline) {
break;
}
rowElement.replaceChildren(
...this._rowFactory.createRow(
bufferline,
row,
row === cursorAbsoluteY,
cursorStyle,
cursorX,
cursorBlink,
this.dimensions.css.cell.width,
this._widthCache,
enabled ? (i === y ? x : 0) : -1,
enabled ? ((i === y2 ? x2 : cols) - 1) : -1
)
);
}
}
}
@@ -11,10 +11,12 @@ 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 { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
const EMPTY_WIDTH = new TestWidthCache(new jsdom.JSDOM('').window.document);
const EMPTY_ELEM_MAPPING = new Int16Array(1000);
describe('DomRendererRowFactory', () => {
let dom: jsdom.JSDOM;
@@ -37,54 +39,46 @@ describe('DomRendererRowFactory', () => {
describe('createRow', () => {
it('should not create anything for an empty row', () => {
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
''
);
});
it('should set correct attributes for double width characters', () => {
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]));
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
'<span style="width: 10px;">語</span>'
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>語</span>'
);
});
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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, true, style, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-cursor xterm-cursor-${style}"> </span>`
);
}
});
it('should add class for cursor blink', () => {
const fragment = rowFactory.createRow(lineData, 0, true, 'block', 0, true, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, true, 'block', 0, true, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-cursor xterm-cursor-blink xterm-cursor-block"> </span>`
);
});
it('should not render cells that go beyond the terminal\'s columns', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 1, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
'<span>a</span>'
);
});
describe('attributes', () => {
it('should add class for bold', () => {
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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bold">a</span>'
);
});
@@ -93,8 +87,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-italic">a</span>'
);
});
@@ -103,8 +97,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-dim">a</span>'
);
});
@@ -116,8 +110,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-1">a</span>'
);
});
@@ -127,8 +121,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-2">a</span>'
);
});
@@ -138,8 +132,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-3">a</span>'
);
});
@@ -149,8 +143,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-4">a</span>'
);
});
@@ -160,8 +154,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-5">a</span>'
);
});
@@ -171,8 +165,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-overline">a</span>'
);
});
@@ -181,8 +175,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-strikethrough">a</span>'
);
});
@@ -194,8 +188,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-fg-${i}">a</span>`
);
}
@@ -208,8 +202,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-bg-${i}">a</span>`
);
}
@@ -220,8 +214,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-2 xterm-fg-1">a</span>'
);
});
@@ -231,8 +225,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-257 xterm-fg-1">a</span>'
);
});
@@ -241,8 +235,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1 xterm-fg-257">a</span>'
);
});
@@ -254,8 +248,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-bold xterm-fg-${i + 8}">a</span>`
);
}
@@ -266,8 +260,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span style="background-color:#040506;color:#010203;">a</span>'
);
});
@@ -277,8 +271,8 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span style="background-color:#010203;color:#040506;">a</span>'
);
});
@@ -289,25 +283,179 @@ 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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>a</span><span class="xterm-decoration-top">b</span>'
);
});
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, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-decoration-top"> </span><span class="xterm-decoration-top">a</span>'
);
});
});
});
function getFragmentHtml(fragment: DocumentFragment): string {
describe('createRow with merged spans', () => {
// for test purpose assume all in codepoints 0..255 are merging
// const ALL_MERGING = new Uint8Array(FontMetrics.MAX);
beforeEach(() => {
lineData = createEmptyLineData(10);
});
it('should not create anything for an empty row', () => {
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
''
);
});
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 spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>abc</span>'
);
});
it('should not merge codepoints with different spacing', () => {
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)]));
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>a</span><span style="letter-spacing: 3px;">€</span><span>c</span>'
);
});
it('should not merge on FG change', () => {
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 spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-fg-1">aa</span><span class="xterm-fg-2">bb</span>'
);
});
it('should not merge cursor cell', () => {
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, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, true, undefined, 2, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>aa</span><span class="xterm-cursor xterm-cursor-block">X</span><span>bb</span>'
);
});
it('should handle BCE correctly', () => {
const nullCell = lineData.loadCell(0, new CellData());
nullCell.bg = Attributes.CM_P16 | 1;
lineData.setCell(2, nullCell);
nullCell.bg = Attributes.CM_P16 | 2;
lineData.setCell(3, nullCell);
lineData.setCell(4, nullCell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span> </span><span class="xterm-bg-1"> </span><span class="xterm-bg-2"> </span>'
);
});
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 spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span>'
);
lineData.setCell(1, nullCell);
spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span>'
);
lineData.setCell(2, nullCell);
lineData.setCell(3, nullCell);
spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span>'
);
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span><span>a</span>'
);
});
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),
'<span>a</span><span style="letter-spacing: 3px;">€</span><span>c語</span><span style="letter-spacing: -2px;">𝄞</span>'
);
});
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),
'<span>aa</span><span style="text-decoration: underline;">xxx</span><span>bb</span>'
);
});
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),
'<span>aa</span><span style="text-decoration: underline;">x x</span>'
);
});
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),
'<span style="text-decoration: underline;">aaaaaaaaaa</span>'
);
});
});
function extractHtml(spans: HTMLSpanElement[]): string {
const element = dom.window.document.createElement('div');
element.appendChild(fragment);
element.replaceChildren(...spans);
return element.innerHTML;
}
+163 -84
View File
@@ -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 } 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,18 +13,23 @@ 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 { WidthCache } from 'browser/renderer/dom/WidthCache';
export const enum RowCss {
BOLD_CLASS = 'xterm-bold',
DIM_CLASS = 'xterm-dim',
ITALIC_CLASS = 'xterm-italic',
UNDERLINE_CLASS = 'xterm-underline',
OVERLINE_CLASS = 'xterm-overline',
STRIKETHROUGH_CLASS = 'xterm-strikethrough',
CURSOR_CLASS = 'xterm-cursor',
CURSOR_BLINK_CLASS = 'xterm-cursor-blink',
CURSOR_STYLE_BLOCK_CLASS = 'xterm-cursor-block',
CURSOR_STYLE_BAR_CLASS = 'xterm-cursor-bar',
CURSOR_STYLE_UNDERLINE_CLASS = 'xterm-cursor-underline'
}
export const BOLD_CLASS = 'xterm-bold';
export const DIM_CLASS = 'xterm-dim';
export const ITALIC_CLASS = 'xterm-italic';
export const UNDERLINE_CLASS = 'xterm-underline';
export const OVERLINE_CLASS = 'xterm-overline';
export const STRIKETHROUGH_CLASS = 'xterm-strikethrough';
export const CURSOR_CLASS = 'xterm-cursor';
export const CURSOR_BLINK_CLASS = 'xterm-cursor-blink';
export const CURSOR_STYLE_BLOCK_CLASS = 'xterm-cursor-block';
export const CURSOR_STYLE_BAR_CLASS = 'xterm-cursor-bar';
export const CURSOR_STYLE_UNDERLINE_CLASS = 'xterm-cursor-underline';
export class DomRendererRowFactory {
private _workCell: CellData = new CellData();
@@ -33,6 +38,8 @@ export class DomRendererRowFactory {
private _selectionEnd: [number, number] | undefined;
private _columnSelectMode: boolean = false;
public defaultSpacing = 0;
constructor(
private readonly _document: Document,
@ICharacterJoinerService private readonly _characterJoinerService: ICharacterJoinerService,
@@ -49,39 +56,47 @@ export class DomRendererRowFactory {
this._columnSelectMode = columnSelectMode;
}
public createRow(lineData: IBufferLine, row: number, isCursorRow: boolean, cursorStyle: string | undefined, cursorX: number, cursorBlink: boolean, cellWidth: number, cols: number, cellMap: Int16Array): DocumentFragment {
// NOTE: `cellMap` maps cell positions to a span element index in a row.
// All positions should be updated, even skipped ones after wide chars or left overs at the end,
// otherwise the mouse hover logic might mark the wrong elements as underlined.
const fragment = this._document.createDocumentFragment();
public createRow(
lineData: IBufferLine,
row: number,
isCursorRow: boolean,
cursorStyle: string | undefined,
cursorX: number,
cursorBlink: boolean,
cellWidth: number,
widthCache: WidthCache,
linkStart: number,
linkEnd: number
): HTMLSpanElement[] {
const elements: HTMLSpanElement[] = [];
const joinedRanges = this._characterJoinerService.getJoinedCharacters(row);
// 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
// properly support reflow and disallow data to go beyond the right-side of
// the viewport).
let lineLength = 0;
for (let x = Math.min(lineData.length, cols) - 1; x >= 0; x--) {
if (lineData.loadCell(x, this._workCell).getCode() !== NULL_CELL_CODE || (isCursorRow && x === cursorX)) {
lineLength = x + 1;
break;
}
const colors = this._themeService.colors;
let lineLength = lineData.getNoBgTrimmedLength();
if (isCursorRow && lineLength < cursorX + 1) {
lineLength = cursorX + 1;
}
const colors = this._themeService.colors;
let elemIndex = -1;
let charElement: HTMLSpanElement | undefined;
let cellAmount = 0;
let text = '';
let oldBg = 0;
let oldFg = 0;
let oldExt = 0;
let oldLinkHover: number | boolean = false;
let oldSpacing = 0;
let spacing = 0;
const classes: string[] = [];
let x = 0;
for (; x < lineLength; x++) {
const hasHover = linkStart !== -1 && linkEnd !== -1;
for (let x = 0; x < lineLength; x++) {
lineData.loadCell(x, this._workCell);
let width = this._workCell.getWidth();
// The character to the left is a wide character, drawing is owned by the char at x-1
// still have to update cellMap with current element index
if (width === 0) {
cellMap[x] = elemIndex;
continue;
}
@@ -112,16 +127,66 @@ export class DomRendererRowFactory {
width = cell.getWidth();
}
const charElement = this._document.createElement('span');
if (width > 1) {
charElement.style.width = `${cellWidth * width}px`;
const isInSelection = this._isCellInSelection(x, row);
const isCursorCell = isCursorRow && x === cursorX;
const isLinkHover = hasHover && x >= linkStart && x <= linkEnd;
// get chars to render for this cell
let chars = cell.getChars() || WHITESPACE_CELL_CHAR;
if (chars === ' ' && (cell.isUnderline() || cell.isOverline())) {
chars = '\xa0';
}
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';
// lookup char render width and calc spacing
spacing = width * cellWidth - widthCache.get(chars, cell.isBold(), cell.isItalic());
if (!charElement) {
charElement = this._document.createElement('span');
} else {
/**
* chars can only be merged on existing span if:
* - existing span only contains mergeable chars (cellAmount != 0)
* - fg/bg/ul did not change
* - char not part of a selection
* - underline from hover state did not change
* - cell content renders to same letter-spacing
* - cell is not cursor
*/
if (
cellAmount
&& cell.bg === oldBg && cell.fg === oldFg && cell.extended.ext === oldExt
&& !isInSelection
&& isLinkHover === oldLinkHover
&& spacing === oldSpacing
&& !isCursorCell
&& !isJoined
) {
// no span alterations, thus only account chars skipping all code below
text += chars;
cellAmount++;
continue;
} else {
/**
* cannot merge:
* - apply left-over text to old span
* - create new span, reset state holders cellAmount & text
*/
if (cellAmount) {
charElement.textContent = text;
}
charElement = this._document.createElement('span');
cellAmount = 0;
text = '';
}
}
// preserve conditions for next merger eval round
oldBg = cell.bg;
oldFg = cell.fg;
oldExt = cell.extended.ext;
oldLinkHover = isLinkHover;
oldSpacing = spacing;
if (isJoined) {
// 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
@@ -130,48 +195,42 @@ export class DomRendererRowFactory {
}
}
if (!this._coreService.isCursorHidden && isCursorRow && x === cursorX) {
charElement.classList.add(CURSOR_CLASS);
if (!this._coreService.isCursorHidden && isCursorCell) {
classes.push(RowCss.CURSOR_CLASS);
if (cursorBlink) {
charElement.classList.add(CURSOR_BLINK_CLASS);
}
switch (cursorStyle) {
case 'bar':
charElement.classList.add(CURSOR_STYLE_BAR_CLASS);
break;
case 'underline':
charElement.classList.add(CURSOR_STYLE_UNDERLINE_CLASS);
break;
default:
charElement.classList.add(CURSOR_STYLE_BLOCK_CLASS);
break;
classes.push(RowCss.CURSOR_BLINK_CLASS);
}
classes.push(
cursorStyle === 'bar'
? RowCss.CURSOR_STYLE_BAR_CLASS
: cursorStyle === 'underline'
? RowCss.CURSOR_STYLE_UNDERLINE_CLASS
: RowCss.CURSOR_STYLE_BLOCK_CLASS
);
}
if (cell.isBold()) {
charElement.classList.add(BOLD_CLASS);
classes.push(RowCss.BOLD_CLASS);
}
if (cell.isItalic()) {
charElement.classList.add(ITALIC_CLASS);
classes.push(RowCss.ITALIC_CLASS);
}
if (cell.isDim()) {
charElement.classList.add(DIM_CLASS);
classes.push(RowCss.DIM_CLASS);
}
if (cell.isInvisible()) {
charElement.textContent = WHITESPACE_CELL_CHAR;
text = WHITESPACE_CELL_CHAR;
} else {
charElement.textContent = cell.getChars() || WHITESPACE_CELL_CHAR;
text = cell.getChars() || WHITESPACE_CELL_CHAR;
}
if (cell.isUnderline()) {
charElement.classList.add(`${UNDERLINE_CLASS}-${cell.extended.underlineStyle}`);
if (charElement.textContent === ' ') {
charElement.textContent = '\xa0'; // = &nbsp;
classes.push(`${RowCss.UNDERLINE_CLASS}-${cell.extended.underlineStyle}`);
if (text === ' ') {
text = '\xa0'; // = &nbsp;
}
if (!cell.isUnderlineColorDefault()) {
if (cell.isUnderlineColorRGB()) {
@@ -187,14 +246,19 @@ export class DomRendererRowFactory {
}
if (cell.isOverline()) {
charElement.classList.add(OVERLINE_CLASS);
if (charElement.textContent === ' ') {
charElement.textContent = '\xa0'; // = &nbsp;
classes.push(RowCss.OVERLINE_CLASS);
if (text === ' ') {
text = '\xa0'; // = &nbsp;
}
}
if (cell.isStrikethrough()) {
charElement.classList.add(STRIKETHROUGH_CLASS);
classes.push(RowCss.STRIKETHROUGH_CLASS);
}
// apply link hover underline late, effectively overrides any previous text-decoration settings
if (isLinkHover) {
charElement.style.textDecoration = 'underline';
}
let fg = cell.getFgColor();
@@ -234,7 +298,6 @@ export class DomRendererRowFactory {
});
// Apply selection foreground if applicable
const isInSelection = this._isCellInSelection(x, row);
if (!isTop) {
if (colors.selectionForeground && isInSelection) {
fgColorMode = Attributes.CM_RGB;
@@ -252,7 +315,7 @@ export class DomRendererRowFactory {
// If it's a top decoration, render above the selection
if (isTop) {
charElement.classList.add(`xterm-decoration-top`);
classes.push('xterm-decoration-top');
}
// Background
@@ -261,7 +324,7 @@ export class DomRendererRowFactory {
case Attributes.CM_P16:
case Attributes.CM_P256:
resolvedBg = colors.ansi[bg];
charElement.classList.add(`xterm-bg-${bg}`);
classes.push(`xterm-bg-${bg}`);
break;
case Attributes.CM_RGB:
resolvedBg = rgba.toColor(bg >> 16, bg >> 8 & 0xFF, bg & 0xFF);
@@ -271,7 +334,7 @@ export class DomRendererRowFactory {
default:
if (isInverse) {
resolvedBg = colors.foreground;
charElement.classList.add(`xterm-bg-${INVERTED_DEFAULT_COLOR}`);
classes.push(`xterm-bg-${INVERTED_DEFAULT_COLOR}`);
} else {
resolvedBg = colors.background;
}
@@ -292,7 +355,7 @@ export class DomRendererRowFactory {
fg += 8;
}
if (!this._applyMinimumContrast(charElement, resolvedBg, colors.ansi[fg], cell, bgOverride, undefined)) {
charElement.classList.add(`xterm-fg-${fg}`);
classes.push(`xterm-fg-${fg}`);
}
break;
case Attributes.CM_RGB:
@@ -309,24 +372,40 @@ export class DomRendererRowFactory {
default:
if (!this._applyMinimumContrast(charElement, resolvedBg, colors.foreground, cell, bgOverride, undefined)) {
if (isInverse) {
charElement.classList.add(`xterm-fg-${INVERTED_DEFAULT_COLOR}`);
classes.push(`xterm-fg-${INVERTED_DEFAULT_COLOR}`);
}
}
}
fragment.appendChild(charElement);
cellMap[x] = ++elemIndex;
// apply CSS classes
// slightly faster than using classList by omitting
// checks for doubled entries (code above should not have doublets)
if (classes.length) {
charElement.className = classes.join(' ');
classes.length = 0;
}
// exclude conditions for cell merging - never merge these
if (!isCursorCell && !isInSelection && !isJoined) {
cellAmount++;
} else {
charElement.textContent = text;
}
// apply letter-spacing rule
if (spacing !== this.defaultSpacing) {
charElement.style.letterSpacing = `${spacing}px`;
}
elements.push(charElement);
x = lastCharX;
}
// since the loop above might exit early not handling all cells,
// also set remaining cell positions to last element index
if (x < cols - 1) {
cellMap.subarray(x).fill(++elemIndex);
// postfix text of last merged span
if (charElement && cellAmount) {
charElement.textContent = text;
}
return fragment;
return elements;
}
private _applyMinimumContrast(element: HTMLElement, bg: IColor, fg: IColor, cell: ICellData, bgOverride: IColor | undefined, fgOverride: IColor | undefined): boolean {
+127
View File
@@ -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<string, number> | 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);
});
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from 'common/Types';
import { FontWeight } from 'common/services/Services';
export const enum WidthCacheSettings {
/** sentinel for unset values in flat cache */
FLAT_UNSET = -9999,
/** size of flat cache, size-1 equals highest codepoint handled by flat */
FLAT_SIZE = 256,
/** char repeat for measuring */
REPEAT = 32
}
const enum FontVariant {
REGULAR = 0,
BOLD = 1,
ITALIC = 2,
BOLD_ITALIC = 3
}
export class WidthCache implements IDisposable {
// flat cache for regular variant up to CacheSettings.FLAT_SIZE
// NOTE: ~4x faster access than holey (serving >>80% of terminal content)
// 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.
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
protected _holey: Map<string, number> | undefined;
private _font = '';
private _fontSize = 0;
private _weight: FontWeight = 'normal';
private _weightBold: FontWeight = 'bold';
private _container: HTMLDivElement;
private _measureElements: HTMLSpanElement[] = [];
constructor(_document: Document) {
this._container = _document.createElement('div');
this._container.style.position = 'absolute';
this._container.style.top = '-50000px';
this._container.style.width = '50000px';
// SP should stack in spans
this._container.style.whiteSpace = 'pre';
// avoid undercuts in non-monospace fonts from kerning
this._container.style.fontKerning = 'none';
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';
// NOTE: must be in order of FontVariant
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(); // remove elements from DOM
this._measureElements.length = 0; // release element refs
this._holey = undefined; // free cache memory via GC
}
/**
* Clear the width cache.
*/
public clear(): void {
this._flat.fill(WidthCacheSettings.FLAT_UNSET);
// .clear() has some overhead, re-assign instead (>3 times faster)
this._holey = new Map<string, number>();
}
/**
* Set the font for measuring.
* Must be called for any changes on font settings.
* Also clears the cache.
*/
public setFont(font: string, fontSize: number, weight: FontWeight, weightBold: FontWeight): void {
// skip if nothing changed
if (font === this._font
&& fontSize === this._fontSize
&& weight === this._weight
&& weightBold === this._weightBold
) {
return;
}
this._font = font;
this._fontSize = fontSize;
this._weight = weight;
this._weightBold = weightBold;
this._container.style.fontFamily = this._font;
this._container.style.fontSize = `${this._fontSize}px`;
this._measureElements[FontVariant.REGULAR].style.fontWeight = `${weight}`;
this._measureElements[FontVariant.BOLD].style.fontWeight = `${weightBold}`;
this._measureElements[FontVariant.ITALIC].style.fontWeight = `${weight}`;
this._measureElements[FontVariant.BOLD_ITALIC].style.fontWeight = `${weightBold}`;
this.clear();
}
/**
* Get the render width for cell content `c` with current font settings.
* `variant` denotes the font variant to be used.
*/
public get(c: string, bold: boolean | number, italic: boolean | number): number {
let cp = 0;
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));
}
let key = c;
if (bold) key += 'B';
if (italic) key += 'I';
let width = this._holey!.get(key);
if (width === undefined) {
let variant = 0;
if (bold) variant |= FontVariant.BOLD;
if (italic) variant |= FontVariant.ITALIC;
width = this._measure(c, variant);
this._holey!.set(key, width);
}
return width;
}
protected _measure(c: string, variant: FontVariant): number {
const el = this._measureElements[variant];
el.textContent = c.repeat(WidthCacheSettings.REPEAT);
return el.offsetWidth / WidthCacheSettings.REPEAT;
}
}
+10 -3
View File
@@ -7,7 +7,12 @@ import { IOptionsService } from 'common/services/Services';
import { EventEmitter } from 'common/EventEmitter';
import { ICharSizeService } from 'browser/services/Services';
import { Disposable } from 'common/Lifecycle';
import { ITerminalOptions } from 'common/Types';
const enum MeasureSettings {
REPEAT = 32
}
export class CharSizeService extends Disposable implements ICharSizeService {
public serviceBrand: undefined;
@@ -67,8 +72,10 @@ class DomMeasureStrategy implements IMeasureStrategy {
) {
this._measureElement = this._document.createElement('span');
this._measureElement.classList.add('xterm-char-measure-element');
this._measureElement.textContent = 'W';
this._measureElement.textContent = 'W'.repeat(MeasureSettings.REPEAT);
this._measureElement.setAttribute('aria-hidden', 'true');
this._measureElement.style.whiteSpace = 'pre';
this._measureElement.style.fontKerning = 'none';
this._parentElement.appendChild(this._measureElement);
}
@@ -85,7 +92,7 @@ class DomMeasureStrategy implements IMeasureStrategy {
// If values are 0 then the element is likely currently display:none, in which case we should
// retain the previous value.
if (geometry.width !== 0 && geometry.height !== 0) {
this._result.width = geometry.width;
this._result.width = geometry.width / MeasureSettings.REPEAT;
this._result.height = Math.ceil(geometry.height);
}
+1
View File
@@ -242,6 +242,7 @@ export interface IBufferLine {
copyFrom(line: IBufferLine): void;
clone(): IBufferLine;
getTrimmedLength(): number;
getNoBgTrimmedLength(): number;
translateToString(trimRight?: boolean, startCol?: number, endCol?: number): string;
/* direct access to cell attrs */
+10 -1
View File
@@ -5,7 +5,7 @@
import { CharData, IBufferLine, ICellData, IAttributeData, IExtendedAttrs } from 'common/Types';
import { stringFromCodePoint } from 'common/input/TextDecoder';
import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Content, BgFlags, FgFlags } from 'common/buffer/Constants';
import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Content, BgFlags, FgFlags, Attributes } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
@@ -463,6 +463,15 @@ export class BufferLine implements IBufferLine {
return 0;
}
public getNoBgTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) {
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
const srcData = src._data;
if (applyInReverse) {