diff --git a/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts b/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts index 8b6008a5..366d8160 100644 --- a/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts +++ b/addons/xterm-addon-web-links/test/WebLinksAddon.api.ts @@ -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 { `\\'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 { - 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 { 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 { - 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 { @@ -137,3 +138,14 @@ async function evalLinkStateData(uri: string, range: any): Promise { 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]; +} diff --git a/demo/client.ts b/demo/client.ts index 5dcff9ab..a7c70e5b 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -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('#image-storagelimit'); diff --git a/demo/index.html b/demo/index.html index 458a12c8..0e22d2d2 100644 --- a/demo/index.html +++ b/demo/index.html @@ -96,6 +96,7 @@
+
Decorations
diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 2423ef51..cd854696 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -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 + ) + ); } } } diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 0c2fb79d..455a401d 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -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), - '' + const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1); + assert.equal(extractHtml(spans), + '' ); }); 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), ` ` ); } }); 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), ` ` ); }); - 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), - 'a' - ); - }); - 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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), `a` ); } @@ -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), `a` ); } @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), `a` ); } @@ -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), 'a' ); }); @@ -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), 'a' ); }); @@ -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), 'ab' ); }); 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), ' a' ); }); }); }); - 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), + 'abc' + ); + }); + + 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), + 'ac' + ); + }); + + 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), + 'aabb' + ); + }); + + 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), + 'aaXbb' + ); + }); + + 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), + ' ' + ); + }); + + 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), + ' ' + ); + lineData.setCell(1, nullCell); + spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1); + assert.equal(extractHtml(spans), + ' ' + ); + 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), + ' ' + ); + 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), + ' a' + ); + }); + + 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), + 'ac語𝄞' + ); + }); + + 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 { const element = dom.window.document.createElement('div'); - element.appendChild(fragment); + element.replaceChildren(...spans); return element.innerHTML; } diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index a0b44d2c..3f9765f0 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 } 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'; // =   + classes.push(`${RowCss.UNDERLINE_CLASS}-${cell.extended.underlineStyle}`); + if (text === ' ') { + text = '\xa0'; // =   } 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'; // =   + classes.push(RowCss.OVERLINE_CLASS); + if (text === ' ') { + text = '\xa0'; // =   } } 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 { 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 new file mode 100644 index 00000000..01b3f658 --- /dev/null +++ b/src/browser/renderer/dom/WidthCache.ts @@ -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 | 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(); + } + + /** + * 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; + } +} diff --git a/src/browser/services/CharSizeService.ts b/src/browser/services/CharSizeService.ts index a4497ed4..8e2a7019 100644 --- a/src/browser/services/CharSizeService.ts +++ b/src/browser/services/CharSizeService.ts @@ -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); } diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 965104d5..8b978673 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -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 */ diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index d5f43844..c3d127cd 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -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) {