diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 3ca16927..ffcc5866 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -762,13 +762,32 @@ export const TerminalView = memo(function TerminalView({ refit(); }; document.fonts?.addEventListener("loadingdone", onFontLoaded); - // The browser may drop or reuse the WebGL glyph atlas's GPU memory without - // a context-loss event (sleep/resume, a long-backgrounded tab, a display or - // zoom change on macOS): xterm keeps slicing the old coordinates and every - // cell shows a clipped or foreign glyph while the buffer stays correct. - // Rebuilding the atlas when the page comes back is cheap and redraws clean. + // @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal + // with the same font, and when it fills up it merges four pages into one + // larger page and shifts the rest down. A GPU texture is re-uploaded only + // when its page's `version` differs from the uploaded one, and after a + // shift those counters can coincide, so a slot keeps a stale page of + // another size: every glyph is sampled squeezed or foreign while the + // buffer stays correct (fixed upstream only in 0.20 betas). Any option + // change makes each renderer re-acquire its atlas and re-upload all pages, + // so this toggles the family's trailing space (as onFontLoaded does) on + // a merge. `clearTextureAtlas` is no fix: it empties the SHARED atlas + // but redraws only this pane, leaving its siblings slicing reused space. + let atlasFrame = 0; + const rebuildAtlas = () => { + if (atlasFrame) return; + // Never inside the render that is rasterizing the merging glyph. + atlasFrame = requestAnimationFrame(() => { + atlasFrame = 0; + const family = term.options.fontFamily ?? ""; + term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `; + }); + }; + const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas); + // The browser may also drop the textures without a context-loss event + // (sleep/resume, a long-backgrounded tab): rebuild when the page is back. const onPageShown = () => { - if (document.visibilityState === "visible") term.clearTextureAtlas(); + if (document.visibilityState === "visible") rebuildAtlas(); }; document.addEventListener("visibilitychange", onPageShown); window.addEventListener("focus", onPageShown); @@ -790,6 +809,8 @@ export const TerminalView = memo(function TerminalView({ searchRef.current = null; resizeObserver.disconnect(); document.fonts?.removeEventListener("loadingdone", onFontLoaded); + if (atlasFrame) cancelAnimationFrame(atlasFrame); + atlasMerged?.dispose(); document.removeEventListener("visibilitychange", onPageShown); window.removeEventListener("focus", onPageShown); window.removeEventListener(FONT_SIZE_EVENT, onFontPref); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 6a1428df..df164860 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -112,12 +112,15 @@ vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), })); // The newest WebGL addon, with the context-loss listener the view gave it. -const gpu: { last: { dispose: ReturnType; lose: () => void } | null } = { +const gpu: { + last: { dispose: ReturnType; lose: () => void; merge: () => void } | null; +} = { last: null, }; vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: vi.fn().mockImplementation(() => { let onLoss = () => {}; + let onMerge = () => {}; const addon = { __webgl: true, dispose: vi.fn(), @@ -125,8 +128,12 @@ vi.mock("@xterm/addon-webgl", () => ({ onLoss = fn; return { dispose: vi.fn() }; }, + onRemoveTextureAtlasCanvas: (fn: () => void) => { + onMerge = fn; + return { dispose: vi.fn() }; + }, }; - gpu.last = { dispose: addon.dispose, lose: () => onLoss() }; + gpu.last = { dispose: addon.dispose, lose: () => onLoss(), merge: () => onMerge() }; return addon; }), })); @@ -488,6 +495,22 @@ describe("TerminalView", () => { expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on }); + it("re-acquires the glyph atlas on the next frame after a page merge", () => { + // addon-webgl 0.19 may keep a stale page in a GPU slot after it merges + // atlas pages; any option change makes it re-upload every page. + const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1); + render(); + const opts = termOptions as { fontFamily?: string }; + opts.fontFamily = "JetBrains Mono"; + gpu.last!.merge(); + gpu.last!.merge(); // coalesced into one frame + expect(raf).toHaveBeenCalledTimes(1); + expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged + (raf.mock.calls[0]![0] as FrameRequestCallback)(0); + expect(opts.fontFamily).toBe("JetBrains Mono "); + raf.mockRestore(); + }); + // xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it. it("sends Alt+arrow as the word-move keys xterm 5 sent", () => { expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");