diff --git a/addons/addon-image/README.md b/addons/addon-image/README.md index 3b636d922..8ea6a1795 100644 --- a/addons/addon-image/README.md +++ b/addons/addon-image/README.md @@ -98,7 +98,7 @@ terminal.loadAddon(imageAddon); Set by default, change it with `{iipSupport: true}`. The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions): - - Supported formats: PNG, JPEG, GIF and QOI + - Supported formats: PNG, JPEG, GIF, QOI, WEBP and AVIF - No animation support. - Image type hinting is not supported (always deducted from data header). - File download is not supported. @@ -112,6 +112,13 @@ terminal.loadAddon(imageAddon); - ReportCellSize sequence is supported. - Hi-res display support is currently limited (image gets blurred). +- **Note on IIP image format support** + For all image formats except QOI the addon only does the dimension extraction and leaves + the image decoding to the underlying browser engine. Note that some formats are implemented + and enabled with different constraints by browser vendors, especially newer formats + (e.g. under Firefox you might need to change the setting _image.avif.compliance_strictness_ to 0 + to get broader AVIF support). + - **Kitty Graphics Support (TGP)** Set by default, change it with `{kittySupport: true}`. diff --git a/addons/addon-image/fixture/testimages/1.webp b/addons/addon-image/fixture/testimages/1.webp new file mode 100644 index 000000000..122741b60 Binary files /dev/null and b/addons/addon-image/fixture/testimages/1.webp differ diff --git a/addons/addon-image/fixture/testimages/1_webp_a.webp b/addons/addon-image/fixture/testimages/1_webp_a.webp new file mode 100644 index 000000000..fd78f9335 Binary files /dev/null and b/addons/addon-image/fixture/testimages/1_webp_a.webp differ diff --git a/addons/addon-image/fixture/testimages/1_webp_ll.webp b/addons/addon-image/fixture/testimages/1_webp_ll.webp new file mode 100644 index 000000000..1953c59b3 Binary files /dev/null and b/addons/addon-image/fixture/testimages/1_webp_ll.webp differ diff --git a/addons/addon-image/fixture/testimages/2.webp b/addons/addon-image/fixture/testimages/2.webp new file mode 100644 index 000000000..c833c5d4a Binary files /dev/null and b/addons/addon-image/fixture/testimages/2.webp differ diff --git a/addons/addon-image/fixture/testimages/2_webp_a.webp b/addons/addon-image/fixture/testimages/2_webp_a.webp new file mode 100644 index 000000000..0a8560a77 Binary files /dev/null and b/addons/addon-image/fixture/testimages/2_webp_a.webp differ diff --git a/addons/addon-image/fixture/testimages/2_webp_ll.webp b/addons/addon-image/fixture/testimages/2_webp_ll.webp new file mode 100644 index 000000000..7c5de8033 Binary files /dev/null and b/addons/addon-image/fixture/testimages/2_webp_ll.webp differ diff --git a/addons/addon-image/fixture/testimages/3.webp b/addons/addon-image/fixture/testimages/3.webp new file mode 100644 index 000000000..e1cf65837 Binary files /dev/null and b/addons/addon-image/fixture/testimages/3.webp differ diff --git a/addons/addon-image/fixture/testimages/3_webp_a.webp b/addons/addon-image/fixture/testimages/3_webp_a.webp new file mode 100644 index 000000000..b87117bf2 Binary files /dev/null and b/addons/addon-image/fixture/testimages/3_webp_a.webp differ diff --git a/addons/addon-image/fixture/testimages/3_webp_ll.webp b/addons/addon-image/fixture/testimages/3_webp_ll.webp new file mode 100644 index 000000000..32ba54bca Binary files /dev/null and b/addons/addon-image/fixture/testimages/3_webp_ll.webp differ diff --git a/addons/addon-image/fixture/testimages/4.webp b/addons/addon-image/fixture/testimages/4.webp new file mode 100644 index 000000000..a608fc85c Binary files /dev/null and b/addons/addon-image/fixture/testimages/4.webp differ diff --git a/addons/addon-image/fixture/testimages/4_webp_a.webp b/addons/addon-image/fixture/testimages/4_webp_a.webp new file mode 100644 index 000000000..d4fe93206 Binary files /dev/null and b/addons/addon-image/fixture/testimages/4_webp_a.webp differ diff --git a/addons/addon-image/fixture/testimages/4_webp_ll.webp b/addons/addon-image/fixture/testimages/4_webp_ll.webp new file mode 100644 index 000000000..311feac36 Binary files /dev/null and b/addons/addon-image/fixture/testimages/4_webp_ll.webp differ diff --git a/addons/addon-image/fixture/testimages/5.webp b/addons/addon-image/fixture/testimages/5.webp new file mode 100644 index 000000000..89e6075fd Binary files /dev/null and b/addons/addon-image/fixture/testimages/5.webp differ diff --git a/addons/addon-image/fixture/testimages/5_webp_a.webp b/addons/addon-image/fixture/testimages/5_webp_a.webp new file mode 100644 index 000000000..c29e6758d Binary files /dev/null and b/addons/addon-image/fixture/testimages/5_webp_a.webp differ diff --git a/addons/addon-image/fixture/testimages/5_webp_ll.webp b/addons/addon-image/fixture/testimages/5_webp_ll.webp new file mode 100644 index 000000000..172ebaee9 Binary files /dev/null and b/addons/addon-image/fixture/testimages/5_webp_ll.webp differ diff --git a/addons/addon-image/fixture/testimages/fox.profile2.12bpc.yuv444.odd-width.odd-height.avif b/addons/addon-image/fixture/testimages/fox.profile2.12bpc.yuv444.odd-width.odd-height.avif new file mode 100644 index 000000000..bfc513dcf Binary files /dev/null and b/addons/addon-image/fixture/testimages/fox.profile2.12bpc.yuv444.odd-width.odd-height.avif differ diff --git a/addons/addon-image/fixture/testimages/hato.profile0.8bpc.yuv420.avif b/addons/addon-image/fixture/testimages/hato.profile0.8bpc.yuv420.avif new file mode 100644 index 000000000..d37c2734b Binary files /dev/null and b/addons/addon-image/fixture/testimages/hato.profile0.8bpc.yuv420.avif differ diff --git a/addons/addon-image/fixture/testimages/kimono.crop.avif b/addons/addon-image/fixture/testimages/kimono.crop.avif new file mode 100644 index 000000000..777813e69 Binary files /dev/null and b/addons/addon-image/fixture/testimages/kimono.crop.avif differ diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts index 56f5cdf35..559b90741 100644 --- a/addons/addon-image/src/IIPHandler.ts +++ b/addons/addon-image/src/IIPHandler.ts @@ -39,7 +39,6 @@ export class IIPHandler implements IOscHandler, IResetHandler { private _header: IHeaderFields = DEFAULT_HEADER; private _dec: Base64Decoder; private _qoiDec: QoiDecoder; - private _metrics = UNSUPPORTED_TYPE; private _isMultipart = false; private _abortMulti = false; @@ -63,7 +62,6 @@ export class IIPHandler implements IOscHandler, IResetHandler { public start(): void { this._aborted = false; - this._metrics = UNSUPPORTED_TYPE; this._hp.reset(); } @@ -120,14 +118,14 @@ export class IIPHandler implements IOscHandler, IResetHandler { if (seqType === SequenceType.REPORTCELLSIZE) { // OSC 1337 ; ReportCellSize=[height];[width];[scale] ST - let width = CELL_SIZE_DEFAULT.width; - let height = CELL_SIZE_DEFAULT.height; + let w = CELL_SIZE_DEFAULT.width; + let h = CELL_SIZE_DEFAULT.height; if (this._renderer.dimensions) { - width = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols; - height = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows; + w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols; + h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows; } const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1; - const report = `\x1b]1337;ReportCellSize=${height.toFixed(3)};${width.toFixed(3)};${scale.toFixed(3)}\x1b\\`; + const report = `\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\x1b\\`; this._coreTerminal.input(report, false); return true; } @@ -154,17 +152,24 @@ export class IIPHandler implements IOscHandler, IResetHandler { // early exit condition chain let cond: number | boolean; + let metrics = UNSUPPORTED_TYPE; if (cond = success) { if (cond = !this._dec.end()) { - this._metrics = imageType(this._dec.data8); - if (cond = this._metrics.mime !== 'unsupported') { - w = this._metrics.width; - h = this._metrics.height; + metrics = imageType(this._dec.data8); + if (cond = metrics.mime !== 'unsupported') { + w = metrics.width; + h = metrics.height; if (cond = w && h && w * h < this._opts.pixelLimit) { [w, h] = this._resize(w, h).map(Math.floor); cond = w && h && w * h < this._opts.pixelLimit; + } else { + console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`); } + } else { + console.warn('IIP: unsupported image type'); } + } else { + console.warn('IIP: error during BASE64 decoding'); } } if (!cond) { @@ -173,7 +178,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { } let blob: Blob | ImageData; - if (this._metrics.mime === 'image/qoi') { + if (metrics.mime === 'image/qoi') { const data = this._qoiDec.decode(this._dec.data8); blob = new ImageData( new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), @@ -190,13 +195,17 @@ export class IIPHandler implements IOscHandler, IResetHandler { return true; } } else { - blob = new Blob([this._dec.data8], { type: this._metrics.mime }); + blob = new Blob([this._dec.data8], { type: metrics.mime }); } this._dec.release(); return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h }) .then(bm => { this._storage.addImage(bm); return true; + }) + .catch(e => { + console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e); + return true; }); } diff --git a/addons/addon-image/src/IIPMetrics.test.ts b/addons/addon-image/src/IIPMetrics.test.ts index cc381e5cb..4df320a2c 100644 --- a/addons/addon-image/src/IIPMetrics.test.ts +++ b/addons/addon-image/src/IIPMetrics.test.ts @@ -32,6 +32,28 @@ const TEST_IMAGES: [string, IMetrics][] = [ ['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }], ['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }], ['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }], + // VP8 + ['1.webp', { mime: 'image/webp', width: 550, height: 368 }], + ['2.webp', { mime: 'image/webp', width: 550, height: 404 }], + ['3.webp', { mime: 'image/webp', width: 1280, height: 720 }], + ['4.webp', { mime: 'image/webp', width: 1024, height: 772 }], + ['5.webp', { mime: 'image/webp', width: 1024, height: 752 }], + // VP8X + ['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301 }], + ['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395 }], + ['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600 }], + ['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163 }], + ['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300 }], + // VP8L + ['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301 }], + ['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395 }], + ['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600 }], + ['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163 }], + ['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300 }], + // some AVIF test images + ['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799 }], + ['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048 }], + ['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024 }], ]; diff --git a/addons/addon-image/src/IIPMetrics.ts b/addons/addon-image/src/IIPMetrics.ts index b377cf061..217a7fc93 100644 --- a/addons/addon-image/src/IIPMetrics.ts +++ b/addons/addon-image/src/IIPMetrics.ts @@ -4,7 +4,7 @@ */ -export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | ''; +export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | ''; export interface IMetrics { mime: ImageType; @@ -19,16 +19,16 @@ export const UNSUPPORTED_TYPE: IMetrics = { }; export function imageType(d: Uint8Array): IMetrics { - if (d.length < 24) { + if (d.length < 32) { return UNSUPPORTED_TYPE; } - const d32 = new Uint32Array(d.buffer, d.byteOffset, 6); + const d32 = new Uint32Array(d.buffer, d.byteOffset, 8); // PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG) // + first chunk must be IHDR if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) { return { mime: 'image/png', - width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19], + width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19], height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23] }; } @@ -41,7 +41,7 @@ export function imageType(d: Uint8Array): IMetrics { if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) { return { mime: 'image/gif', - width: d[7] << 8 | d[6], + width: d[7] << 8 | d[6], height: d[9] << 8 | d[8] }; } @@ -49,13 +49,71 @@ export function imageType(d: Uint8Array): IMetrics { if (d32[0] === 0x66696F71) { return { mime: 'image/qoi', - width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7], + width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7], height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11] }; } + // WEBP: RIFF | xxxx | WEBP | VP8x + if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) { + switch (d[15]) { + case 0x58: // Extended WebP VP8X --> "X" + return { + mime: 'image/webp', + width: (d[24] | d[25] << 8 | d[26] << 16) + 1, + height: (d[27] | d[28] << 8 | d[29] << 16) + 1 + }; + case 0x4C: // Lossless WebP VP8L --> "L" + if (d[20] !== 0x2f) return UNSUPPORTED_TYPE; + const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24; + return { + mime: 'image/webp', + width: (dim & 0x3FFF) + 1, + height: (dim >>> 14 & 0x3FFF) + 1 + }; + case 0x20: // Lossy WebP VP8 --> " " + if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE; + return { + mime: 'image/webp', + width: (d[26] | d[27] << 8) & 0x3FFF, + height: (d[28] | d[29] << 8) & 0x3FFF + }; + } + return UNSUPPORTED_TYPE; + } + // AVIF: Box size | ftyp | avif/avis + if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) { + let pos = -1; + // search for ispe box within first 1024 bytes + const limit = Math.min(d.length - 16, 1024); + for (let i = 8; i < limit; i++) { + // scan for ispe + if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) { + pos = i; + break; + } + } + if (pos !== -1) { + // dimensions are in BE at +8 (width) at +12 (height) + const width = + d[pos + 8] << 24 | + d[pos + 9] << 16 | + d[pos + 10] << 8 | + d[pos + 11]; + const height = + d[pos + 12] << 24 | + d[pos + 13] << 16 | + d[pos + 14] << 8 | + d[pos + 15]; + if (width > 0 && height > 0) { + return { mime: 'image/avif', width, height }; + } + } + return UNSUPPORTED_TYPE; + } return UNSUPPORTED_TYPE; } + function jpgSize(d: Uint8Array): [number, number] { const len = d.length; let i = 4; diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts index 459bc39fb..6387ded6e 100644 --- a/addons/addon-image/src/ImageStorage.ts +++ b/addons/addon-image/src/ImageStorage.ts @@ -346,52 +346,44 @@ export class ImageStorage implements IDisposable { const placeholderCalls: { col: number, row: number, count: number }[] = []; // walk all cells in viewport and collect tiles found - // Note: We check extended directly (not just HAS_EXTENDED flag) - // because text writes clear the BG flag but leave image tile data intact. - // This lets top-layer images survive text overwrites (kitty C=1 behavior). for (let row = start; row <= end; ++row) { const line = buffer.lines.get(row + buffer.ydisp); if (!line) return; - const workCell = this._workCell; for (let col = 0; col < cols; ++col) { - line.loadCell(col, workCell); - const e = workCell.hasExtendedAttrs() && workCell.extended.payload; - if (!(e instanceof ImageTileInfo)) { - continue; - } - const imageId = e.imageId; - if (imageId === undefined || imageId === -1) { - continue; - } - const imgSpec = this._images.get(imageId); - if (e.tileId !== -1) { - const startTile = e.tileId; - const startCol = col; - let count = 1; - /** - * merge tiles to the right into a single draw call, if: - * - not at end of line - * - cell has same image id - * - cell has consecutive tile id - * Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED. - */ - while (++col < cols) { - line.loadCell(col, workCell); - const nextE = workCell.hasExtendedAttrs() && workCell.extended.payload; - if (!(nextE instanceof ImageTileInfo) || !nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { - break; - } - count++; + const e = line.getExtended(col)?.payload; + if (e instanceof ImageTileInfo) { + const imageId = e.imageId; + if (imageId === undefined || imageId === -1) { + continue; } - col--; - if (imgSpec) { - if (imgSpec.actual) { - drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count }); + const imgSpec = this._images.get(imageId); + if (e.tileId !== -1) { + const startTile = e.tileId; + const startCol = col; + let count = 1; + /** + * merge tiles to the right into a single draw call, if: + * - not at end of line + * - cell has same image id + * - cell has consecutive tile id + */ + while (++col < cols) { + const nextE = line.getExtended(col)?.payload; + if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) { + break; + } + count++; } - } else if (this._opts.showPlaceholder) { - placeholderCalls.push({ col: startCol, row, count }); + col--; + if (imgSpec) { + if (imgSpec.actual) { + drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count }); + } + } else if (this._opts.showPlaceholder) { + placeholderCalls.push({ col: startCol, row, count }); + } + this._fullyCleared = false; } - this._fullyCleared = false; } } } @@ -428,15 +420,10 @@ export class ImageStorage implements IDisposable { const buffer = this._terminal._core.buffer; const rows = buffer.lines.length; const oldCol = this._viewportMetrics.cols - 1; - const workCell = this._workCell; for (let row = 0; row < rows; ++row) { const line = buffer.lines.get(row)!; - line.loadCell(oldCol, workCell); - if (workCell.hasExtendedAttrs()) { - const e = workCell.extended.payload; - if (!(e instanceof ImageTileInfo)) { - continue; - } + const e = line.getExtended(oldCol)?.payload; + if (e instanceof ImageTileInfo) { const imageId = e.imageId; if (imageId === undefined || imageId === -1) { continue; @@ -480,8 +467,8 @@ export class ImageStorage implements IDisposable { public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { const buffer = this._terminal._core.buffer; const line = buffer.lines.get(y); - if (line && line.loadCell(x, this._workCell).hasExtendedAttrs()) { - const e = this._workCell.extended.payload; + if (line) { + const e = line.getExtended(x)?.payload; if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) { const orig = this._images.get(e.imageId)?.orig; if (window.ImageBitmap && orig instanceof ImageBitmap) { @@ -500,8 +487,8 @@ export class ImageStorage implements IDisposable { public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined { const buffer = this._terminal._core.buffer; const line = buffer.lines.get(y); - if (line && line.loadCell(x, this._workCell).hasExtendedAttrs()) { - const e = this._workCell.extended.payload; + if (line) { + const e = line.getExtended(x)?.payload; if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1 && e.tileId !== -1) { const spec = this._images.get(e.imageId); if (spec) { @@ -552,7 +539,7 @@ export class ImageStorage implements IDisposable { workCell.extended.payload = new ImageTileInfo(imageId, tileId); return; } - // fall-through: always create new ExtendedAttrsImage entry + // fall-through: always create new ExtendedAttrs entry const extattr = workCell.extended.clone(); extattr.payload = new ImageTileInfo(imageId, tileId); workCell.extended = extattr; @@ -574,16 +561,12 @@ export class ImageStorage implements IDisposable { if (!line) { continue; } - const workCell = this._workCell; for (let x = 0; x < this._terminal.cols; ++x) { - line.loadCell(x, workCell); - if (workCell.hasExtendedAttrs()) { - const payload = workCell.extended.payload; - if (payload instanceof ImageTileInfo) { - const spec = this._images.get(payload.imageId); - if (spec) { - spec.tileCount++; - } + const payload = line.getExtended(x)?.payload; + if (payload instanceof ImageTileInfo) { + const spec = this._images.get(payload.imageId); + if (spec) { + spec.tileCount++; } } } diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index f3cde06a8..f4e54d7f2 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -435,6 +435,15 @@ test.describe('ImageAddon', () => { }); } }); + + test.describe('text overwrite removes tiles', () => { + test('sixel', async () => { + await assertTextClearsTiles(SIXEL_SEQ_0); + }); + test('iip', async () => { + await assertTextClearsTiles(TESTDATA_IIP[0][0]); + }); + }); }); /** @@ -472,3 +481,20 @@ async function getOrigSize(id: number): Promise<[number, number]> { async function getImageAtBufferCell(x: number, y: number): Promise { return ctx.page.evaluate(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`); } + +async function hasTileAtBufferCell(x: number, y: number): Promise { + return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`); +} + +async function assertTextClearsTiles(imageSeq: string): Promise { + await ctx.proxy.write('\x1b[H' + imageSeq); + await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true); + ok(await hasTileAtBufferCell(0, 1)); + await ctx.proxy.write('\x1b[2;6H#######'); + for (let x = 5; x < 12; x++) { + strictEqual(await hasTileAtBufferCell(x, 1), false); + } + ok(await hasTileAtBufferCell(0, 1)); + ok(await hasTileAtBufferCell(13, 1)); + ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######')); +} diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index a7c8e60c1..8c1833454 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -2213,6 +2213,21 @@ test.describe('Kitty Graphics Protocol', () => { await pollFor(ctx.page, 'window._imageAddedCount', 2); }); }); + + test.describe('text overwrite removes tiles', () => { + test('a=T placement', async () => { + await ctx.proxy.write(`\x1b[H\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`); + await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true); + ok(await hasTileAtBufferCell(0, 1)); + await ctx.proxy.write('\x1b[2;6H#######'); + for (let x = 5; x < 12; x++) { + strictEqual(await hasTileAtBufferCell(x, 1), false); + } + ok(await hasTileAtBufferCell(0, 1)); + ok(await hasTileAtBufferCell(13, 1)); + ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######')); + }); + }); }); /** @@ -2262,3 +2277,7 @@ async function getPixels(col: number, row: number, x: number, y: number, w: numb return Array.from(ctx2d.getImageData(x, y, w, h).data); }, [col, row, x, y, w, h]); } + +async function hasTileAtBufferCell(x: number, y: number): Promise { + return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`); +} diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.test.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.test.ts new file mode 100644 index 000000000..dca1b0ff7 --- /dev/null +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.test.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { createPatternCanvas } from './CustomGlyphRasterizer'; + +describe('CustomGlyphRasterizer', () => { + describe('createPatternCanvas', () => { + it('prefers an offscreen canvas without using the DOM canvas factory', () => { + const expectedCanvas = {} as OffscreenCanvas; + const calls: string[] = []; + + const canvas = createPatternCanvas( + 2, + 3, + (width, height) => { + calls.push(`offscreen:${width}x${height}`); + return expectedCanvas; + }, + () => { + calls.push('dom'); + return {} as HTMLCanvasElement; + } + ); + + assert.deepEqual({ canvas, calls }, { + canvas: expectedCanvas, + calls: ['offscreen:2x3'] + }); + }); + + it('uses the main-realm DOM canvas factory when OffscreenCanvas is unavailable', () => { + const expectedCanvas = {} as HTMLCanvasElement; + const calls: string[] = []; + + const canvas = createPatternCanvas( + 4, + 5, + undefined, + (width, height) => { + calls.push(`dom:${width}x${height}`); + return expectedCanvas; + } + ); + + assert.deepEqual({ canvas, calls }, { + canvas: expectedCanvas, + calls: ['dom:4x5'] + }); + }); + }); +}); diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts index 137cca7e3..e5d9bf998 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts @@ -8,6 +8,29 @@ import type { ILogService } from 'common/services/Services'; import { customGlyphDefinitions } from './CustomGlyphDefinitions'; import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types'; +type PatternCanvas = HTMLCanvasElement | OffscreenCanvas; +type PatternCanvasFactory = (width: number, height: number) => PatternCanvas; + +const createOffscreenPatternCanvas: PatternCanvasFactory | undefined = typeof OffscreenCanvas === 'undefined' + ? undefined + : (width, height) => new OffscreenCanvas(width, height); + +const createDomPatternCanvas: PatternCanvasFactory = (width, height) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; + +export function createPatternCanvas( + width: number, + height: number, + offscreenCanvasFactory: PatternCanvasFactory | undefined = createOffscreenPatternCanvas, + domCanvasFactory: PatternCanvasFactory = createDomPatternCanvas +): PatternCanvas { + return offscreenCanvasFactory?.(width, height) ?? domCanvasFactory(width, height); +} + /** * Try drawing a custom block element or box drawing character, returning whether it was * successfully drawn. @@ -458,9 +481,9 @@ function drawPatternChar( if (!pattern) { const width = charDefinition[0].length; const height = charDefinition.length; - const tmpCanvas = ctx.canvas.ownerDocument.createElement('canvas'); - tmpCanvas.width = width; - tmpCanvas.height = height; + // The atlas canvas can be adopted into another document, so temporary resources must not use + // its mutable ownerDocument. + const tmpCanvas = createPatternCanvas(width, height); const tmpCtx = throwIfFalsy(tmpCanvas.getContext('2d')); const imageData = new ImageData(width, height); diff --git a/addons/addon-webgl/test/WebglCustomGlyphs.test.ts b/addons/addon-webgl/test/WebglCustomGlyphs.test.ts new file mode 100644 index 000000000..0a55b6aee --- /dev/null +++ b/addons/addon-webgl/test/WebglCustomGlyphs.test.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import test, { expect } from '@playwright/test'; +import { platform } from 'os'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; + +interface ITestRendererWithAtlasCanvas { + readonly _charAtlas?: { + readonly _tmpCanvas?: HTMLCanvasElement; + }; +} + +async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.write(data); + await renderPromise; +} + +test.describe('WebGL custom glyphs', () => { + if (platform() === 'linux') { + test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit'); + } + + test('pattern glyphs render after the terminal is adopted into another document', async ({ browser }) => { + const ctx = await createTestContext(browser); + const errors: string[] = []; + const onError = (error: Error): void => { errors.push(error.message); }; + ctx.page.on('pageerror', onError); + try { + await openTerminal(ctx); + await ctx.page.evaluate(` + window.addon = new window.WebglAddon({ customGlyphs: true }); + window.term.loadAddon(window.addon); + `); + await ctx.page.evaluate(() => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const auxiliaryDocument = frame.contentDocument; + if (!auxiliaryDocument?.body || !window.term.element) { + throw new Error('Auxiliary document and terminal element must be available'); + } + const renderer = window.term._core?._renderService?._renderer?.value as ITestRendererWithAtlasCanvas | undefined; + const atlasCanvas = renderer?._charAtlas?._tmpCanvas; + if (!atlasCanvas) { + throw new Error('Texture atlas canvas must be available'); + } + // Model the atlas state after an uncached glyph has attached the measurement canvas. + window.term.element.appendChild(atlasCanvas); + auxiliaryDocument.body.appendChild(window.term.element); + if (atlasCanvas.ownerDocument !== auxiliaryDocument) { + throw new Error('Texture atlas canvas must be adopted into the auxiliary document'); + } + auxiliaryDocument.createElement = () => { + throw new Error('Not allowed to create elements in the auxiliary document'); + }; + }); + + await writeAndWaitForRender(ctx, '\u2591'); + await writeAndWaitForRender(ctx, 'X'); + + const line = await ctx.page.evaluate(() => window.term.buffer.active.getLine(0)?.translateToString(true)); + expect(errors, `renderer must not create pattern canvases in the auxiliary document: ${errors[0] ?? ''}`).toEqual([]); + expect(line).toBe('\u2591X'); + } finally { + ctx.page.off('pageerror', onError); + await ctx.page.close(); + } + }); +}); diff --git a/demo/client/components/window/addonImageWindow.ts b/demo/client/components/window/addonImageWindow.ts index e5402182a..2631cdd41 100644 --- a/demo/client/components/window/addonImageWindow.ts +++ b/demo/client/components/window/addonImageWindow.ts @@ -67,6 +67,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow { dlIip.appendChild(dtIip); this._addDdWithButton(dlIip, 'image-demo3', 'palette (png File)'); this._addDdWithButton(dlIip, 'image-demo4', 'dice (qoi MultipartFile)'); + this._addDdWithButton(dlIip, 'image-demo5', 'rose (webp File)'); + this._addDdWithButton(dlIip, 'image-demo6', 'kimono (avif MultipartFile)'); container.appendChild(dlIip); // Kitty demos @@ -173,11 +175,15 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow { document.getElementById('image-demo2')!.addEventListener('click', sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel')); document.getElementById('image-demo3')!.addEventListener('click', - iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png')); + iipDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/palette.png')); document.getElementById('image-demo4')!.addEventListener('click', - iipDemoMulti('https://raw.githubusercontent.com/jerch/xterm-wasm-parts/master/fixtures/qoi/dice.qoi')); + iipDemoMulti('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/dice.qoi')); + document.getElementById('image-demo5')!.addEventListener('click', + iipDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/1_webp_a.webp')); + document.getElementById('image-demo6')!.addEventListener('click', + iipDemoMulti('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/kimono.crop.avif')); document.getElementById('image-demo-kitty1')!.addEventListener('click', - kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png')); + kittyDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/palette.png')); // demo for image retrieval API this._terminal.element!.addEventListener('click', (ev: MouseEvent) => { diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index ade53f461..e19f6852a 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -6,16 +6,14 @@ import jsdom = require('jsdom'); import { assert } from 'chai'; import { DomRendererRowFactory } from './DomRendererRowFactory'; -import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants'; +import { DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants'; import { BufferLine, DEFAULT_ATTR_DATA } from '../../../common/buffer/BufferLine'; -import { BufferLineStringCache } from '../../../common/buffer/BufferLineStringCache'; import { IBufferLine } from '../../../common/buffer/Types'; import { CellData } from '../../../common/buffer/CellData'; import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from '../../../common/TestUtils.test'; import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from '../../TestUtils.test'; import { TestWidthCache } from './WidthCache.test'; -const TEST_STRING_CACHE = new BufferLineStringCache(); describe('DomRendererRowFactory', () => { let dom: jsdom.JSDOM; @@ -517,7 +515,7 @@ describe('DomRendererRowFactory', () => { } function createEmptyLineData(cols: number): IBufferLine { - const lineData = new BufferLine(TEST_STRING_CACHE, cols); + const lineData = new BufferLine(cols); for (let i = 0; i < cols; i++) { lineData.setCell(i, NULL_CELL_DATA); } diff --git a/src/browser/services/CharacterJoinerService.test.ts b/src/browser/services/CharacterJoinerService.test.ts index 2e73f035b..b12595bdd 100644 --- a/src/browser/services/CharacterJoinerService.test.ts +++ b/src/browser/services/CharacterJoinerService.test.ts @@ -7,12 +7,10 @@ import { assert } from 'chai'; import { ICharacterJoinerService } from './Services'; import { CharacterJoinerService } from './CharacterJoinerService'; import { BufferLine } from '../../common/buffer/BufferLine'; -import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache'; import { IBufferLine } from '../../common/buffer/Types'; import { CellData } from '../../common/buffer/CellData'; import { MockBufferService, createCellData } from '../../common/TestUtils.test'; -const TEST_STRING_CACHE = new BufferLineStringCache(); describe('CharacterJoinerService', () => { let service: ICharacterJoinerService; @@ -25,7 +23,7 @@ describe('CharacterJoinerService', () => { lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]])); lines.set(3, lineData([['no joined ranges']])); - lines.set(4, new BufferLine(TEST_STRING_CACHE, 0)); + lines.set(4, new BufferLine(0)); lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]])); const line6 = lineData([['wi']]); line6.resize(line6.length + 1, createCellData(0, '¥', 2)); @@ -270,7 +268,7 @@ describe('CharacterJoinerService', () => { type IPartialLineData = ([string] | [string, number]); function lineData(data: IPartialLineData[]): IBufferLine { - const tline = new BufferLine(TEST_STRING_CACHE, 0); + const tline = new BufferLine(0); for (let i = 0; i < data.length; ++i) { const line = data[i][0]; const attr = (data[i][1] || 0) as number; diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index 1f65e909e..59d36d250 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -9,13 +9,11 @@ import { SelectionModel } from '../selection/SelectionModel'; import { IBuffer, IBufferLine } from '../../common/buffer/Types'; import { MockBufferService, MockOptionsService, MockCoreService, MockMouseStateService, createCellData } from '../../common/TestUtils.test'; import { BufferLine } from '../../common/buffer/BufferLine'; -import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache'; import { IBufferService, IOptionsService } from '../../common/services/Services'; import { MockCoreBrowserService, MockMouseService, MockRenderService } from '../TestUtils.test'; import { CellData } from '../../common/buffer/CellData'; import { IRenderService } from './Services'; -const TEST_STRING_CACHE = new BufferLineStringCache(); class TestSelectionService extends SelectionService { constructor( @@ -60,7 +58,7 @@ describe('SelectionService', () => { }); function stringToRow(text: string): IBufferLine { - const result = new BufferLine(TEST_STRING_CACHE, text.length); + const result = new BufferLine(text.length); for (let i = 0; i < text.length; i++) { result.setCell(i, createCellData(0, text.charAt(i), 1)); } @@ -68,7 +66,7 @@ describe('SelectionService', () => { } function stringArrayToRow(chars: string[]): IBufferLine { - const line = new BufferLine(TEST_STRING_CACHE, chars.length); + const line = new BufferLine(chars.length); chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1))); return line; } @@ -123,7 +121,7 @@ describe('SelectionService', () => { [0, 'o', 1, 'o'.charCodeAt(0)], [0, 'o', 1, 'o'.charCodeAt(0)] ]; - const line = new BufferLine(TEST_STRING_CACHE, data.length); + const line = new BufferLine(data.length); for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i])); buffer.lines.set(0, line); // Ensure wide characters take up 2 columns diff --git a/src/common/Event.ts b/src/common/Event.ts index 88417fc83..9f7df4f9c 100644 --- a/src/common/Event.ts +++ b/src/common/Event.ts @@ -27,11 +27,13 @@ export class Emitter { } const entry = { fn: listener, thisArgs }; + this._listeners = this._listeners.slice(); this._listeners.push(entry); const result = toDisposable(() => { const idx = this._listeners.indexOf(entry); if (idx !== -1) { + this._listeners = this._listeners.slice(); this._listeners.splice(idx, 1); } }); @@ -50,23 +52,16 @@ export class Emitter { } public fire(event: T): void { - if (this._disposed) { + if (this._disposed || !this._listeners.length) { return; } - switch (this._listeners.length) { - case 0: return; - case 1: { - const { fn, thisArgs } = this._listeners[0]; - fn.call(thisArgs, event); - return; - } - default: { - // Snapshot listeners to allow modifications during iteration (2+ listeners) - const listeners = this._listeners.slice(); - for (const { fn, thisArgs } of listeners) { - fn.call(thisArgs, event); - } - } + if (this._listeners.length === 1) { + this._listeners[0].fn.call(this._listeners[0].thisArgs, event); + return; + } + const listeners = this._listeners; + for (let i = 0, len = listeners.length; i < len; ++i) { + listeners[i].fn.call(listeners[i].thisArgs, event); } } diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index cbb8cf9ed..477a758b5 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -8,24 +8,14 @@ import { Buffer } from './Buffer'; import { CircularList } from '../CircularList'; import { MockOptionsService, MockBufferService, MockLogService, createCellData } from '../TestUtils.test'; import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine'; -import { BufferLineStringCache } from './BufferLineStringCache'; import { CellData } from './CellData'; -import { ExtendedAttrs } from './AttributeData'; + const INIT_COLS = 80; const INIT_ROWS = 24; const INIT_SCROLLBACK = 1000; -const TEST_STRING_CACHE = new BufferLineStringCache(); -class TestBuffer extends Buffer { - public getStringCache(): BufferLineStringCache { - return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache; - } - - public getStringCacheClearTimeout(): unknown { - return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value; - } -} +class TestBuffer extends Buffer {} describe('Buffer', () => { let optionsService: MockOptionsService; @@ -1202,7 +1192,7 @@ describe('Buffer', () => { describe ('translateBufferLineToString', () => { it('should handle selecting a section of ascii text', () => { - const line = new BufferLine(TEST_STRING_CACHE, 4); + const line = new BufferLine(4); line.setCell(0, createCellData(0, 'a', 1)); line.setCell(1, createCellData(0, 'b', 1)); line.setCell(2, createCellData(0, 'c', 1)); @@ -1214,7 +1204,7 @@ describe('Buffer', () => { }); it('should handle a cut-off double width character by including it', () => { - const line = new BufferLine(TEST_STRING_CACHE, 3); + const line = new BufferLine(3); line.setCell(0, createCellData(0, '語', 2)); line.setCell(1, createCellData(0, '', 0)); line.setCell(2, createCellData(0, 'a', 1)); @@ -1225,7 +1215,7 @@ describe('Buffer', () => { }); it('should handle a zero width character in the middle of the string by not including it', () => { - const line = new BufferLine(TEST_STRING_CACHE, 3); + const line = new BufferLine(3); line.setCell(0, createCellData(0, '語', 2)); line.setCell(1, createCellData(0, '', 0)); line.setCell(2, createCellData(0, 'a', 1)); @@ -1242,7 +1232,7 @@ describe('Buffer', () => { }); it('should handle single width emojis', () => { - const line = new BufferLine(TEST_STRING_CACHE, 2); + const line = new BufferLine(2); line.setCell(0, createCellData(0, '😁', 1)); line.setCell(1, createCellData(0, 'a', 1)); buffer.lines.set(0, line); @@ -1255,7 +1245,7 @@ describe('Buffer', () => { }); it('should handle double width emojis', () => { - const line = new BufferLine(TEST_STRING_CACHE, 2); + const line = new BufferLine(2); line.setCell(0, createCellData(0, '😁', 2)); line.setCell(1, createCellData(0, '', 0)); buffer.lines.set(0, line); @@ -1266,7 +1256,7 @@ describe('Buffer', () => { const str2 = buffer.translateBufferLineToString(0, true, 0, 2); assert.equal(str2, '😁'); - const line2 = new BufferLine(TEST_STRING_CACHE, 3); + const line2 = new BufferLine(3); line2.setCell(0, createCellData(0, '😁', 2)); line2.setCell(1, createCellData(0, '', 0)); line2.setCell(2, createCellData(0, 'a', 1)); @@ -1277,99 +1267,6 @@ describe('Buffer', () => { }); }); - describe('line string cache cleanup', () => { - it('should clear shared cache entries with a single timer', () => { - const originalSetTimeout = globalThis.setTimeout; - const originalClearTimeout = globalThis.clearTimeout; - const originalDateNow = Date.now; - let timeoutId = 0; - let now = 0; - const clearedTimeouts: number[] = []; - const scheduledTimeouts = new Map void }>(); - (globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => { - const id = ++timeoutId; - scheduledTimeouts.set(id, { - delay: timeout ?? 0, - fire: () => { - scheduledTimeouts.delete(id); - handler(); - } - }); - return id as ReturnType; - }) as typeof setTimeout; - (globalThis as any).clearTimeout = ((id: ReturnType) => { - const numericId = id as unknown as number; - clearedTimeouts.push(numericId); - scheduledTimeouts.delete(numericId); - }) as typeof clearTimeout; - Date.now = () => now; - try { - buffer.fillViewportRows(); - buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); - - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`); - - const cache = buffer.getStringCache(); - assert.equal(cache.entries.size, 2); - assert.ok(buffer.getStringCacheClearTimeout() !== undefined); - assert.equal(scheduledTimeouts.size, 1); - assert.equal([...scheduledTimeouts.values()][0].delay, 15000); - const initialTimerCreationCount = timeoutId; - - now = 5000; - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.equal(timeoutId, initialTimerCreationCount); - assert.equal(scheduledTimeouts.size, 1); - assert.deepEqual(clearedTimeouts, []); - - now = 15000; - [...scheduledTimeouts.values()][0].fire(); - assert.equal(timeoutId, initialTimerCreationCount + 1); - assert.ok(buffer.getStringCacheClearTimeout() !== undefined); - assert.equal(scheduledTimeouts.size, 1); - assert.equal([...scheduledTimeouts.values()][0].delay, 5000); - - now = 20000; - [...scheduledTimeouts.values()][0].fire(); - - assert.equal(cache.entries.size, 0); - assert.equal(buffer.getStringCacheClearTimeout(), undefined); - - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.equal(cache.entries.size, 1); - } finally { - Date.now = originalDateNow; - globalThis.setTimeout = originalSetTimeout; - globalThis.clearTimeout = originalClearTimeout; - } - }); - - it('should reset line string cache state on clear and resize', () => { - buffer.fillViewportRows(); - buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - buffer.translateBufferLineToString(0, false); - - const cache = buffer.getStringCache(); - assert.equal(cache.entries.size, 1); - assert.ok(buffer.getStringCacheClearTimeout() !== undefined); - - buffer.clear(); - assert.equal(cache.entries.size, 0); - assert.equal(buffer.getStringCacheClearTimeout(), undefined); - - buffer.fillViewportRows(); - buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); - buffer.translateBufferLineToString(0, false); - assert.equal(cache.entries.size, 1); - - buffer.resize(INIT_COLS - 1, INIT_ROWS); - assert.equal(cache.entries.size, 0); - assert.equal(buffer.getStringCacheClearTimeout(), undefined); - }); - }); - describe('memory cleanup after shrinking', () => { it('should realign memory from idle task execution', async () => { buffer.fillViewportRows(); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index c7d094a43..e3a88bfeb 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -10,7 +10,6 @@ import { ICharset } from '../Types'; import { IAttributeData, IBuffer, IBufferLine, ICellData } from './Types'; import { ExtendedAttrs } from './AttributeData'; import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine'; -import { BufferLineStringCache } from './BufferLineStringCache'; import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow'; import { CellData } from './CellData'; import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from './Constants'; @@ -52,7 +51,6 @@ export class Buffer extends Disposable implements IBuffer { private _isClearing: boolean = false; private _memoryCleanupQueue: InstanceType; private _memoryCleanupPosition = 0; - private readonly _stringCache: BufferLineStringCache; constructor( private _hasScrollback: boolean, @@ -70,7 +68,6 @@ export class Buffer extends Disposable implements IBuffer { this._memoryCleanupQueue = new IdleTaskQueue(this._logService); this._register(toDisposable(() => this._memoryCleanupQueue.clear())); this._register(toDisposable(() => this.clearAllMarkers())); - this._stringCache = this._register(new BufferLineStringCache()); } public getNullCell(attr?: IAttributeData): ICellData { @@ -100,7 +97,7 @@ export class Buffer extends Disposable implements IBuffer { } public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { - return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped); + return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped); } public get hasScrollback(): boolean { @@ -145,7 +142,6 @@ export class Buffer extends Disposable implements IBuffer { * Clears the buffer to its initial state, discarding all previous data. */ public clear(): void { - this._stringCache.clear(); this.ydisp = 0; this.ybase = 0; this.y = 0; @@ -164,7 +160,6 @@ export class Buffer extends Disposable implements IBuffer { public resize(newCols: number, newRows: number): void { // store reference to null cell with default attrs const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); - this._stringCache.clear(); // count bufferlines with overly big memory to be cleaned afterwards let dirtyMemoryLines = 0; @@ -199,7 +194,7 @@ export class Buffer extends Disposable implements IBuffer { if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { // Just add the new missing rows on Windows as conpty reprints the screen with its // view of the world. Once a line enters scrollback for conpty it remains there - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); + this.lines.push(new BufferLine(newCols, nullCell, false)); } else { if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) { // There is room above the buffer and there are no empty elements below the line, @@ -213,7 +208,7 @@ export class Buffer extends Disposable implements IBuffer { } else { // Add a blank line if there is no buffer left at the top to scroll to, or if there // are blank lines after the cursor - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); + this.lines.push(new BufferLine(newCols, nullCell, false)); } } } @@ -354,7 +349,7 @@ export class Buffer extends Disposable implements IBuffer { } if (this.lines.length < newRows) { // Add an extra row at the bottom of the viewport - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); + this.lines.push(new BufferLine(newCols, nullCell, false)); } } else { if (this.ydisp === this.ybase) { diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index 7fc12ad76..e14662f8e 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -4,19 +4,16 @@ */ import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from './Constants'; import { BufferLine } from './BufferLine'; -import { BufferLineStringCache } from './BufferLineStringCache'; import { CellData } from './CellData'; import { CharData, IBufferLine, ICellData } from './Types'; import { assert } from 'chai'; import { AttributeData } from './AttributeData'; import { createCellData, NULL_CELL_DATA, extendedAttributes } from '../TestUtils.test'; -const TEST_STRING_CACHE = new BufferLineStringCache(); - class TestBufferLine extends BufferLine { constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) { - super(TEST_STRING_CACHE, cols, fillCellData, isWrapped); + super(cols, fillCellData, isWrapped); } public get combined(): {[index: number]: string} { @@ -24,19 +21,19 @@ class TestBufferLine extends BufferLine { } public get cachedString(): string | undefined { - return this._getStringCacheEntry(false)?.value; + return this._cacheValid ? this._cache : undefined; } public set cachedString(value: string | undefined) { - this._getStringCacheEntry(true)!.value = value; + this._cache = value ?? ''; } public get isCachedStringTrimmed(): boolean { - return this._getStringCacheEntry(false)?.isTrimmed ?? false; + return this._cacheTrimmed; } public set isCachedStringTrimmed(value: boolean) { - this._getStringCacheEntry(true)!.isTrimmed = value; + this._cacheTrimmed = value; } public toArray(): CharData[] { diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 30b4ed3bb..8166d1ff9 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -8,7 +8,6 @@ import { AttributeData } from './AttributeData'; import { CellData } from './CellData'; import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from './Constants'; import { stringFromCodePoint } from '../input/TextDecoder'; -import { StringBuilder } from '../StringBuilder'; // Buffer memory layout: // @@ -37,24 +36,20 @@ const enum Cell { BG = 2 // currently unused } + +interface IExtendedAttrsExt extends IExtendedAttrs { + _ext: number; + _urlId: number; +} + + export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData()); // Work variables to avoid garbage collection let $startIndex = 0; const $workCell = new CellData(); -const $translateToStringBuilder = new StringBuilder(); +const $extended = DEFAULT_ATTR_DATA.extended.clone() as IExtendedAttrsExt; -export interface IBufferLineStringCacheEntry { - value: string | undefined; - isTrimmed: boolean; - generation: number; -} - -export interface IBufferLineStringCache { - generation: number; - allocateEntry(): IBufferLineStringCacheEntry; - touch?(): void; -} /** * Typed array based bufferline implementation. @@ -77,11 +72,14 @@ export class BufferLine implements IBufferLine { protected _combined: {[index: number]: string} = {}; /** Sparse cache; only read when `HAS_EXTENDED` is set in `_data`. */ protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; - protected _stringCacheEntryRef: WeakRef | undefined; public length: number; + /** line text cache */ + protected _cacheValid = false; + protected _cache: string = ''; + protected _cacheTrimmed = false; + constructor( - protected readonly _stringCache: IBufferLineStringCache, cols: number, fillCellData?: ICellData, public isWrapped: boolean = false @@ -118,7 +116,7 @@ export class BufferLine implements IBufferLine { * @deprecated */ public set(index: number, value: CharData): void { - this._invalidateStringCache(); + this._cacheValid = false; this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; if (value[CHAR_DATA_CHAR_INDEX].length > 1) { this._combined[index] = value[1]; @@ -210,21 +208,30 @@ export class BufferLine implements IBufferLine { } else { cell.combinedData = ''; } - if (cell.bg & BgFlags.HAS_EXTENDED) { - cell.extended = this._extendedAttrs[index]!; - } else { - // Do not mutate cell.extended in place: it may still reference this line's map entry from a - // prior loadCell into a reused CellData (e.g. $workCell during insert/delete). - cell.extended = DEFAULT_ATTR_DATA.extended.clone(); - } + cell.extended = this.getExtended(index); return cell; } + public getExtended(index: number): IExtendedAttrs { + $startIndex = index * Constants.CELL_INDICIES; + if (this._data[$startIndex + Cell.BG] & BgFlags.HAS_EXTENDED) { + return this._extendedAttrs[index]!; + } + // Do not mutate cell.extended in place: it may still reference this line's map entry from a + // prior loadCell into a reused CellData (e.g. $workCell during insert/delete). + // We use $extended as blueprint and reset the internals + // mimicking the ctor to avoid a new allocation. + $extended._ext = 0; + $extended._urlId = 0; + $extended.payload = undefined; + return $extended; + } + /** * Set data at `index` to `cell`. */ public setCell(index: number, cell: ICellData): void { - this._invalidateStringCache(); + this._cacheValid = false; if (cell.content & Content.IS_COMBINED_MASK) { this._combined[index] = cell.combinedData; } @@ -242,13 +249,14 @@ export class BufferLine implements IBufferLine { * it gets an optimized access method. */ public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { - this._invalidateStringCache(); + this._cacheValid = false; if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; } - this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); - this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg; - this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg; + const $idx = index * Constants.CELL_INDICIES; + this._data[$idx + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); + this._data[$idx + Cell.FG] = attrs.fg; + this._data[$idx + Cell.BG] = attrs.bg; } /** @@ -258,7 +266,7 @@ export class BufferLine implements IBufferLine { * by the previous `setDataFromCodePoint` call, we can omit it here. */ public addCodepointToCell(index: number, codePoint: number, width: number): void { - this._invalidateStringCache(); + this._cacheValid = false; let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add @@ -285,7 +293,7 @@ export class BufferLine implements IBufferLine { } public insertCells(pos: number, n: number, fillCellData: ICellData): void { - this._invalidateStringCache(); + this._cacheValid = false; pos %= this.length; // handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char @@ -313,7 +321,7 @@ export class BufferLine implements IBufferLine { } public deleteCells(pos: number, n: number, fillCellData: ICellData): void { - this._invalidateStringCache(); + this._cacheValid = false; pos %= this.length; if (n < this.length - pos) { for (let i = 0; i < this.length - pos - n; ++i) { @@ -340,7 +348,7 @@ export class BufferLine implements IBufferLine { } public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void { - this._invalidateStringCache(); + this._cacheValid = false; // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) { @@ -380,7 +388,7 @@ export class BufferLine implements IBufferLine { * excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD). */ public resize(cols: number, fillCellData: ICellData): boolean { - this._invalidateStringCache(); + this._cacheValid = false; if (cols === this.length) { return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength; } @@ -440,7 +448,7 @@ export class BufferLine implements IBufferLine { /** fill a line with fillCharData */ public fill(fillCellData: ICellData, respectProtect: boolean = false): void { - this._invalidateStringCache(); + this._cacheValid = false; // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { for (let i = 0; i < this.length; ++i) { @@ -458,8 +466,7 @@ export class BufferLine implements IBufferLine { } /** alter to a full copy of line */ - public copyFrom(line: BufferLine): void { - this._invalidateStringCache(); + public copyFrom(line: BufferLine, blank?: boolean): void { if (this.length !== line.length) { this._data = new Uint32Array(line._data); } else { @@ -467,16 +474,29 @@ export class BufferLine implements IBufferLine { this._data.set(line._data); } this.length = line.length; - this._copySparseMapsFrom(line); + if (blank) { + // a blank line may never hold combined or extended attrs, + // thus we can skip handling them + this._combined = {}; + this._extendedAttrs = {}; + } else { + this._copySparseMapsFrom(line); + } + this._cache = ''; + this._cacheValid = false; this.isWrapped = line.isWrapped; } /** create a new clone */ - public clone(): IBufferLine { - const newLine = new BufferLine(this._stringCache, 0, undefined, false); + public clone(blank?: boolean): IBufferLine { + const newLine = new BufferLine(0, undefined, false); newLine._data = new Uint32Array(this._data); newLine.length = this.length; - newLine._copySparseMapsFrom(this); + if (!blank) { + // a blank line may never hold combined or extended attrs, + // thus we can skip handling them + newLine._copySparseMapsFrom(this); + } newLine.isWrapped = this.isWrapped; return newLine; } @@ -500,7 +520,7 @@ export class BufferLine implements IBufferLine { } public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { - this._invalidateStringCache(); + this._cacheValid = false; const srcData = src._data; if (applyInReverse) { for (let cell = length - 1; cell >= 0; cell--) { @@ -534,17 +554,13 @@ export class BufferLine implements IBufferLine { * returned string, the corresponding entries in `outColumns` will have the same column number. */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { - const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; - if (isCanonicalRequest) { - this._stringCache.touch?.(); - } - const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined; - if (isCanonicalRequest && stringCacheEntry?.value !== undefined) { + const isCanonical = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; + if (isCanonical && this._cacheValid) { if (trimRight) { - return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd(); + return this._cacheTrimmed ? this._cache : this._cache.trimEnd(); } - if (!stringCacheEntry.isTrimmed) { - return stringCacheEntry.value; + if (!this._cacheTrimmed) { + return this._cache; } } startCol = startCol ?? 0; @@ -555,12 +571,12 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.length = 0; } - $translateToStringBuilder.reset(); + const cellContents: string[] = []; while (startCol < endCol) { const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; - $translateToStringBuilder.append(chars); + cellContents.push(chars); if (outColumns) { for (let i = 0; i < chars.length; ++i) { outColumns.push(startCol); @@ -571,39 +587,15 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.push(startCol); } - const result = $translateToStringBuilder.toString(); - $translateToStringBuilder.reset(); - if (isCanonicalRequest) { - const cacheEntry = this._getStringCacheEntry(true)!; - cacheEntry.value = result; - cacheEntry.isTrimmed = !!trimRight; + const result = cellContents.join(''); + if (isCanonical) { + this._cache = result; + this._cacheValid = true; + this._cacheTrimmed = !!trimRight; } return result; } - protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined { - const cachedEntry = this._stringCacheEntryRef?.deref(); - if (cachedEntry) { - if (cachedEntry.generation === this._stringCache.generation) { - return cachedEntry; - } - } - if (!createIfNeeded) { - return undefined; - } - const cacheEntry = this._stringCache.allocateEntry(); - this._stringCacheEntryRef = new WeakRef(cacheEntry); - return cacheEntry; - } - - private _invalidateStringCache(): void { - const cacheEntry = this._getStringCacheEntry(false); - if (cacheEntry) { - cacheEntry.value = undefined; - cacheEntry.isTrimmed = false; - } - } - /** Copy sparse map entries for a single cell when `_data` flags require them. */ private _copyCellMapsFrom(src: BufferLine, srcCol: number, destCol: number): void { const srcStart = srcCol * Constants.CELL_INDICIES; diff --git a/src/common/buffer/BufferLineStringCache.ts b/src/common/buffer/BufferLineStringCache.ts deleted file mode 100644 index 54c97423a..000000000 --- a/src/common/buffer/BufferLineStringCache.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) 2026 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from './BufferLine'; -import { disposableTimeout } from '../Async'; -import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../Lifecycle'; - -const enum Constants { - CACHE_TTL_MS = 15000 -} - -export class BufferLineStringCache extends Disposable implements IBufferLineStringCache { - public generation: number = 0; - public readonly entries: Set = new Set(); - private readonly _clearTimeout = this._register(new MutableDisposable()); - private _lastAccessTimestamp: number = 0; - - constructor() { - super(); - this._register(toDisposable(() => this.entries.clear())); - } - - public touch(): void { - this._scheduleClear(); - } - - public allocateEntry(): IBufferLineStringCacheEntry { - const entry: IBufferLineStringCacheEntry = { - value: undefined, - isTrimmed: false, - generation: this.generation - }; - this.entries.add(entry); - this._scheduleClear(); - return entry; - } - - public clear(): void { - this._clearTimeout.clear(); - this._lastAccessTimestamp = 0; - this.generation++; - for (const entry of this.entries) { - entry.value = undefined; - entry.isTrimmed = false; - } - this.entries.clear(); - } - - private _scheduleClear(): void { - this._lastAccessTimestamp = Date.now(); - if (this._clearTimeout.value) { - return; - } - this._scheduleClearTimeout(Constants.CACHE_TTL_MS); - } - - private _scheduleClearTimeout(timeoutMs: number): void { - this._clearTimeout.value = disposableTimeout(() => { - const elapsed = Date.now() - this._lastAccessTimestamp; - if (elapsed >= Constants.CACHE_TTL_MS) { - this.clear(); - return; - } - this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed); - }, timeoutMs); - } -} diff --git a/src/common/buffer/BufferReflow.test.ts b/src/common/buffer/BufferReflow.test.ts index 8bcf5bf9c..2a6021eeb 100644 --- a/src/common/buffer/BufferReflow.test.ts +++ b/src/common/buffer/BufferReflow.test.ts @@ -5,18 +5,16 @@ import { assert } from 'chai'; import { CircularList } from '../CircularList'; import { BufferLine } from './BufferLine'; -import { BufferLineStringCache } from './BufferLineStringCache'; import { CellData } from './CellData'; import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Constants'; import { reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow'; import { IBufferLine } from './Types'; -const TEST_STRING_CACHE = new BufferLineStringCache(); describe('BufferReflow', () => { describe('reflowSmallerGetNewLineLengths', () => { it('should return correct line lengths for a small line with wide characters', () => { - const line = new BufferLine(TEST_STRING_CACHE, 4); + const line = new BufferLine(4); line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(1, [0, '', 0, 0]); line.set(2, [0, '语', 2, '语'.charCodeAt(0)]); @@ -26,7 +24,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语'); }); it('should return correct line lengths for a large line with wide characters', () => { - const line = new BufferLine(TEST_STRING_CACHE, 12); + const line = new BufferLine(12); for (let i = 0; i < 12; i += 4) { line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]); @@ -48,7 +46,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语'); }); it('should return correct line lengths for a string with wide and single characters', () => { - const line = new BufferLine(TEST_STRING_CACHE, 6); + const line = new BufferLine(6); line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(2, [0, '', 0, 0]); @@ -62,14 +60,14 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b'); }); it('should return correct line lengths for a wrapped line with wide and single characters', () => { - const line1 = new BufferLine(TEST_STRING_CACHE, 6); + const line1 = new BufferLine(6); line1.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line1.set(2, [0, '', 0, 0]); line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]); line1.set(4, [0, '', 0, 0]); line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]); - const line2 = new BufferLine(TEST_STRING_CACHE, 6, undefined, true); + const line2 = new BufferLine(6, undefined, true); line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line2.set(2, [0, '', 0, 0]); @@ -84,7 +82,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b'); }); it('should work on lines ending in null space', () => { - const line = new BufferLine(TEST_STRING_CACHE, 5); + const line = new BufferLine(5); line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(1, [0, '', 0, 0]); line.set(2, [0, '语', 2, '语'.charCodeAt(0)]); @@ -102,7 +100,7 @@ describe('BufferReflow', () => { function createWrappedLines(chars: string): CircularList { const lines = new CircularList(chars.length); for (let i = 0; i < chars.length; i++) { - const line = new BufferLine(TEST_STRING_CACHE, 1); + const line = new BufferLine(1); line.set(0, [0, chars[i], 1, chars.charCodeAt(i)]); line.isWrapped = i > 0; lines.push(line); diff --git a/src/common/buffer/BufferSet.test.ts b/src/common/buffer/BufferSet.test.ts index 71a1026dc..7cba74459 100644 --- a/src/common/buffer/BufferSet.test.ts +++ b/src/common/buffer/BufferSet.test.ts @@ -82,45 +82,4 @@ describe('BufferSet', () => { assert.equal(bufferSet.alt.markers.length, 0); }); }); - - describe('lifecycle', () => { - it('should dispose previous buffers on reset', () => { - const oldNormal = bufferSet.normal as any; - oldNormal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - oldNormal.translateBufferLineToString(0, false); - - const oldCache = oldNormal._stringCache; - assert.equal(oldCache.entries.size, 1); - assert.notEqual(oldCache._clearTimeout.value, undefined); - - bufferSet.reset(); - - assert.notEqual(bufferSet.normal, oldNormal); - assert.equal(oldCache.entries.size, 0); - assert.equal(oldCache._clearTimeout.value, undefined); - }); - - it('should dispose both buffers when disposed', () => { - const normal = bufferSet.normal as any; - normal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - normal.translateBufferLineToString(0, false); - - bufferSet.activateAltBuffer(); - const alt = bufferSet.alt as any; - alt.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); - alt.translateBufferLineToString(0, false); - - const normalCache = normal._stringCache; - const altCache = alt._stringCache; - assert.notEqual(normalCache._clearTimeout.value, undefined); - assert.notEqual(altCache._clearTimeout.value, undefined); - - bufferSet.dispose(); - - assert.equal(normalCache.entries.size, 0); - assert.equal(altCache.entries.size, 0); - assert.equal(normalCache._clearTimeout.value, undefined); - assert.equal(altCache._clearTimeout.value, undefined); - }); - }); }); diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index 18f76d86c..025fed0b7 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -132,8 +132,8 @@ export interface IBufferLine { resize(cols: number, fill: ICellData): boolean; cleanupMemory(): number; fill(fillCellData: ICellData, respectProtect?: boolean): void; - copyFrom(line: IBufferLine): void; - clone(): IBufferLine; + copyFrom(line: IBufferLine, blank?: boolean): void; + clone(blank?: boolean): IBufferLine; getTrimmedLength(): number; getNoBgTrimmedLength(): number; translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string; @@ -147,6 +147,7 @@ export interface IBufferLine { getCodePoint(index: number): number; isCombined(index: number): number; getString(index: number): string; + getExtended(index: number): IExtendedAttrs; } export interface IMarker extends IDisposable { diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 21b0a163a..7abdac978 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -86,12 +86,12 @@ export class BufferService extends Disposable implements IBufferService { // Insert the line using the fastest method if (bottomRow === buffer.lines.length - 1) { if (willBufferBeTrimmed) { - buffer.lines.recycle().copyFrom(newLine); + buffer.lines.recycle().copyFrom(newLine, true); } else { - buffer.lines.push(newLine.clone()); + buffer.lines.push(newLine.clone(true)); } } else { - buffer.lines.splice(bottomRow + 1, 0, newLine.clone()); + buffer.lines.splice(bottomRow + 1, 0, newLine.clone(true)); } // Only adjust ybase and ydisp when the buffer is not trimmed @@ -113,7 +113,7 @@ export class BufferService extends Disposable implements IBufferService { // scrollback, instead we can just shift them in-place. const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */; buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1); - buffer.lines.set(bottomRow, newLine.clone()); + buffer.lines.set(bottomRow, newLine.clone(true)); } // Move the viewport to the bottom of the buffer unless the user is diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index ea9b7fb14..49e85729d 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -214,7 +214,7 @@ export class DecorationLineCache extends Disposable { } private _handleBufferLinesTrim(amount: number): void { - if (amount <= 0) { + if (amount <= 0 || !this._decorationsByLine.size) { return; } const newMap = new Map();