Merge branch 'master' into image-ext
This commit is contained in:
+32
-17
@@ -6,14 +6,19 @@ on:
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
@@ -69,7 +74,7 @@ jobs:
|
||||
./addons/addon-webgl/out/* \
|
||||
./addons/addon-webgl/out-*st/*
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: compressed-build.zip
|
||||
@@ -79,9 +84,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
@@ -100,16 +107,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -137,16 +146,18 @@ jobs:
|
||||
runs-on: [ubuntu, macos, windows]
|
||||
runs-on: ${{ matrix.runs-on }}-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -171,9 +182,11 @@ jobs:
|
||||
browser: [chromium, firefox, webkit]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
@@ -182,7 +195,7 @@ jobs:
|
||||
npm ci
|
||||
- name: Install playwright
|
||||
run: npx playwright install --with-deps ${{ matrix.browser }}
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -232,9 +245,11 @@ jobs:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
@@ -243,7 +258,7 @@ jobs:
|
||||
npm ci
|
||||
- name: Install playwright
|
||||
run: npx playwright install
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
|
||||
@@ -8,6 +8,11 @@ on:
|
||||
schedule:
|
||||
- cron: '41 17 * * 0'
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
@@ -24,12 +29,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
|
||||
@@ -9,6 +9,9 @@ on:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -18,9 +21,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
|
||||
@@ -6,13 +6,18 @@ on:
|
||||
# without running tests to save time to publish
|
||||
branches: [ "master" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
|
||||
@@ -255,6 +255,12 @@ Xterm.js is used in many world-class applications to provide great terminal expe
|
||||
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
|
||||
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
|
||||
- [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty.
|
||||
- [**OmnyShell PWA**](https://github.com/OmnyGrid/omnyshell_web): Web client for [**OmnyShell**](https://github.com/OmnyGrid/omnyshell), featuring remote terminals, a built-in IDE, AI-assisted workflows, and detachable/resumable sessions for seamless browser-based access to nodes.
|
||||
- [**kmux**](https://github.com/kkd927/kmux): A keyboard-centric terminal workspace for running parallel AI coding agents side by side on macOS and Linux.
|
||||
- [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management.
|
||||
- [**Codeman**](https://github.com/Ark0N/Codeman): Self-hosted mission control for AI coding agents, streaming live tmux sessions to browser and phone.
|
||||
- [**JSVIsion**](https://github.com/blendsdk/jsvision): A Turbo Vision-Style TUI SDK for TypeScript. We use xterm.js in our docs to show live JSVision examples running in a web terminal.
|
||||
- [**BitFun**](https://github.com/GCWing/BitFun): Open-source desktop AI agent whose integrated terminals and read-only terminal output views use xterm.js with the Fit, Web Links, and WebGL addons.
|
||||
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
|
||||
|
||||
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
|
||||
|
||||
@@ -100,6 +100,8 @@ export class GlyphRenderer extends Disposable {
|
||||
private readonly _attributesBuffer: WebGLBuffer;
|
||||
|
||||
private _atlas: ITextureAtlas | undefined;
|
||||
private _lastSeenPageLayoutVersion: number = -1;
|
||||
private _pageOverflowWarned: boolean = false;
|
||||
private _activeBuffer: number = 0;
|
||||
private readonly _vertices: IVertices = {
|
||||
count: 0,
|
||||
@@ -213,8 +215,19 @@ export class GlyphRenderer extends Disposable {
|
||||
this.handleResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when a frame is being drawn. Returns whether the full model must be rebuilt before
|
||||
* rendering this frame because the atlas page layout changed since this renderer last drew.
|
||||
*/
|
||||
public beginFrame(): boolean {
|
||||
return this._atlas ? this._atlas.beginFrame() : true;
|
||||
if (!this._atlas) {
|
||||
return true;
|
||||
}
|
||||
if (this._atlas.pageLayoutVersion !== this._lastSeenPageLayoutVersion) {
|
||||
this._lastSeenPageLayoutVersion = this._atlas.pageLayoutVersion;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
|
||||
@@ -363,7 +376,13 @@ export class GlyphRenderer extends Disposable {
|
||||
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
|
||||
// monotonic, so a page object swap at the same index (which happens after a page merge)
|
||||
// is detected by the same comparison.
|
||||
for (let i = 0; i < this._atlas.pages.length; i++) {
|
||||
// Clamp defensively in case the atlas unexpectedly exceeds texture capacity.
|
||||
const pageCount = Math.min(this._atlas.pages.length, this._atlasTextures.length);
|
||||
if (this._atlas.pages.length > this._atlasTextures.length && !this._pageOverflowWarned) {
|
||||
this._pageOverflowWarned = true;
|
||||
this._logService.warn(`Atlas page count (${this._atlas.pages.length}) exceeds the renderer's texture capacity (${this._atlasTextures.length}), some glyphs will not render correctly`);
|
||||
}
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
|
||||
this._bindAtlasPageTexture(gl, this._atlas, i);
|
||||
}
|
||||
@@ -375,6 +394,7 @@ export class GlyphRenderer extends Disposable {
|
||||
|
||||
public setAtlas(atlas: ITextureAtlas): void {
|
||||
this._atlas = atlas;
|
||||
this._lastSeenPageLayoutVersion = -1;
|
||||
this.invalidateAtlasTextures();
|
||||
}
|
||||
|
||||
|
||||
@@ -132,12 +132,8 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
private _requestClearModel = false;
|
||||
public beginFrame(): boolean {
|
||||
const result = this._requestClearModel;
|
||||
this._requestClearModel = false;
|
||||
return result;
|
||||
}
|
||||
private _pageLayoutVersion = 0;
|
||||
public get pageLayoutVersion(): number { return this._pageLayoutVersion; }
|
||||
|
||||
public clearTexture(): void {
|
||||
if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
|
||||
@@ -149,6 +145,11 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
this._cacheMap.clear();
|
||||
this._cacheMapCombined.clear();
|
||||
this._didWarmUp = false;
|
||||
|
||||
// Invalidate renderer models so all texture pages are refreshed. The atlas may be shared, in
|
||||
// which case the clearing renderer has cleared only its own model and every other owner still
|
||||
// holds texture coords into the rows just wiped.
|
||||
this._pageLayoutVersion++;
|
||||
}
|
||||
|
||||
private _createNewPage(): AtlasPage {
|
||||
@@ -184,6 +185,8 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
// Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot
|
||||
// effectively reduce page count and merging would cause issues.
|
||||
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) {
|
||||
// Evict instead of adding a page beyond the renderer's texture capacity.
|
||||
this._evictAllPages();
|
||||
const newPage = new AtlasPage(this._document, this._textureSize);
|
||||
this._pages.push(newPage);
|
||||
this._activePages.push(newPage);
|
||||
@@ -206,8 +209,8 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
// Add the new merged page to the end
|
||||
this.pages.push(mergedPage);
|
||||
|
||||
// Request the model to be cleared to refresh all texture pages.
|
||||
this._requestClearModel = true;
|
||||
// Invalidate renderer models so all texture pages are refreshed.
|
||||
this._pageLayoutVersion++;
|
||||
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
|
||||
}
|
||||
|
||||
@@ -258,6 +261,23 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
private _evictAllPages(): void {
|
||||
const startTime = performance.now();
|
||||
const pageCount = this._pages.length;
|
||||
for (const page of this._pages) {
|
||||
this._onRemoveTextureAtlasCanvas.fire(page.canvas);
|
||||
page.canvas.remove();
|
||||
}
|
||||
this._pages.length = 0;
|
||||
this._activePages.length = 0;
|
||||
this._overflowSizePage = undefined;
|
||||
this._cacheMap.clear();
|
||||
this._cacheMapCombined.clear();
|
||||
this._didWarmUp = false;
|
||||
this._pageLayoutVersion++;
|
||||
this._logService.debug(`Evicted ${pageCount} WebGL atlas pages in ${(performance.now() - startTime).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
|
||||
return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext, restrictToCellHeight, domContainer);
|
||||
}
|
||||
@@ -815,11 +835,15 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
// Create a new page for oversized glyphs as they come up
|
||||
if (rasterizedGlyph.size.x > this._textureSize) {
|
||||
if (!this._overflowSizePage) {
|
||||
// Make room for the oversized page without exceeding texture capacity.
|
||||
if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) {
|
||||
this._evictAllPages();
|
||||
}
|
||||
this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
|
||||
this.pages.push(this._overflowSizePage);
|
||||
|
||||
// Request the model to be cleared to refresh all texture pages.
|
||||
this._requestClearModel = true;
|
||||
// Invalidate renderer models so all texture pages are refreshed.
|
||||
this._pageLayoutVersion++;
|
||||
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
|
||||
}
|
||||
activePage = this._overflowSizePage;
|
||||
|
||||
@@ -68,10 +68,12 @@ export interface ITextureAtlas extends IDisposable {
|
||||
warmUp(): void;
|
||||
|
||||
/**
|
||||
* Call when a frame is being drawn, this will return true if the atlas was cleared to make room
|
||||
* for a new set of glyphs.
|
||||
* Incremented whenever cached glyph texture page mappings may be stale, such as after atlas page
|
||||
* merges or overflow page creation. Renderers compare this against their own last-seen value and
|
||||
* rebuild their model when it changes; a shared atlas can have many renderers, so this must not
|
||||
* be a consume-once flag.
|
||||
*/
|
||||
beginFrame(): boolean;
|
||||
readonly pageLayoutVersion: number;
|
||||
|
||||
/**
|
||||
* Clear all glyphs from the texture atlas.
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IImage32, decodePng } from '@lunapaint/png-codec';
|
||||
import test, { expect } from '@playwright/test';
|
||||
import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm';
|
||||
import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
|
||||
type CellSignature = number[];
|
||||
type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal;
|
||||
type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon;
|
||||
|
||||
interface ITestTextureAtlasConstructor {
|
||||
maxAtlasPages: number | undefined;
|
||||
maxTextureSize: number | undefined;
|
||||
}
|
||||
|
||||
interface ITestAtlasPage {
|
||||
canvas: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
interface ITestTextureAtlas {
|
||||
constructor: ITestTextureAtlasConstructor;
|
||||
pages: ITestAtlasPage[];
|
||||
pageLayoutVersion: number;
|
||||
_overflowSizePage?: ITestAtlasPage;
|
||||
_textureSize: number;
|
||||
}
|
||||
|
||||
interface ITestGlyphRenderer {
|
||||
_atlasTextures: unknown[];
|
||||
}
|
||||
|
||||
interface ITestRenderer {
|
||||
_charAtlas?: ITestTextureAtlas;
|
||||
_glyphRenderer?: {
|
||||
value?: ITestGlyphRenderer;
|
||||
};
|
||||
dimensions: {
|
||||
device: {
|
||||
cell: {
|
||||
width: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestRenderService {
|
||||
_renderer?: {
|
||||
value?: ITestRenderer;
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestTerminal extends Terminal {
|
||||
_core?: {
|
||||
_renderService?: ITestRenderService;
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestWebglAddon extends WebglAddon {
|
||||
_renderer?: ITestRenderer;
|
||||
}
|
||||
|
||||
interface IAtlasEventStats {
|
||||
removals: number;
|
||||
maxPages: number;
|
||||
}
|
||||
|
||||
interface ITestWindow extends Window {
|
||||
Terminal: TestTerminalConstructor;
|
||||
WebglAddon: TestWebglAddonConstructor;
|
||||
term: ITestTerminal;
|
||||
termB?: ITestTerminal;
|
||||
addon?: ITestWebglAddon;
|
||||
addonB?: ITestWebglAddon;
|
||||
atlasEventStats?: IAtlasEventStats;
|
||||
}
|
||||
|
||||
interface IAtlasLimits {
|
||||
maxAtlasPages: number;
|
||||
maxTextureSize: number;
|
||||
}
|
||||
|
||||
interface IAtlasState {
|
||||
pages: number;
|
||||
atlasTextures: number;
|
||||
pageLayoutVersion: number;
|
||||
overflowPageCreated: boolean;
|
||||
removals: number;
|
||||
maxPages: number;
|
||||
}
|
||||
|
||||
const NORMAL_HEADER = ' NORMAL_REF_0123456789';
|
||||
const POST_EVICTION_TEXT = ' AFTER_EVICTION_0123456789';
|
||||
const NORMAL_HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const POST_EVICTION_COLS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen';
|
||||
const TERM_SELECTOR = '#terminal-container .xterm-screen';
|
||||
|
||||
async function loadWebglStrict(ctx: ITestContext): Promise<void> {
|
||||
await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
w.addon = new w.WebglAddon({ preserveDrawingBuffer: true });
|
||||
w.term.loadAddon(w.addon);
|
||||
});
|
||||
const isWebglRenderer = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
return !!w.addon && w.term._core?._renderService?._renderer?.value === w.addon._renderer;
|
||||
});
|
||||
expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true);
|
||||
}
|
||||
|
||||
async function createTerminalB(ctx: ITestContext, cols: number): Promise<void> {
|
||||
await ctx.page.evaluate(columnCount => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
w.termB?.dispose();
|
||||
document.getElementById('terminal-container-b')?.remove();
|
||||
const element = document.createElement('div');
|
||||
element.id = 'terminal-container-b';
|
||||
document.body.appendChild(element);
|
||||
w.termB = new w.Terminal({ cols: columnCount, rows: 24, allowProposedApi: true });
|
||||
w.termB.open(element);
|
||||
w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true });
|
||||
w.termB.loadAddon(w.addonB);
|
||||
}, cols);
|
||||
}
|
||||
|
||||
async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
|
||||
await ctx.page.evaluate(value => new Promise<void>(resolve => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
if (!w.termB) {
|
||||
throw new Error('Terminal B must be created before writing to it');
|
||||
}
|
||||
const disposable = w.termB.onRender(() => {
|
||||
disposable.dispose();
|
||||
resolve();
|
||||
});
|
||||
w.termB.write(value);
|
||||
}), data);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function refreshAndWaitForRender(ctx: ITestContext, terminal: 'term' | 'termB'): Promise<void> {
|
||||
await ctx.page.evaluate(name => new Promise<void>(resolve => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
const term = w[name];
|
||||
if (!term) {
|
||||
throw new Error(`${name} must exist before refreshing it`);
|
||||
}
|
||||
const disposable = term.onRender(() => {
|
||||
disposable.dispose();
|
||||
resolve();
|
||||
});
|
||||
term.refresh(0, term.rows - 1);
|
||||
}), terminal);
|
||||
}
|
||||
|
||||
async function configureAtlasLimits(ctx: ITestContext): Promise<IAtlasLimits> {
|
||||
const limits = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas;
|
||||
if (!atlas || atlas.constructor.maxAtlasPages === undefined || atlas.constructor.maxTextureSize === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const result = {
|
||||
maxAtlasPages: atlas.constructor.maxAtlasPages,
|
||||
maxTextureSize: atlas.constructor.maxTextureSize
|
||||
};
|
||||
atlas.constructor.maxAtlasPages = 4;
|
||||
atlas.constructor.maxTextureSize = 512;
|
||||
return result;
|
||||
});
|
||||
expect(limits, 'TextureAtlas limits must be initialized').toBeDefined();
|
||||
return limits!;
|
||||
}
|
||||
|
||||
async function restoreAtlasLimits(ctx: ITestContext, limits: IAtlasLimits | undefined): Promise<void> {
|
||||
if (!limits) {
|
||||
return;
|
||||
}
|
||||
await ctx.page.evaluate(original => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas;
|
||||
if (atlas) {
|
||||
atlas.constructor.maxAtlasPages = original.maxAtlasPages;
|
||||
atlas.constructor.maxTextureSize = original.maxTextureSize;
|
||||
}
|
||||
}, limits);
|
||||
}
|
||||
|
||||
async function installAtlasEventStats(ctx: ITestContext): Promise<void> {
|
||||
const installed = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas;
|
||||
if (!atlas || !w.addon) {
|
||||
return false;
|
||||
}
|
||||
const stats: IAtlasEventStats = {
|
||||
removals: 0,
|
||||
maxPages: atlas.pages.length
|
||||
};
|
||||
w.atlasEventStats = stats;
|
||||
w.addon.onAddTextureAtlasCanvas(() => {
|
||||
stats.maxPages = Math.max(stats.maxPages, atlas.pages.length);
|
||||
});
|
||||
w.addon.onRemoveTextureAtlasCanvas(() => stats.removals++);
|
||||
return true;
|
||||
});
|
||||
expect(installed, 'atlas event tracking must be installed').toBe(true);
|
||||
}
|
||||
|
||||
async function getAtlasState(ctx: ITestContext): Promise<IAtlasState> {
|
||||
const state = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
const renderer = w.term._core?._renderService?._renderer?.value;
|
||||
const atlas = renderer?._charAtlas;
|
||||
const glyphRenderer = renderer?._glyphRenderer?.value;
|
||||
const stats = w.atlasEventStats;
|
||||
if (!atlas || !glyphRenderer || !stats) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
pages: atlas.pages.length,
|
||||
atlasTextures: glyphRenderer._atlasTextures.length,
|
||||
pageLayoutVersion: atlas.pageLayoutVersion,
|
||||
overflowPageCreated: !!atlas._overflowSizePage,
|
||||
removals: stats.removals,
|
||||
maxPages: stats.maxPages
|
||||
};
|
||||
});
|
||||
expect(state, 'atlas state must be available').toBeDefined();
|
||||
return state!;
|
||||
}
|
||||
|
||||
function generateUniqueGlyphFlood(count: number, cols: number, offset: number): string {
|
||||
const base = 0x4E00;
|
||||
const range = 0x9FFF - base;
|
||||
const perRow = Math.max(1, Math.floor(cols / 2));
|
||||
let result = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
result += String.fromCodePoint(base + ((offset + i) % range));
|
||||
if ((i + 1) % perRow === 0 && i + 1 < count) {
|
||||
result += '\r\n';
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function captureRowSignatures(ctx: ITestContext, selector: string, cols: number, row: number, sampleCols: number[], debugName: string): Promise<CellSignature[]> {
|
||||
const screenshotOptions = process.env.DEBUG ? { path: `out-esbuild-test/playwright/${debugName}.png` } : undefined;
|
||||
const buffer = await ctx.page.locator(selector).screenshot(screenshotOptions);
|
||||
const frame = {
|
||||
cols,
|
||||
rows: 24,
|
||||
decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image
|
||||
};
|
||||
return sampleCols.map(col => cellSignature(frame, col, row));
|
||||
}
|
||||
|
||||
function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature {
|
||||
const grid = 4;
|
||||
const data = frame.decoded.data;
|
||||
const imageWidth = frame.decoded.width;
|
||||
const cellWidth = frame.decoded.width / frame.cols;
|
||||
const cellHeight = frame.decoded.height / frame.rows;
|
||||
const x0 = (col - 1) * cellWidth;
|
||||
const y0 = (row - 1) * cellHeight;
|
||||
const sums = new Array<number>(grid * grid * 4).fill(0);
|
||||
const counts = new Array<number>(grid * grid).fill(0);
|
||||
const startX = Math.max(0, Math.floor(x0));
|
||||
const startY = Math.max(0, Math.floor(y0));
|
||||
const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth));
|
||||
const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight));
|
||||
|
||||
for (let y = startY; y < endY; y++) {
|
||||
const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid)));
|
||||
for (let x = startX; x < endX; x++) {
|
||||
const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid)));
|
||||
const sub = gy * grid + gx;
|
||||
const i = (y * imageWidth + x) * 4;
|
||||
sums[sub * 4] += data[i];
|
||||
sums[sub * 4 + 1] += data[i + 1];
|
||||
sums[sub * 4 + 2] += data[i + 2];
|
||||
sums[sub * 4 + 3] += data[i + 3];
|
||||
counts[sub]++;
|
||||
}
|
||||
}
|
||||
|
||||
const signature = new Array<number>(grid * grid * 4).fill(0);
|
||||
for (let sub = 0; sub < counts.length; sub++) {
|
||||
if (counts[sub] > 0) {
|
||||
signature[sub * 4] = sums[sub * 4] / counts[sub];
|
||||
signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub];
|
||||
signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub];
|
||||
signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub];
|
||||
}
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
function signatureDiff(actual: CellSignature, reference: CellSignature): number {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
diff = Math.max(diff, Math.abs(actual[i] - reference[i]));
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void {
|
||||
const diff = signatureDiff(actual, reference);
|
||||
expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14);
|
||||
}
|
||||
|
||||
test.describe('atlas page overflow (#6038)', () => {
|
||||
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
|
||||
test('evicts normal pages when the page cap cannot be reduced by merging', async ({ browser }) => {
|
||||
const ctx = await createTestContext(browser);
|
||||
const errors: string[] = [];
|
||||
const onError = (error: Error): void => { errors.push(error.message); };
|
||||
let limits: IAtlasLimits | undefined;
|
||||
ctx.page.on('pageerror', onError);
|
||||
try {
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
limits = await configureAtlasLimits(ctx);
|
||||
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
await createTerminalB(ctx, 80);
|
||||
await installAtlasEventStats(ctx);
|
||||
|
||||
const atlasShared = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
return w.term._core?._renderService?._renderer?.value?._charAtlas === w.termB?._core?._renderService?._renderer?.value?._charAtlas;
|
||||
});
|
||||
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
|
||||
|
||||
await writeToBAndWaitForRender(ctx, `\x1b[H${NORMAL_HEADER}\x1b[2;1H${POST_EVICTION_TEXT}`);
|
||||
const headerReference = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 1, NORMAL_HEADER_COLS, 'atlas-overflow-header-reference');
|
||||
const postReference = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 2, POST_EVICTION_COLS, 'atlas-overflow-post-reference');
|
||||
await writeToBAndWaitForRender(ctx, '\x1b[2;1H\x1b[2K');
|
||||
|
||||
const initialState = await getAtlasState(ctx);
|
||||
const glyphsPerChunk = 23 * 40 - 1;
|
||||
for (let chunk = 0; chunk < 24; chunk++) {
|
||||
await ctx.proxy.write('\x1b[H\x1b[2J' + generateUniqueGlyphFlood(glyphsPerChunk, 80, chunk * glyphsPerChunk));
|
||||
await ctx.page.waitForTimeout(50);
|
||||
const state = await getAtlasState(ctx);
|
||||
if (state.removals > 0 || state.pages > state.atlasTextures || errors.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const evictionState = await getAtlasState(ctx);
|
||||
expect(evictionState.removals, 'normal atlas pages must be evicted at the page cap').toBeGreaterThan(0);
|
||||
expect(evictionState.pageLayoutVersion, 'eviction must invalidate renderer models').toBeGreaterThan(initialState.pageLayoutVersion);
|
||||
expect(evictionState.maxPages, 'atlas pages must stay within renderer texture capacity').toBeLessThanOrEqual(evictionState.atlasTextures);
|
||||
expect(errors, `renderer must not throw during atlas eviction: ${errors[0] ?? ''}`).toEqual([]);
|
||||
|
||||
await writeToBAndWaitForRender(ctx, `\x1b[2;1H${POST_EVICTION_TEXT}`);
|
||||
const termBHeader = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
return w.termB?.buffer.active.getLine(0)?.translateToString(true);
|
||||
});
|
||||
expect(termBHeader, 'terminal B buffer must still contain the header').toBe(NORMAL_HEADER);
|
||||
|
||||
const headerAfter = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 1, NORMAL_HEADER_COLS, 'atlas-overflow-header-after');
|
||||
const postAfter = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 2, POST_EVICTION_COLS, 'atlas-overflow-post-after');
|
||||
for (let i = 0; i < NORMAL_HEADER_COLS.length; i++) {
|
||||
expectSignatureMatches(headerAfter[i], headerReference[i], `existing header col ${NORMAL_HEADER_COLS[i]} changed after eviction`);
|
||||
}
|
||||
for (let i = 0; i < POST_EVICTION_COLS.length; i++) {
|
||||
expectSignatureMatches(postAfter[i], postReference[i], `new text col ${POST_EVICTION_COLS[i]} changed after eviction`);
|
||||
}
|
||||
|
||||
const stableState = await getAtlasState(ctx);
|
||||
await refreshAndWaitForRender(ctx, 'termB');
|
||||
const refreshedState = await getAtlasState(ctx);
|
||||
expect(refreshedState.pageLayoutVersion, 'an unchanged full refresh must not evict again').toBe(stableState.pageLayoutVersion);
|
||||
expect(refreshedState.removals, 'an unchanged full refresh must not remove more pages').toBe(stableState.removals);
|
||||
} finally {
|
||||
ctx.page.off('pageerror', onError);
|
||||
await restoreAtlasLimits(ctx, limits).catch(() => {});
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('evicts before adding an oversized glyph page at the page cap', async ({ browser }) => {
|
||||
const ctx = await createTestContext(browser);
|
||||
const errors: string[] = [];
|
||||
const onError = (error: Error): void => { errors.push(error.message); };
|
||||
let limits: IAtlasLimits | undefined;
|
||||
ctx.page.on('pageerror', onError);
|
||||
try {
|
||||
await openTerminal(ctx, { cols: 160, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
limits = await configureAtlasLimits(ctx);
|
||||
|
||||
await openTerminal(ctx, { cols: 160, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
await installAtlasEventStats(ctx);
|
||||
|
||||
let state = await getAtlasState(ctx);
|
||||
let offset = 0;
|
||||
for (let chunk = 0; chunk < 120 && state.pages < state.atlasTextures; chunk++) {
|
||||
const glyphCount = state.pages < state.atlasTextures - 1 ? 200 : 25;
|
||||
await ctx.proxy.write('\x1b[H\x1b[2J' + generateUniqueGlyphFlood(glyphCount, 160, offset));
|
||||
await ctx.page.waitForTimeout(50);
|
||||
offset += glyphCount;
|
||||
state = await getAtlasState(ctx);
|
||||
}
|
||||
expect(state.pages, 'normal atlas pages must reach the texture capacity before the oversized glyph').toBe(state.atlasTextures);
|
||||
expect(state.overflowPageCreated, 'normal glyph filling must not create the oversized page').toBe(false);
|
||||
expect(errors, `renderer must not fail while filling normal pages: ${errors[0] ?? ''}`).toEqual([]);
|
||||
|
||||
await writeAndWaitForRender(ctx, '\x1b[H\x1b[2K\x1b[24;1H');
|
||||
const sampleCols = [1, 8, 16, 24, 32];
|
||||
const blankSignatures = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-blank');
|
||||
const joinedLength = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
const renderer = w.term._core?._renderService?._renderer?.value;
|
||||
const atlas = renderer?._charAtlas;
|
||||
if (!renderer || !atlas) {
|
||||
throw new Error('renderer and atlas must exist');
|
||||
}
|
||||
const cellWidth = renderer.dimensions.device.cell.width;
|
||||
const length = Math.min(w.term.cols - 1, Math.ceil(atlas._textureSize / cellWidth) + 32);
|
||||
if (length * cellWidth <= atlas._textureSize) {
|
||||
throw new Error('joined glyph must be wider than a normal atlas page');
|
||||
}
|
||||
const marker = 'W'.repeat(length);
|
||||
w.term.registerCharacterJoiner(text => text.startsWith(marker) ? [[0, length]] : []);
|
||||
return length;
|
||||
});
|
||||
|
||||
const beforeOversized = await getAtlasState(ctx);
|
||||
await ctx.proxy.write(`\x1b[H${'W'.repeat(joinedLength)}`);
|
||||
await ctx.page.waitForTimeout(50);
|
||||
const oversizedState = await getAtlasState(ctx);
|
||||
expect(oversizedState.overflowPageCreated, 'the joined glyph must use the oversized atlas page').toBe(true);
|
||||
expect(oversizedState.removals, 'normal pages must be evicted before adding the oversized page').toBeGreaterThan(beforeOversized.removals);
|
||||
expect(oversizedState.maxPages, 'oversized page creation must stay within renderer texture capacity').toBeLessThanOrEqual(oversizedState.atlasTextures);
|
||||
expect(oversizedState.pageLayoutVersion, 'oversized eviction must invalidate renderer models').toBeGreaterThan(beforeOversized.pageLayoutVersion);
|
||||
expect(errors, `renderer must not throw while adding the oversized page: ${errors[0] ?? ''}`).toEqual([]);
|
||||
|
||||
const wideSignatures = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-after');
|
||||
for (let i = 0; i < sampleCols.length; i++) {
|
||||
expect(signatureDiff(wideSignatures[i], blankSignatures[i]), `wide glyph col ${sampleCols[i]} must render visible pixels`).toBeGreaterThan(14);
|
||||
}
|
||||
|
||||
const stableState = await getAtlasState(ctx);
|
||||
await refreshAndWaitForRender(ctx, 'term');
|
||||
const refreshedState = await getAtlasState(ctx);
|
||||
expect(refreshedState.pageLayoutVersion, 'oversized glyph refresh must settle without another eviction').toBe(stableState.pageLayoutVersion);
|
||||
expect(refreshedState.removals, 'oversized glyph refresh must not remove more pages').toBe(stableState.removals);
|
||||
const wideAfterRefresh = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-refreshed');
|
||||
for (let i = 0; i < sampleCols.length; i++) {
|
||||
expectSignatureMatches(wideAfterRefresh[i], wideSignatures[i], `wide glyph col ${sampleCols[i]} changed after refresh`);
|
||||
}
|
||||
} finally {
|
||||
ctx.page.off('pageerror', onError);
|
||||
await restoreAtlasLimits(ctx, limits).catch(() => {});
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { decodePng, type IImage32 } from '@lunapaint/png-codec';
|
||||
import test, { expect } from '@playwright/test';
|
||||
import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm';
|
||||
import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
|
||||
type CellSignature = number[];
|
||||
type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal;
|
||||
type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon;
|
||||
|
||||
interface ITestTextureAtlas {
|
||||
readonly pages: ReadonlyArray<{ readonly canvas: HTMLCanvasElement }>;
|
||||
}
|
||||
|
||||
interface ITestGlyphRenderer {
|
||||
readonly _atlasTextures: ReadonlyArray<unknown>;
|
||||
}
|
||||
|
||||
interface ITestRenderer {
|
||||
_charAtlas?: ITestTextureAtlas;
|
||||
_glyphRenderer?: {
|
||||
value?: ITestGlyphRenderer;
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestRenderService {
|
||||
_renderer?: {
|
||||
value?: ITestRenderer;
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestTerminal extends Terminal {
|
||||
_core?: {
|
||||
_renderService?: ITestRenderService;
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestWebglAddon extends WebglAddon {
|
||||
_renderer?: ITestRenderer;
|
||||
}
|
||||
|
||||
interface IAtlasEventStats {
|
||||
additions: number;
|
||||
maxPages: number;
|
||||
}
|
||||
|
||||
interface ITestWindow {
|
||||
WebglAddon: TestWebglAddonConstructor;
|
||||
term: ITestTerminal;
|
||||
addon?: ITestWebglAddon;
|
||||
atlasEventStats?: IAtlasEventStats;
|
||||
}
|
||||
|
||||
interface IAtlasState {
|
||||
additions: number;
|
||||
atlasTextures: number;
|
||||
maxPages: number;
|
||||
}
|
||||
|
||||
const REFERENCE_LINE = 'Ref0123456789ABCDEF';
|
||||
const REFERENCE_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||
const TERM_SELECTOR = '#terminal-container .xterm-screen';
|
||||
|
||||
async function loadWebglStrict(ctx: ITestContext): Promise<void> {
|
||||
await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
w.addon = new w.WebglAddon({ preserveDrawingBuffer: true });
|
||||
w.term.loadAddon(w.addon);
|
||||
});
|
||||
const isWebglRenderer = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
return !!w.addon && w.term._core?._renderService?._renderer?.value === w.addon._renderer;
|
||||
});
|
||||
expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true);
|
||||
}
|
||||
|
||||
async function installAtlasEventStats(ctx: ITestContext): Promise<void> {
|
||||
const installed = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas;
|
||||
if (!atlas || !w.addon) {
|
||||
return false;
|
||||
}
|
||||
const stats: IAtlasEventStats = {
|
||||
additions: 0,
|
||||
maxPages: atlas.pages.length
|
||||
};
|
||||
w.atlasEventStats = stats;
|
||||
w.addon.onAddTextureAtlasCanvas(() => {
|
||||
stats.additions++;
|
||||
stats.maxPages = Math.max(stats.maxPages, atlas.pages.length);
|
||||
});
|
||||
return true;
|
||||
});
|
||||
expect(installed, 'atlas event tracking must be installed').toBe(true);
|
||||
}
|
||||
|
||||
async function getAtlasState(ctx: ITestContext): Promise<IAtlasState> {
|
||||
const state = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
const renderer = w.term._core?._renderService?._renderer?.value;
|
||||
const glyphRenderer = renderer?._glyphRenderer?.value;
|
||||
const stats = w.atlasEventStats;
|
||||
if (!glyphRenderer || !stats) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
additions: stats.additions,
|
||||
atlasTextures: glyphRenderer._atlasTextures.length,
|
||||
maxPages: stats.maxPages
|
||||
};
|
||||
});
|
||||
expect(state, 'atlas state must be available').toBeDefined();
|
||||
return state!;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function refreshAndWaitForRender(ctx: ITestContext): Promise<void> {
|
||||
const renderPromise = new Promise<void>(resolve => {
|
||||
const disposable = ctx.proxy.onRender(() => {
|
||||
disposable.dispose();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1);
|
||||
await renderPromise;
|
||||
}
|
||||
|
||||
function generateSyntheticTuiFrames(frameCount: number, cols: number, rows: number): string[] {
|
||||
const spinner = ['|', '/', '-', '\\'];
|
||||
const words = ['reading', 'rendering', 'atlas', 'terminal', 'buffer', 'tokens', 'diff', 'patch'];
|
||||
const frames: string[] = [];
|
||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) {
|
||||
const lines = [
|
||||
`+${'-'.repeat(cols - 2)}+`,
|
||||
`|${(` ${spinner[frameIndex % spinner.length]} Copilot is working`).padEnd(cols - 2).slice(0, cols - 2)}|`,
|
||||
`+${'-'.repeat(cols - 2)}+`
|
||||
];
|
||||
for (let row = 3; row < rows - 1; row++) {
|
||||
const foreground = 31 + ((frameIndex + row) % 6);
|
||||
const text = `${words[(frameIndex + row) % words.length]} ${frameIndex}:${row}`;
|
||||
lines.push(`\x1b[${foreground}m${text}\x1b[0m`);
|
||||
}
|
||||
lines.push(`\x1b[2mtokens ${1000 + frameIndex * 37}\x1b[0m`);
|
||||
frames.push(`\x1b[2J\x1b[H${lines.join('\r\n')}`);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function generateUniqueGlyphFlood(count: number, cols: number, offset: number): string {
|
||||
const base = 0x4E00;
|
||||
const range = 0x9FFF - base;
|
||||
const perRow = Math.max(1, Math.floor(cols / 2));
|
||||
let result = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
result += String.fromCodePoint(base + ((offset + i) % range));
|
||||
if ((i + 1) % perRow === 0 && i + 1 < count) {
|
||||
result += '\r\n';
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function captureRowSignatures(ctx: ITestContext, row: number, cols: number[], debugName: string): Promise<CellSignature[]> {
|
||||
const screenshotOptions = process.env.DEBUG ? { path: `out-esbuild-test/playwright/${debugName}.png` } : undefined;
|
||||
const buffer = await ctx.page.locator(TERM_SELECTOR).screenshot(screenshotOptions);
|
||||
const frame = {
|
||||
cols: await ctx.proxy.cols,
|
||||
rows: await ctx.proxy.rows,
|
||||
decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image
|
||||
};
|
||||
return cols.map(col => cellSignature(frame, col, row));
|
||||
}
|
||||
|
||||
function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature {
|
||||
const grid = 4;
|
||||
const data = frame.decoded.data;
|
||||
const imageWidth = frame.decoded.width;
|
||||
const cellWidth = frame.decoded.width / frame.cols;
|
||||
const cellHeight = frame.decoded.height / frame.rows;
|
||||
const x0 = (col - 1) * cellWidth;
|
||||
const y0 = (row - 1) * cellHeight;
|
||||
const sums = new Array<number>(grid * grid * 4).fill(0);
|
||||
const counts = new Array<number>(grid * grid).fill(0);
|
||||
const startX = Math.max(0, Math.floor(x0));
|
||||
const startY = Math.max(0, Math.floor(y0));
|
||||
const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth));
|
||||
const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight));
|
||||
|
||||
for (let y = startY; y < endY; y++) {
|
||||
const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid)));
|
||||
for (let x = startX; x < endX; x++) {
|
||||
const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid)));
|
||||
const sub = gy * grid + gx;
|
||||
const i = (y * imageWidth + x) * 4;
|
||||
sums[sub * 4] += data[i];
|
||||
sums[sub * 4 + 1] += data[i + 1];
|
||||
sums[sub * 4 + 2] += data[i + 2];
|
||||
sums[sub * 4 + 3] += data[i + 3];
|
||||
counts[sub]++;
|
||||
}
|
||||
}
|
||||
|
||||
const signature = new Array<number>(grid * grid * 4).fill(0);
|
||||
for (let sub = 0; sub < counts.length; sub++) {
|
||||
if (counts[sub] > 0) {
|
||||
signature[sub * 4] = sums[sub * 4] / counts[sub];
|
||||
signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub];
|
||||
signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub];
|
||||
signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub];
|
||||
}
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
diff = Math.max(diff, Math.abs(actual[i] - reference[i]));
|
||||
}
|
||||
expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14);
|
||||
}
|
||||
|
||||
test.describe('WebGL atlas TUI stress (#6038)', () => {
|
||||
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
|
||||
test('primary-buffer glyphs survive alternate-buffer redraws and atlas page churn', 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, { cols: 80, rows: 24, scrollback: 5000 });
|
||||
await loadWebglStrict(ctx);
|
||||
await installAtlasEventStats(ctx);
|
||||
|
||||
await writeAndWaitForRender(ctx, REFERENCE_LINE);
|
||||
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS, 'atlas-stress-reference');
|
||||
|
||||
await writeAndWaitForRender(ctx, '\x1b[?1049h');
|
||||
for (const frame of generateSyntheticTuiFrames(30, 80, 24)) {
|
||||
await writeAndWaitForRender(ctx, frame);
|
||||
}
|
||||
const glyphsPerViewport = 23 * 40 - 1;
|
||||
for (let chunk = 0; chunk < 4; chunk++) {
|
||||
await writeAndWaitForRender(ctx, `\x1b[2J\x1b[H${generateUniqueGlyphFlood(glyphsPerViewport, 80, chunk * glyphsPerViewport)}`);
|
||||
}
|
||||
await writeAndWaitForRender(ctx, '\x1b[?1049l');
|
||||
await refreshAndWaitForRender(ctx);
|
||||
|
||||
const state = await getAtlasState(ctx);
|
||||
expect(state.additions, 'stress workload must add atlas pages').toBeGreaterThan(0);
|
||||
expect(state.maxPages, 'stress workload must exercise multiple atlas pages').toBeGreaterThan(1);
|
||||
expect(state.maxPages, 'atlas pages must remain within renderer texture capacity').toBeLessThanOrEqual(state.atlasTextures);
|
||||
expect(errors, `renderer must not throw during TUI stress: ${errors[0] ?? ''}`).toEqual([]);
|
||||
|
||||
const primaryText = await ctx.page.evaluate(() => {
|
||||
const w = window as unknown as ITestWindow;
|
||||
return w.term.buffer.active.getLine(0)?.translateToString(true);
|
||||
});
|
||||
expect(primaryText, 'primary buffer must still contain the reference line').toBe(REFERENCE_LINE);
|
||||
|
||||
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS, 'atlas-stress-after');
|
||||
for (let i = 0; i < REFERENCE_COLS.length; i++) {
|
||||
expectSignatureMatches(after[i], reference[i], `primary-buffer glyph at col ${REFERENCE_COLS[i]} changed after TUI stress`);
|
||||
}
|
||||
} finally {
|
||||
ctx.page.off('pageerror', onError);
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IImage32, decodePng } from '@lunapaint/png-codec';
|
||||
import test, { expect } from '@playwright/test';
|
||||
import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm';
|
||||
import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
|
||||
type CellSignature = number[];
|
||||
type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal;
|
||||
type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon;
|
||||
|
||||
interface ITestTextureAtlasConstructor {
|
||||
maxAtlasPages: number | undefined;
|
||||
}
|
||||
|
||||
interface ITestTextureAtlas {
|
||||
constructor: ITestTextureAtlasConstructor;
|
||||
}
|
||||
|
||||
interface ITestRenderer {
|
||||
_charAtlas?: ITestTextureAtlas;
|
||||
}
|
||||
|
||||
interface ITestRenderService {
|
||||
_renderer?: {
|
||||
value?: ITestRenderer;
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestTerminal extends Terminal {
|
||||
_core?: {
|
||||
_renderService?: ITestRenderService;
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestWebglAddon extends WebglAddon {
|
||||
_renderer?: ITestRenderer;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window { // eslint-disable-line @typescript-eslint/naming-convention
|
||||
Terminal: TestTerminalConstructor;
|
||||
WebglAddon: TestWebglAddonConstructor;
|
||||
term: ITestTerminal;
|
||||
termB?: ITestTerminal;
|
||||
addon?: ITestWebglAddon;
|
||||
addonB?: ITestWebglAddon;
|
||||
}
|
||||
}
|
||||
|
||||
const HEADER = ' HEADER_REF_0123456789';
|
||||
const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen';
|
||||
|
||||
async function loadWebglStrict(ctx: ITestContext): Promise<void> {
|
||||
await ctx.page.evaluate(() => {
|
||||
window.addon = new window.WebglAddon({ preserveDrawingBuffer: true });
|
||||
window.term.loadAddon(window.addon);
|
||||
});
|
||||
const isWebglRenderer = await ctx.page.evaluate(() => {
|
||||
return !!window.addon && window.term?._core?._renderService?._renderer?.value === window.addon._renderer;
|
||||
});
|
||||
expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true);
|
||||
}
|
||||
|
||||
async function createTerminalB(ctx: ITestContext): Promise<void> {
|
||||
await ctx.page.evaluate(() => {
|
||||
window.termB?.dispose();
|
||||
document.getElementById('terminal-container-b')?.remove();
|
||||
const el = document.createElement('div');
|
||||
el.id = 'terminal-container-b';
|
||||
document.body.appendChild(el);
|
||||
window.termB = new window.Terminal({ cols: 80, rows: 24, allowProposedApi: true });
|
||||
window.termB.open(el);
|
||||
window.addonB = new window.WebglAddon({ preserveDrawingBuffer: true });
|
||||
window.termB.loadAddon(window.addonB);
|
||||
});
|
||||
}
|
||||
|
||||
async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
|
||||
await ctx.page.evaluate(d => new Promise<void>(resolve => {
|
||||
const termB = window.termB;
|
||||
if (!termB) {
|
||||
throw new Error('Terminal B must be created before writing to it');
|
||||
}
|
||||
const disposable = termB.onRender(() => {
|
||||
disposable.dispose();
|
||||
resolve();
|
||||
});
|
||||
termB.write(d);
|
||||
}), data);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function setMaxAtlasPages(ctx: ITestContext, maxAtlasPages: number): Promise<void> {
|
||||
const applied = await ctx.page.evaluate(max => {
|
||||
const atlas = window.term?._core?._renderService?._renderer?.value?._charAtlas;
|
||||
if (!atlas) {
|
||||
return false;
|
||||
}
|
||||
atlas.constructor.maxAtlasPages = max;
|
||||
return true;
|
||||
}, maxAtlasPages);
|
||||
expect(applied, 'should be able to set TextureAtlas.maxAtlasPages').toBe(true);
|
||||
}
|
||||
|
||||
async function resetMaxAtlasPages(ctx: ITestContext): Promise<void> {
|
||||
await ctx.page.evaluate(() => {
|
||||
const atlas = window.term?._core?._renderService?._renderer?.value?._charAtlas;
|
||||
if (atlas) {
|
||||
atlas.constructor.maxAtlasPages = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateColoredAsciiFlood(cells: number, offset: number = 0): string {
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let out = '';
|
||||
for (let i = 0; i < cells; i++) {
|
||||
const ch = letters[(offset + i) % letters.length];
|
||||
const fg = (offset + i) % 256;
|
||||
const bg = (offset + i * 7) % 256;
|
||||
out += `\x1b[38;5;${fg}m\x1b[48;5;${bg}m${ch}`;
|
||||
if ((i + 1) % 78 === 0 && i + 1 < cells) {
|
||||
out += '\x1b[0m\r\n';
|
||||
}
|
||||
}
|
||||
return out + '\x1b[0m';
|
||||
}
|
||||
|
||||
async function captureRowSignatures(ctx: ITestContext, row: number, cols: number[]): Promise<CellSignature[]> {
|
||||
const screenshotOptions = process.env.DEBUG ? { path: 'out-esbuild-test/playwright/shared-atlas-term-b.png' } : undefined;
|
||||
const buffer = await ctx.page.locator(TERM_B_SELECTOR).screenshot(screenshotOptions);
|
||||
const frame = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image
|
||||
};
|
||||
return cols.map(col => cellSignature(frame, col, row));
|
||||
}
|
||||
|
||||
function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature {
|
||||
const grid = 4;
|
||||
const data = frame.decoded.data;
|
||||
const imageWidth = frame.decoded.width;
|
||||
const cellWidth = frame.decoded.width / frame.cols;
|
||||
const cellHeight = frame.decoded.height / frame.rows;
|
||||
const x0 = (col - 1) * cellWidth;
|
||||
const y0 = (row - 1) * cellHeight;
|
||||
const sums = new Array<number>(grid * grid * 4).fill(0);
|
||||
const counts = new Array<number>(grid * grid).fill(0);
|
||||
const startX = Math.max(0, Math.floor(x0));
|
||||
const startY = Math.max(0, Math.floor(y0));
|
||||
const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth));
|
||||
const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight));
|
||||
|
||||
for (let y = startY; y < endY; y++) {
|
||||
const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid)));
|
||||
for (let x = startX; x < endX; x++) {
|
||||
const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid)));
|
||||
const sub = gy * grid + gx;
|
||||
const i = (y * imageWidth + x) * 4;
|
||||
sums[sub * 4] += data[i];
|
||||
sums[sub * 4 + 1] += data[i + 1];
|
||||
sums[sub * 4 + 2] += data[i + 2];
|
||||
sums[sub * 4 + 3] += data[i + 3];
|
||||
counts[sub]++;
|
||||
}
|
||||
}
|
||||
|
||||
const signature = new Array<number>(grid * grid * 4).fill(0);
|
||||
for (let sub = 0; sub < counts.length; sub++) {
|
||||
if (counts[sub] > 0) {
|
||||
signature[sub * 4] = sums[sub * 4] / counts[sub];
|
||||
signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub];
|
||||
signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub];
|
||||
signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub];
|
||||
}
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
|
||||
function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
diff = Math.max(diff, Math.abs(actual[i] - reference[i]));
|
||||
}
|
||||
expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14);
|
||||
}
|
||||
|
||||
test.describe('shared-atlas garble across terminals (#6038)', () => {
|
||||
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||
test.describe.configure({ timeout: 60000 });
|
||||
|
||||
test('keeps a second terminal rendering correctly after shared atlas page merges', async ({ browser }) => {
|
||||
const ctx = await createTestContext(browser);
|
||||
try {
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
await createTerminalB(ctx);
|
||||
|
||||
const atlasShared = await ctx.page.evaluate(() => {
|
||||
return window.term._core?._renderService?._renderer?.value?._charAtlas === window.termB?._core?._renderService?._renderer?.value?._charAtlas;
|
||||
});
|
||||
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
|
||||
|
||||
await setMaxAtlasPages(ctx, 4);
|
||||
|
||||
await writeToBAndWaitForRender(ctx, HEADER);
|
||||
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS);
|
||||
|
||||
for (let c = 0; c < 10; c++) {
|
||||
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78));
|
||||
}
|
||||
|
||||
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
|
||||
const termBHeader = await ctx.page.evaluate(() => window.termB?.buffer.active.getLine(0)?.translateToString(true));
|
||||
expect(termBHeader, 'terminal B buffer must still contain the header').toBe(HEADER);
|
||||
|
||||
const after = await captureRowSignatures(ctx, 1, HEADER_COLS);
|
||||
for (let i = 0; i < HEADER_COLS.length; i++) {
|
||||
expectSignatureMatches(after[i], reference[i], `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas merges`);
|
||||
}
|
||||
} finally {
|
||||
await resetMaxAtlasPages(ctx).catch(() => {});
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps a second terminal rendering correctly after terminal A clears the shared atlas', async ({ browser }) => {
|
||||
const ctx = await createTestContext(browser);
|
||||
try {
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
await loadWebglStrict(ctx);
|
||||
await createTerminalB(ctx);
|
||||
|
||||
const atlasShared = await ctx.page.evaluate(() => {
|
||||
return window.term._core?._renderService?._renderer?.value?._charAtlas === window.termB?._core?._renderService?._renderer?.value?._charAtlas;
|
||||
});
|
||||
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
|
||||
|
||||
await writeToBAndWaitForRender(ctx, HEADER);
|
||||
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS);
|
||||
|
||||
// Terminal A alone clears the shared atlas, as embedders do to force a redraw (VS Code calls
|
||||
// this for every terminal on OS resume). This wipes the pages that terminal B's model still
|
||||
// holds texture coords into.
|
||||
await ctx.proxy.clearTextureAtlas();
|
||||
|
||||
// Terminal A repopulates the cleared pages with a different glyph set, so B's stale coords now
|
||||
// resolve to whatever A rasterized into those slots.
|
||||
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78));
|
||||
|
||||
// Nudge B into a render that does not touch row 1, mirroring a terminal that is idle but
|
||||
// visible. Row 1 must still be drawn from a model B rebuilt against the current atlas.
|
||||
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
|
||||
const termBHeader = await ctx.page.evaluate(() => window.termB?.buffer.active.getLine(0)?.translateToString(true));
|
||||
expect(termBHeader, 'terminal B buffer must still contain the header').toBe(HEADER);
|
||||
|
||||
const after = await captureRowSignatures(ctx, 1, HEADER_COLS);
|
||||
for (let i = 0; i < HEADER_COLS.length; i++) {
|
||||
expectSignatureMatches(after[i], reference[i], `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas clear`);
|
||||
}
|
||||
} finally {
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -12,31 +12,33 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
|
||||
|
||||
private _textureAtlasContainer!: HTMLElement;
|
||||
private _stressRunning = false;
|
||||
private _stressScrolling: HTMLInputElement | undefined;
|
||||
private _scrollOutput: HTMLInputElement | undefined;
|
||||
|
||||
public build(container: HTMLElement): void {
|
||||
const stressDiv = document.createElement('div');
|
||||
const stressButtons = document.createElement('div');
|
||||
const stressStart = document.createElement('button');
|
||||
stressStart.id = 'stress-start';
|
||||
stressStart.textContent = 'atlas stress start';
|
||||
stressStart.addEventListener('click', () => this._stress());
|
||||
stressDiv.appendChild(stressStart);
|
||||
stressButtons.appendChild(stressStart);
|
||||
|
||||
const stressStop = document.createElement('button');
|
||||
stressStop.id = 'stress-stop';
|
||||
stressStop.textContent = 'atlas stress stop';
|
||||
stressStop.addEventListener('click', () => this._stressRunning = false);
|
||||
stressDiv.appendChild(stressStop);
|
||||
stressButtons.appendChild(stressStop);
|
||||
container.appendChild(stressButtons);
|
||||
|
||||
this._stressScrolling = document.createElement('input');
|
||||
this._stressScrolling.type = 'checkbox';
|
||||
this._stressScrolling.id = 'stress-scrolling';
|
||||
stressDiv.appendChild(this._stressScrolling);
|
||||
const stressScrollingLabel = document.createElement('label');
|
||||
stressScrollingLabel.htmlFor = 'stress-scrolling';
|
||||
stressScrollingLabel.textContent = 'stress scrolling';
|
||||
stressDiv.appendChild(stressScrollingLabel);
|
||||
container.appendChild(stressDiv);
|
||||
const stressOptions = document.createElement('div');
|
||||
this._scrollOutput = document.createElement('input');
|
||||
this._scrollOutput.type = 'checkbox';
|
||||
this._scrollOutput.id = 'scroll-output';
|
||||
stressOptions.appendChild(this._scrollOutput);
|
||||
const scrollOutputLabel = document.createElement('label');
|
||||
scrollOutputLabel.htmlFor = 'scroll-output';
|
||||
scrollOutputLabel.textContent = 'Scroll output';
|
||||
stressOptions.appendChild(scrollOutputLabel);
|
||||
container.appendChild(stressOptions);
|
||||
|
||||
const zoomCheckbox = document.createElement('input');
|
||||
zoomCheckbox.type = 'checkbox';
|
||||
@@ -80,7 +82,7 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
|
||||
return;
|
||||
}
|
||||
this._stressRunning = true;
|
||||
const scrolling = !!(this._stressScrolling?.checked);
|
||||
const scrollOutput = !!(this._scrollOutput?.checked);
|
||||
for (let r = 0; r < 256; r += 2) {
|
||||
for (let g = 0; g < 256; g += 2) {
|
||||
let s: string[] = [];
|
||||
@@ -91,7 +93,7 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
|
||||
const rbg = `RGB: ${[r, g, b]}`;
|
||||
s.push(`\r\x1b[38;2;${r};${g};${b}m${rbg.padEnd(18, ' ')}${TEXT}`);
|
||||
if (s.length >= 16) {
|
||||
if (scrolling) {
|
||||
if (scrollOutput) {
|
||||
await new Promise<void>(r => this._terminal.write(s.join('\r\n') + '\r\n', r));
|
||||
} else {
|
||||
await new Promise<void>(r => this._terminal.write('\x1b[H' + s.join('\r\n'), r));
|
||||
|
||||
Generated
+29
-29
@@ -3061,16 +3061,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -3519,15 +3519,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"version": "9.2.4",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
|
||||
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"shell-quote": "1.9.0",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
@@ -4308,9 +4308,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-ws/node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
|
||||
"integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4351,9 +4351,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4749,9 +4749,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5795,9 +5795,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mocha/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7127,9 +7127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7975,9 +7975,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8452,9 +8452,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -492,6 +492,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
// https://issuetracker.google.com/issues/260170397
|
||||
this.textarea.setAttribute('aria-multiline', 'false');
|
||||
}
|
||||
this.textarea.setAttribute('autocomplete', 'off');
|
||||
this.textarea.setAttribute('autocorrect', 'off');
|
||||
this.textarea.setAttribute('autocapitalize', 'off');
|
||||
this.textarea.setAttribute('spellcheck', 'false');
|
||||
|
||||
@@ -351,3 +351,96 @@ describe('MouseService mouseEventsRequireAlt', () => {
|
||||
assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS));
|
||||
});
|
||||
});
|
||||
|
||||
describe('MouseService multi-window', () => {
|
||||
interface IRecordingDocument {
|
||||
doc: Document;
|
||||
added: string[];
|
||||
removed: string[];
|
||||
listeners: { [type: string]: EventListener[] };
|
||||
}
|
||||
|
||||
function createRecordingDocument(): IRecordingDocument {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const listeners: { [type: string]: EventListener[] } = {};
|
||||
const doc = {
|
||||
addEventListener: (type: string, listener: EventListener) => {
|
||||
added.push(type);
|
||||
(listeners[type] ??= []).push(listener);
|
||||
},
|
||||
removeEventListener: (type: string) => removed.push(type)
|
||||
} as any;
|
||||
return { doc, added, removed, listeners };
|
||||
}
|
||||
|
||||
it('should manage protocol mouseup/mousemove listeners on the element\'s current document, not the document the terminal was opened with', () => {
|
||||
const mouseStateService = new MouseStateService();
|
||||
const optionsService = new OptionsService({});
|
||||
const mouseService = new MouseService(
|
||||
new MockRenderService(),
|
||||
{
|
||||
getMouseReportCoords: () => ({ col: 0, row: 0, x: 0, y: 0 })
|
||||
} as any,
|
||||
mouseStateService,
|
||||
{
|
||||
triggerDataEvent: () => {},
|
||||
triggerBinaryEvent: () => {},
|
||||
decPrivateModes: { applicationCursorKeys: false }
|
||||
} as any,
|
||||
bufferService,
|
||||
optionsService,
|
||||
new TestSelectionService(),
|
||||
logService,
|
||||
new MockCoreBrowserService()
|
||||
);
|
||||
|
||||
// Simulate a terminal that was opened with one document but whose element now lives in
|
||||
// another document (eg. it was moved to a child window)
|
||||
const openDocument = createRecordingDocument();
|
||||
const childDocument = createRecordingDocument();
|
||||
const elementListeners: { [type: string]: EventListener[] } = {};
|
||||
const element = {
|
||||
classList: {
|
||||
add: () => {},
|
||||
remove: () => {},
|
||||
contains: () => false
|
||||
},
|
||||
addEventListener: (type: string, listener: EventListener) => {
|
||||
(elementListeners[type] ??= []).push(listener);
|
||||
},
|
||||
removeEventListener: () => {},
|
||||
ownerDocument: childDocument.doc
|
||||
} as any;
|
||||
|
||||
mouseService.bindMouse({
|
||||
element,
|
||||
screenElement: createTestMouseTargetElement(),
|
||||
document: openDocument.doc
|
||||
}, disposable => disposable, () => {});
|
||||
mouseStateService.activeProtocol = 'ANY';
|
||||
|
||||
elementListeners['mousedown'][0]({
|
||||
type: 'mousedown',
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: () => {}
|
||||
} as any);
|
||||
assert.deepEqual(childDocument.added, ['mouseup', 'mousemove']);
|
||||
assert.deepEqual(openDocument.added, []);
|
||||
|
||||
childDocument.listeners['mouseup'][0]({
|
||||
type: 'mouseup',
|
||||
button: 0,
|
||||
buttons: 0,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false
|
||||
} as any);
|
||||
assert.deepEqual(childDocument.removed, ['mouseup', 'mousemove']);
|
||||
assert.deepEqual(openDocument.removed, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { addDisposableListener } from '../Dom';
|
||||
import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from '../../common/services/Services';
|
||||
import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from '../../common/Types';
|
||||
import { C0 } from '../../common/data/EscapeSequences';
|
||||
import { DisposableStore, MutableDisposable, toDisposable } from '../../common/Lifecycle';
|
||||
import { DisposableStore, MutableDisposable } from '../../common/Lifecycle';
|
||||
import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
|
||||
import { Gesture, EventType as GestureEventType, IGestureEvent } from '../scrollable/touch';
|
||||
|
||||
@@ -21,6 +21,8 @@ interface IMouseBindContext {
|
||||
readonly target: IMouseServiceTarget;
|
||||
readonly focus: () => void;
|
||||
readonly requestedEvents: RequestedMouseEvents;
|
||||
readonly mouseupListener: MutableDisposable<IDisposable>;
|
||||
readonly mousedragListener: MutableDisposable<IDisposable>;
|
||||
}
|
||||
|
||||
export class MouseService implements IMouseService {
|
||||
@@ -61,7 +63,11 @@ export class MouseService implements IMouseService {
|
||||
mousedrag: null,
|
||||
mousemove: null
|
||||
};
|
||||
const ctx: IMouseBindContext = { target, focus, requestedEvents };
|
||||
const mouseupListener = new MutableDisposable<IDisposable>();
|
||||
const mousedragListener = new MutableDisposable<IDisposable>();
|
||||
register(mouseupListener);
|
||||
register(mousedragListener);
|
||||
const ctx: IMouseBindContext = { target, focus, requestedEvents, mouseupListener, mousedragListener };
|
||||
const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = {
|
||||
mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent),
|
||||
wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent),
|
||||
@@ -85,16 +91,6 @@ export class MouseService implements IMouseService {
|
||||
// force initial onProtocolChange so we dont miss early mouse requests
|
||||
this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol;
|
||||
|
||||
// Ensure document-level listeners are removed on dispose
|
||||
register(toDisposable(() => {
|
||||
if (requestedEvents.mouseup) {
|
||||
document.removeEventListener('mouseup', requestedEvents.mouseup);
|
||||
}
|
||||
if (requestedEvents.mousedrag) {
|
||||
document.removeEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* "Always on" event listeners.
|
||||
*/
|
||||
@@ -199,12 +195,8 @@ export class MouseService implements IMouseService {
|
||||
this._sendEvent(ctx, ev);
|
||||
if (!ev.buttons) {
|
||||
// if no other button is held remove global handlers
|
||||
if (ctx.requestedEvents.mouseup) {
|
||||
ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup);
|
||||
}
|
||||
if (ctx.requestedEvents.mousedrag) {
|
||||
ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag);
|
||||
}
|
||||
ctx.mouseupListener.clear();
|
||||
ctx.mousedragListener.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,11 +238,14 @@ export class MouseService implements IMouseService {
|
||||
// of the terminal element.
|
||||
// Note: Other emulators also do this for 'mousedown' while a button
|
||||
// is held, we currently limit 'mousedown' to the terminal only.
|
||||
// Use the element's current document in case it moved to another window after open.
|
||||
const { element, document: targetDocument } = ctx.target;
|
||||
const listenerDocument = element.ownerDocument ?? targetDocument;
|
||||
if (ctx.requestedEvents.mouseup) {
|
||||
ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup);
|
||||
ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup);
|
||||
}
|
||||
if (ctx.requestedEvents.mousedrag) {
|
||||
ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag);
|
||||
ctx.mousedragListener.value = addDisposableListener(listenerDocument, 'mousemove', ctx.requestedEvents.mousedrag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +393,7 @@ export class MouseService implements IMouseService {
|
||||
}
|
||||
|
||||
private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void {
|
||||
const { element, document } = ctx.target;
|
||||
const { element } = ctx.target;
|
||||
const { requestedEvents } = ctx;
|
||||
// apply global changes on events
|
||||
if (events) {
|
||||
@@ -433,18 +428,14 @@ export class MouseService implements IMouseService {
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.UP)) {
|
||||
if (requestedEvents.mouseup) {
|
||||
document.removeEventListener('mouseup', requestedEvents.mouseup);
|
||||
}
|
||||
ctx.mouseupListener.clear();
|
||||
requestedEvents.mouseup = null;
|
||||
} else {
|
||||
requestedEvents.mouseup ??= eventListeners.mouseup;
|
||||
}
|
||||
|
||||
if (!(events & CoreMouseEventType.DRAG)) {
|
||||
if (requestedEvents.mousedrag) {
|
||||
document.removeEventListener('mousemove', requestedEvents.mousedrag);
|
||||
}
|
||||
ctx.mousedragListener.clear();
|
||||
requestedEvents.mousedrag = null;
|
||||
} else {
|
||||
requestedEvents.mousedrag ??= eventListeners.mousedrag;
|
||||
|
||||
@@ -2394,6 +2394,54 @@ describe('InputHandler', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ED3 - erase saved lines', () => {
|
||||
beforeEach(() => {
|
||||
bufferService.resize(10, 5);
|
||||
});
|
||||
it('should stop locking the viewport when the scrollback the user scrolled into is erased', async () => {
|
||||
for (let i = 0; i < 20; ++i) {
|
||||
await inputHandler.parseP(`old ${i}\r\n`);
|
||||
}
|
||||
bufferService.scrollLines(-5);
|
||||
assert.strictEqual(bufferService.isUserScrolling, true);
|
||||
|
||||
await inputHandler.parseP('\x1b[3J');
|
||||
|
||||
assert.strictEqual(bufferService.isUserScrolling, false);
|
||||
assert.strictEqual(bufferService.buffer.ybase, 0);
|
||||
assert.strictEqual(bufferService.buffer.ydisp, 0);
|
||||
|
||||
for (let i = 0; i < 20; ++i) {
|
||||
await inputHandler.parseP(`new ${i}\r\n`);
|
||||
}
|
||||
|
||||
assert.strictEqual(bufferService.buffer.ydisp, bufferService.buffer.ybase);
|
||||
});
|
||||
it('should not reset the scroll position when erasing scrollback on a resized alt buffer', async () => {
|
||||
bufferService.resize(10, 50);
|
||||
bufferService.resize(10, 5);
|
||||
for (let i = 0; i < 20; ++i) {
|
||||
await inputHandler.parseP(`old ${i}\r\n`);
|
||||
}
|
||||
bufferService.scrollLines(-5);
|
||||
const ydisp = bufferService.buffer.ydisp;
|
||||
|
||||
await inputHandler.parseP('\x1b[?1049h');
|
||||
for (let i = 0; i < 20; ++i) {
|
||||
await inputHandler.parseP(`new ${i}\r\n`);
|
||||
}
|
||||
await inputHandler.parseP('\x1b[3J\x1b[?1049l');
|
||||
|
||||
assert.strictEqual(bufferService.isUserScrolling, true);
|
||||
assert.strictEqual(bufferService.buffer.ydisp, ydisp);
|
||||
|
||||
await inputHandler.parseP('more\r\n');
|
||||
|
||||
assert.strictEqual(bufferService.isUserScrolling, true);
|
||||
assert.strictEqual(bufferService.buffer.ydisp, ydisp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DECSCA and DECSED/DECSEL', () => {
|
||||
it('default is unprotected', async () => {
|
||||
await inputHandler.parseP('some text');
|
||||
|
||||
@@ -1286,6 +1286,11 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
this._activeBuffer.lines.trimStart(scrollBackSize);
|
||||
this._activeBuffer.ybase = Math.max(this._activeBuffer.ybase - scrollBackSize, 0);
|
||||
this._activeBuffer.ydisp = Math.max(this._activeBuffer.ydisp - scrollBackSize, 0);
|
||||
// isUserScrolling tracks the normal buffer's viewport, so ED3 on the alt
|
||||
// screen must not touch it
|
||||
if (this._activeBuffer === this._bufferService.buffers.normal) {
|
||||
this._bufferService.isUserScrolling = false;
|
||||
}
|
||||
// Force a scroll event to refresh viewport
|
||||
this._onScroll.fire(0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user