Skip to content

Commit 5dc37ae

Browse files
authored
Merge branch 'master' into fix-fcitx5-candidate-commit
2 parents 949c4d9 + b1aee19 commit 5dc37ae

4 files changed

Lines changed: 266 additions & 12 deletions

File tree

addons/addon-webgl/src/GlyphRenderer.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export class GlyphRenderer extends Disposable {
100100
private readonly _attributesBuffer: WebGLBuffer;
101101

102102
private _atlas: ITextureAtlas | undefined;
103+
private _lastSeenPageLayoutVersion: number = -1;
103104
private _activeBuffer: number = 0;
104105
private readonly _vertices: IVertices = {
105106
count: 0,
@@ -213,8 +214,19 @@ export class GlyphRenderer extends Disposable {
213214
this.handleResize();
214215
}
215216

217+
/**
218+
* Call when a frame is being drawn. Returns whether the full model must be rebuilt before
219+
* rendering this frame because the atlas page layout changed since this renderer last drew.
220+
*/
216221
public beginFrame(): boolean {
217-
return this._atlas ? this._atlas.beginFrame() : true;
222+
if (!this._atlas) {
223+
return true;
224+
}
225+
if (this._atlas.pageLayoutVersion !== this._lastSeenPageLayoutVersion) {
226+
this._lastSeenPageLayoutVersion = this._atlas.pageLayoutVersion;
227+
return true;
228+
}
229+
return false;
218230
}
219231

220232
public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
@@ -375,6 +387,7 @@ export class GlyphRenderer extends Disposable {
375387

376388
public setAtlas(atlas: ITextureAtlas): void {
377389
this._atlas = atlas;
390+
this._lastSeenPageLayoutVersion = -1;
378391
this.invalidateAtlasTextures();
379392
}
380393

addons/addon-webgl/src/TextureAtlas.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,8 @@ export class TextureAtlas implements ITextureAtlas {
132132
}
133133
}
134134

135-
private _requestClearModel = false;
136-
public beginFrame(): boolean {
137-
const result = this._requestClearModel;
138-
this._requestClearModel = false;
139-
return result;
140-
}
135+
private _pageLayoutVersion = 0;
136+
public get pageLayoutVersion(): number { return this._pageLayoutVersion; }
141137

142138
public clearTexture(): void {
143139
if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
@@ -207,7 +203,7 @@ export class TextureAtlas implements ITextureAtlas {
207203
this.pages.push(mergedPage);
208204

209205
// Request the model to be cleared to refresh all texture pages.
210-
this._requestClearModel = true;
206+
this._pageLayoutVersion++;
211207
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
212208
}
213209

@@ -819,7 +815,7 @@ export class TextureAtlas implements ITextureAtlas {
819815
this.pages.push(this._overflowSizePage);
820816

821817
// Request the model to be cleared to refresh all texture pages.
822-
this._requestClearModel = true;
818+
this._pageLayoutVersion++;
823819
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
824820
}
825821
activePage = this._overflowSizePage;

addons/addon-webgl/src/Types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,12 @@ export interface ITextureAtlas extends IDisposable {
6868
warmUp(): void;
6969

7070
/**
71-
* Call when a frame is being drawn, this will return true if the atlas was cleared to make room
72-
* for a new set of glyphs.
71+
* Incremented whenever cached glyph texture page mappings may be stale, such as after atlas page
72+
* merges or overflow page creation. Renderers compare this against their own last-seen value and
73+
* rebuild their model when it changes; a shared atlas can have many renderers, so this must not
74+
* be a consume-once flag.
7375
*/
74-
beginFrame(): boolean;
76+
readonly pageLayoutVersion: number;
7577

7678
/**
7779
* Clear all glyphs from the texture atlas.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/**
2+
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { IImage32, decodePng } from '@lunapaint/png-codec';
7+
import test, { expect } from '@playwright/test';
8+
import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm';
9+
import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl';
10+
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
11+
12+
type CellSignature = number[];
13+
type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal;
14+
type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon;
15+
16+
interface ITestTextureAtlasConstructor {
17+
maxAtlasPages: number | undefined;
18+
}
19+
20+
interface ITestTextureAtlas {
21+
constructor: ITestTextureAtlasConstructor;
22+
}
23+
24+
interface ITestRenderer {
25+
_charAtlas?: ITestTextureAtlas;
26+
}
27+
28+
interface ITestRenderService {
29+
_renderer?: {
30+
value?: ITestRenderer;
31+
};
32+
}
33+
34+
interface ITestTerminal extends Terminal {
35+
_core?: {
36+
_renderService?: ITestRenderService;
37+
};
38+
}
39+
40+
interface ITestWebglAddon extends WebglAddon {
41+
_renderer?: ITestRenderer;
42+
}
43+
44+
declare global {
45+
interface Window { // eslint-disable-line @typescript-eslint/naming-convention
46+
Terminal: TestTerminalConstructor;
47+
WebglAddon: TestWebglAddonConstructor;
48+
term: ITestTerminal;
49+
termB?: ITestTerminal;
50+
addon?: ITestWebglAddon;
51+
addonB?: ITestWebglAddon;
52+
}
53+
}
54+
55+
const HEADER = ' HEADER_REF_0123456789';
56+
const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
57+
const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen';
58+
59+
async function loadWebglStrict(ctx: ITestContext): Promise<void> {
60+
await ctx.page.evaluate(() => {
61+
window.addon = new window.WebglAddon({ preserveDrawingBuffer: true });
62+
window.term.loadAddon(window.addon);
63+
});
64+
const isWebglRenderer = await ctx.page.evaluate(() => {
65+
return !!window.addon && window.term?._core?._renderService?._renderer?.value === window.addon._renderer;
66+
});
67+
expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true);
68+
}
69+
70+
async function createTerminalB(ctx: ITestContext): Promise<void> {
71+
await ctx.page.evaluate(() => {
72+
window.termB?.dispose();
73+
document.getElementById('terminal-container-b')?.remove();
74+
const el = document.createElement('div');
75+
el.id = 'terminal-container-b';
76+
document.body.appendChild(el);
77+
window.termB = new window.Terminal({ cols: 80, rows: 24, allowProposedApi: true });
78+
window.termB.open(el);
79+
window.addonB = new window.WebglAddon({ preserveDrawingBuffer: true });
80+
window.termB.loadAddon(window.addonB);
81+
});
82+
}
83+
84+
async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
85+
await ctx.page.evaluate(d => new Promise<void>(resolve => {
86+
const termB = window.termB;
87+
if (!termB) {
88+
throw new Error('Terminal B must be created before writing to it');
89+
}
90+
const disposable = termB.onRender(() => {
91+
disposable.dispose();
92+
resolve();
93+
});
94+
termB.write(d);
95+
}), data);
96+
}
97+
98+
async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
99+
const renderPromise = new Promise<void>(resolve => {
100+
const disposable = ctx.proxy.onRender(() => {
101+
disposable.dispose();
102+
resolve();
103+
});
104+
});
105+
await ctx.proxy.write(data);
106+
await renderPromise;
107+
}
108+
109+
async function setMaxAtlasPages(ctx: ITestContext, maxAtlasPages: number): Promise<void> {
110+
const applied = await ctx.page.evaluate(max => {
111+
const atlas = window.term?._core?._renderService?._renderer?.value?._charAtlas;
112+
if (!atlas) {
113+
return false;
114+
}
115+
atlas.constructor.maxAtlasPages = max;
116+
return true;
117+
}, maxAtlasPages);
118+
expect(applied, 'should be able to set TextureAtlas.maxAtlasPages').toBe(true);
119+
}
120+
121+
async function resetMaxAtlasPages(ctx: ITestContext): Promise<void> {
122+
await ctx.page.evaluate(() => {
123+
const atlas = window.term?._core?._renderService?._renderer?.value?._charAtlas;
124+
if (atlas) {
125+
atlas.constructor.maxAtlasPages = undefined;
126+
}
127+
});
128+
}
129+
130+
function generateColoredAsciiFlood(cells: number, offset: number = 0): string {
131+
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
132+
let out = '';
133+
for (let i = 0; i < cells; i++) {
134+
const ch = letters[(offset + i) % letters.length];
135+
const fg = (offset + i) % 256;
136+
const bg = (offset + i * 7) % 256;
137+
out += `\x1b[38;5;${fg}m\x1b[48;5;${bg}m${ch}`;
138+
if ((i + 1) % 78 === 0 && i + 1 < cells) {
139+
out += '\x1b[0m\r\n';
140+
}
141+
}
142+
return out + '\x1b[0m';
143+
}
144+
145+
async function captureRowSignatures(ctx: ITestContext, row: number, cols: number[]): Promise<CellSignature[]> {
146+
const screenshotOptions = process.env.DEBUG ? { path: 'out-esbuild-test/playwright/shared-atlas-term-b.png' } : undefined;
147+
const buffer = await ctx.page.locator(TERM_B_SELECTOR).screenshot(screenshotOptions);
148+
const frame = {
149+
cols: 80,
150+
rows: 24,
151+
decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image
152+
};
153+
return cols.map(col => cellSignature(frame, col, row));
154+
}
155+
156+
function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature {
157+
const grid = 4;
158+
const data = frame.decoded.data;
159+
const imageWidth = frame.decoded.width;
160+
const cellWidth = frame.decoded.width / frame.cols;
161+
const cellHeight = frame.decoded.height / frame.rows;
162+
const x0 = (col - 1) * cellWidth;
163+
const y0 = (row - 1) * cellHeight;
164+
const sums = new Array<number>(grid * grid * 4).fill(0);
165+
const counts = new Array<number>(grid * grid).fill(0);
166+
const startX = Math.max(0, Math.floor(x0));
167+
const startY = Math.max(0, Math.floor(y0));
168+
const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth));
169+
const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight));
170+
171+
for (let y = startY; y < endY; y++) {
172+
const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid)));
173+
for (let x = startX; x < endX; x++) {
174+
const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid)));
175+
const sub = gy * grid + gx;
176+
const i = (y * imageWidth + x) * 4;
177+
sums[sub * 4] += data[i];
178+
sums[sub * 4 + 1] += data[i + 1];
179+
sums[sub * 4 + 2] += data[i + 2];
180+
sums[sub * 4 + 3] += data[i + 3];
181+
counts[sub]++;
182+
}
183+
}
184+
185+
const signature = new Array<number>(grid * grid * 4).fill(0);
186+
for (let sub = 0; sub < counts.length; sub++) {
187+
if (counts[sub] > 0) {
188+
signature[sub * 4] = sums[sub * 4] / counts[sub];
189+
signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub];
190+
signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub];
191+
signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub];
192+
}
193+
}
194+
return signature;
195+
}
196+
197+
function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void {
198+
let diff = 0;
199+
for (let i = 0; i < actual.length; i++) {
200+
diff = Math.max(diff, Math.abs(actual[i] - reference[i]));
201+
}
202+
expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14);
203+
}
204+
205+
test.describe('shared-atlas garble across terminals (#6038)', () => {
206+
test.skip(({ browserName }) => browserName !== 'chromium');
207+
test.describe.configure({ timeout: 60000 });
208+
209+
test('keeps a second terminal rendering correctly after shared atlas page merges', async ({ browser }) => {
210+
const ctx = await createTestContext(browser);
211+
try {
212+
await openTerminal(ctx, { cols: 80, rows: 24 });
213+
await loadWebglStrict(ctx);
214+
await createTerminalB(ctx);
215+
216+
const atlasShared = await ctx.page.evaluate(() => {
217+
return window.term._core?._renderService?._renderer?.value?._charAtlas === window.termB?._core?._renderService?._renderer?.value?._charAtlas;
218+
});
219+
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
220+
221+
await setMaxAtlasPages(ctx, 4);
222+
223+
await writeToBAndWaitForRender(ctx, HEADER);
224+
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS);
225+
226+
for (let c = 0; c < 10; c++) {
227+
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78));
228+
}
229+
230+
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
231+
const termBHeader = await ctx.page.evaluate(() => window.termB?.buffer.active.getLine(0)?.translateToString(true));
232+
expect(termBHeader, 'terminal B buffer must still contain the header').toBe(HEADER);
233+
234+
const after = await captureRowSignatures(ctx, 1, HEADER_COLS);
235+
for (let i = 0; i < HEADER_COLS.length; i++) {
236+
expectSignatureMatches(after[i], reference[i], `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas merges`);
237+
}
238+
} finally {
239+
await resetMaxAtlasPages(ctx).catch(() => {});
240+
await ctx.page.close();
241+
}
242+
});
243+
});

0 commit comments

Comments
 (0)