Merge branch 'master' into webp_avif_support

This commit is contained in:
jerch
2026-08-24 13:27:57 +02:00
committed by GitHub
6 changed files with 232 additions and 42 deletions
+30 -39
View File
@@ -427,55 +427,46 @@ 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 _extendedAttrs 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) as IBufferLineExt;
if (!line) return;
for (let col = 0; col < cols; ++col) {
let e: IExtendedAttrsImage;
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
e = line._extendedAttrs[col] ?? EMPTY_ATTRS;
} else {
const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) {
let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
e = maybeImg;
}
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) {
const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
break;
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
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
&& (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)
&& (e.imageId === imageId)
&& (e.tileId === startTile + count)
) {
count++;
}
count++;
}
col--;
if (imgSpec) {
if (imgSpec.actual) {
drawCalls.push({ imgSpec, tileId: startTile, 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 });
}
} else if (this._opts.showPlaceholder) {
placeholderCalls.push({ col: startCol, row, count });
this._fullyCleared = false;
}
this._fullyCleared = false;
}
}
}
@@ -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<string | undefined> {
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
}
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
}
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
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('#######'));
}
@@ -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<boolean> {
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
}
@@ -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']
});
});
});
});
@@ -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);
@@ -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<void> {
const renderPromise = new Promise<void>(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();
}
});
});