Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e01b7fdc | ||
|
|
2cc9ff0317 | ||
|
|
c58ea3637f | ||
|
|
3eaea160f4 | ||
|
|
c9b14056c5 | ||
|
|
c1d2d6853f | ||
|
|
42894ff9a1 | ||
|
|
8c8c228ac5 | ||
|
|
e54045d89b | ||
|
|
d6d95171a5 | ||
|
|
4d0a9c3419 | ||
|
|
de620dc826 | ||
|
|
651a3537dd | ||
|
|
f1ae0ab30b | ||
|
|
d3e32b344d | ||
|
|
0659493637 | ||
|
|
08ad9a4de9 | ||
|
|
fbaf28af5c | ||
|
|
8938fe3785 | ||
|
|
163104e14f | ||
|
|
9db3a4beeb | ||
|
|
3558626911 | ||
|
|
a783fc1817 | ||
|
|
90fad0f277 | ||
|
|
4e41215a87 | ||
|
|
6a49e1aa61 | ||
|
|
f039ba3fba | ||
|
|
116a09617e | ||
|
|
bc62843844 | ||
|
|
aca98b8a5e | ||
|
|
409c931093 | ||
|
|
4c8963a487 | ||
|
|
c480898d0d | ||
|
|
c44a913c2d | ||
|
|
4708890a27 | ||
|
|
fa3821adb9 | ||
|
|
e69a8b6240 | ||
|
|
9f64663bd0 | ||
|
|
29a7384233 | ||
|
|
d6cfe3db5d | ||
|
|
8c9b9fdb9b | ||
|
|
6ccafd9779 | ||
|
|
b5222f8010 | ||
|
|
e2ff222c4f | ||
|
|
1b822cb814 | ||
|
|
8961a72d2d | ||
|
|
576181d93d | ||
|
|
eef62138a4 | ||
|
|
c793c317fd | ||
|
|
a4e4e28f97 | ||
|
|
4452773563 | ||
|
|
7aea5197d8 | ||
|
|
9f39dc2bf9 | ||
|
|
330eaaf205 | ||
|
|
904ae93526 | ||
|
|
699f5537b0 | ||
|
|
abce6f1c62 | ||
|
|
4d93815c36 | ||
|
|
5b00193876 | ||
|
|
0b1c0b5c5a | ||
|
|
ce21694856 | ||
|
|
2ea67f9ea8 | ||
|
|
2ae6e3a603 | ||
|
|
06ce754fcc | ||
|
|
6791a0abea | ||
|
|
5501e931f7 | ||
|
|
b1aee19ac6 | ||
|
|
2b982bc39c | ||
|
|
8237004d5c | ||
|
|
9559005bbc | ||
|
|
46556eb6cf | ||
|
|
64ecce4f25 | ||
|
|
77bf56066c | ||
|
|
6fe6fe1af3 | ||
|
|
780cbef3cf | ||
|
|
8aab310366 | ||
|
|
9f232d981b | ||
|
|
1efd80cd17 | ||
|
|
1701dcef90 | ||
|
|
55dbe3b65e | ||
|
|
fed1138b85 | ||
|
|
53a98a720a | ||
|
|
b69ae4b3c9 | ||
|
|
092b07da86 | ||
|
|
3cdbc77386 | ||
|
|
f57b8e1534 | ||
|
|
c8f11d63b5 | ||
|
|
3d929a1db0 | ||
|
|
2fe3fd13a1 | ||
|
|
f33f502267 | ||
|
|
7c24c58007 | ||
|
|
596634ac41 | ||
|
|
0c8271db49 | ||
|
|
f3f4dcaf9f | ||
|
|
64718444e2 | ||
|
|
7b66cd026a | ||
|
|
235457ccdd | ||
|
|
eaa3a63ddf | ||
|
|
8883dc17a7 |
@@ -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@v3
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
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@v4
|
||||
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@v3
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
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@v3
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -129,6 +138,7 @@ jobs:
|
||||
exit $EXIT_CODE
|
||||
|
||||
test-unit:
|
||||
needs: build
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -136,21 +146,18 @@ jobs:
|
||||
runs-on: [ubuntu, macos, windows]
|
||||
runs-on: ${{ matrix.runs-on }}-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- name: Wait for build job
|
||||
uses: NathanFirmo/wait-for-other-job@v1.1.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
job: build
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -166,6 +173,7 @@ jobs:
|
||||
run: npm run test-unit --forbid-only
|
||||
|
||||
test-integration:
|
||||
needs: build
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -174,9 +182,11 @@ jobs:
|
||||
browser: [chromium, firefox, webkit]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
@@ -185,12 +195,7 @@ jobs:
|
||||
npm ci
|
||||
- name: Install playwright
|
||||
run: npx playwright install --with-deps ${{ matrix.browser }}
|
||||
- name: Wait for build job
|
||||
uses: NathanFirmo/wait-for-other-job@v1.1.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
job: build
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -240,9 +245,11 @@ jobs:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'npm'
|
||||
@@ -251,7 +258,7 @@ jobs:
|
||||
npm ci
|
||||
- name: Install playwright
|
||||
run: npx playwright install
|
||||
- uses: actions/download-artifact@v4
|
||||
- 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@v3
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
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@v4
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
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@v3
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
|
||||
@@ -254,6 +254,13 @@ Xterm.js is used in many world-class applications to provide great terminal expe
|
||||
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
|
||||
- [**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.
|
||||
|
||||
@@ -78,13 +78,43 @@ export class BrowserClipboardProvider implements IClipboardProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Once the base64 handling on Uint8Arrays is more common,
|
||||
* remove the btoa/atob fallbacks below.
|
||||
*/
|
||||
interface IUint8ArrayB64 extends Uint8Array {
|
||||
toBase64(): string;
|
||||
}
|
||||
interface IUint8ArrayB64Ctor extends Uint8ArrayConstructor {
|
||||
fromBase64(s: string): IUint8ArrayB64;
|
||||
}
|
||||
|
||||
export class Base64 implements IBase64 {
|
||||
public encodeText(data: string): string {
|
||||
return btoa(data);
|
||||
const bytes = new TextEncoder().encode(data) as IUint8ArrayB64;
|
||||
if (bytes.toBase64 !== undefined) {
|
||||
return bytes.toBase64();
|
||||
}
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bin += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
public decodeText(data: string): string {
|
||||
if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) {
|
||||
try {
|
||||
return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data));
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return atob(data);
|
||||
const bin = atob(data);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; ++i) {
|
||||
bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => {
|
||||
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
}
|
||||
await ctx.page.evaluate(`
|
||||
window.term.reset()
|
||||
window.term.reset();
|
||||
window.clipboard?.dispose();
|
||||
window.clipboard = new ClipboardAddon();
|
||||
window.term.loadAddon(window.clipboard);
|
||||
@@ -92,4 +92,22 @@ test.describe('ClipboardAddon', () => {
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('non-ASCII', () => {
|
||||
const testDataEncoded = '4oKsbWzDpMO8dMOf';
|
||||
const testDataDecoded = '€mläütß';
|
||||
test('write simple string', async () => {
|
||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
||||
});
|
||||
test('read simple string', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.data2 = [];
|
||||
window.term.onData(e => data2.push(e));
|
||||
`);
|
||||
await ctx.page.evaluate((d) => window.navigator.clipboard.writeText(d), testDataDecoded);
|
||||
await ctx.proxy.write(`\x1b]52;c;?\x07`);
|
||||
deepEqual(await ctx.page.evaluate('window.data2'), [`\x1b]52;c;${testDataEncoded}\x07`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,7 +98,7 @@ terminal.loadAddon(imageAddon);
|
||||
Set by default, change it with `{iipSupport: true}`.
|
||||
|
||||
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
|
||||
- Supported formats: PNG, JPEG, GIF and QOI
|
||||
- Supported formats: PNG, JPEG, GIF, QOI, WEBP and AVIF
|
||||
- No animation support.
|
||||
- Image type hinting is not supported (always deducted from data header).
|
||||
- File download is not supported.
|
||||
@@ -112,6 +112,13 @@ terminal.loadAddon(imageAddon);
|
||||
- ReportCellSize sequence is supported.
|
||||
- Hi-res display support is currently limited (image gets blurred).
|
||||
|
||||
- **Note on IIP image format support**
|
||||
For all image formats except QOI the addon only does the dimension extraction and leaves
|
||||
the image decoding to the underlying browser engine. Note that some formats are implemented
|
||||
and enabled with different constraints by browser vendors, especially newer formats
|
||||
(e.g. under Firefox you might need to change the setting _image.avif.compliance_strictness_ to 0
|
||||
to get broader AVIF support).
|
||||
|
||||
- **Kitty Graphics Support (TGP)**
|
||||
Set by default, change it with `{kittySupport: true}`.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,630 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { IDisposable } from '@xterm/xterm';
|
||||
import type { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageTileInfo, type ImageStorage } from './ImageStorage';
|
||||
import type { IImageSpec, ITerminalExt, IResetHandler } from './Types';
|
||||
import type { IMetrics } from 'IIPMetrics';
|
||||
|
||||
|
||||
/**
|
||||
* Note on Firefox Bug (https://bugzilla.mozilla.org/show_bug.cgi?id=2066911)
|
||||
*
|
||||
* ImageDecoder.decode() returns a corrupted VideoFrame,
|
||||
* if the frame was closed from a previous decode.
|
||||
*
|
||||
* Therefore the AnimationManager class implements certain
|
||||
* firefox shims gated by the isFirefox boolean:
|
||||
* - IAnimation.highest tracks highest frameID ever decoded
|
||||
* - whenever frameID is lesser or equal zu highest create new decoder
|
||||
* - raw image bytes need to be held for new decoder
|
||||
*
|
||||
* To be removed once the bug got fixed.
|
||||
*/
|
||||
|
||||
|
||||
interface IAnimation {
|
||||
/** image id in storage */
|
||||
id: number;
|
||||
/** image bytes */
|
||||
data: Blob;
|
||||
/** metrics of the image */
|
||||
metrics: IMetrics;
|
||||
/** width */
|
||||
w: number;
|
||||
/** height */
|
||||
h: number;
|
||||
/** number of frames */
|
||||
numFrames: number;
|
||||
frames: number[];
|
||||
/** ImageDecoder instance */
|
||||
decoder: ImageDecoder | undefined;
|
||||
decoderPromise: Promise<ImageDecoder> | undefined;
|
||||
cache: BitmapBuffer;
|
||||
/** start timestamp */
|
||||
startTime: number | undefined;
|
||||
/** whether a decode task ist scheduled */
|
||||
scheduled: boolean;
|
||||
/** whether the image is in viewport */
|
||||
inViewport: boolean;
|
||||
/** firefox bug helper */
|
||||
highest: number;
|
||||
/** playtime of animation */
|
||||
playtime: number;
|
||||
/** frame timestamps */
|
||||
timestamps: number[];
|
||||
/** frame ID targeted by animation */
|
||||
current: number;
|
||||
/** frame ID currently visible (owned by spec.actual) */
|
||||
actual: number;
|
||||
/** idx of current on frames */
|
||||
frameIdx: number;
|
||||
}
|
||||
|
||||
|
||||
interface IImageDraw {
|
||||
imageId: number;
|
||||
imgSpec: IImageSpec;
|
||||
tileId: number;
|
||||
col: number;
|
||||
row: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
|
||||
interface IBufferSlot {
|
||||
id: number;
|
||||
bm: ImageBitmap | null;
|
||||
}
|
||||
|
||||
|
||||
const enum Constants {
|
||||
BUFFERSIZE = 4,
|
||||
BUFFERMOD = BUFFERSIZE - 1,
|
||||
MAX_FRAMES = 65535
|
||||
}
|
||||
|
||||
|
||||
class BitmapBuffer implements IDisposable {
|
||||
private readonly _d: IBufferSlot[];
|
||||
private _disposed = false;
|
||||
|
||||
constructor() {
|
||||
this._d = Array(Constants.BUFFERSIZE).fill(0).map(() => ({ id: -1, bm: null }));
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clear();
|
||||
this._disposed = true;
|
||||
}
|
||||
|
||||
public peek(id: number): ImageBitmap | null {
|
||||
const slot = this._d[id & Constants.BUFFERMOD];
|
||||
return id === slot.id ? slot.bm : null;
|
||||
}
|
||||
|
||||
public pop(id: number): ImageBitmap | null {
|
||||
const slot = this._d[id & Constants.BUFFERMOD];
|
||||
const res = id === slot.id
|
||||
? slot.bm
|
||||
: (slot.bm?.close(), null);
|
||||
slot.bm = null;
|
||||
slot.id = -1;
|
||||
return res;
|
||||
}
|
||||
|
||||
public push(id: number, bm: ImageBitmap): void {
|
||||
/**
|
||||
* A late decode task might still push bitmaps.
|
||||
* Since push gives the buffer ownership of
|
||||
* the bitmap, we use the isDisposed flag to
|
||||
* free it right away.
|
||||
*/
|
||||
if (this._disposed) {
|
||||
bm.close();
|
||||
return;
|
||||
}
|
||||
const slot = this._d[id & Constants.BUFFERMOD];
|
||||
slot.bm?.close();
|
||||
slot.bm = bm;
|
||||
slot.id = id;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (let i = 0; i < Constants.BUFFERSIZE; ++i) {
|
||||
const slot = this._d[i];
|
||||
slot.bm?.close();
|
||||
slot.bm = null;
|
||||
slot.id = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const InstallTrigger: any;
|
||||
|
||||
|
||||
// FIXME: move to AnimationManager ctor to control it from xterm side
|
||||
const isChromium = (window as any).chrome !== undefined;
|
||||
const isFirefox: boolean = typeof InstallTrigger !== 'undefined' || 'MozAppearance' in document.documentElement.style;
|
||||
|
||||
|
||||
/**
|
||||
* Helper to create a read to use ImageDecoder instance from a blob.
|
||||
*/
|
||||
async function BlobDecoder(blob: Blob, type: string): Promise<ImageDecoder> {
|
||||
/**
|
||||
* Dealing with browser bugs - in theory this should be possible:
|
||||
*
|
||||
* const decoder = new ImageDecoder({ data: blob.stream(), type, preferAnimation: true });
|
||||
* await decoder.tracks.ready;
|
||||
* return decoder;
|
||||
*
|
||||
* But this straightforward handling with blob.stream() uncovers several bugs:
|
||||
* 1. Chromium: Fails to stream chunked blobs reliably.
|
||||
* 2. Firefox: Native lifecycle race condition. Closing an old decoder breaks the
|
||||
* shared underlying native stream of the same blob for new decoders, throwing
|
||||
* an uncatchable "Closed decoder" DOMException during `tracks.ready`.
|
||||
*
|
||||
* So we use a safer approach by pulling full raw bytes over first and transferring ownership.
|
||||
*/
|
||||
const data = await blob.arrayBuffer();
|
||||
const decoder = new ImageDecoder({ data, type, preferAnimation: true, transfer: [data] });
|
||||
await decoder.tracks.ready;
|
||||
if (isChromium && type === 'image/avif') {
|
||||
// chromium bug: random frame access in AVIF is
|
||||
// only possible after reading frame 0
|
||||
const res = await decoder.decode({ frameIndex: 0 });
|
||||
res.image.close();
|
||||
}
|
||||
return decoder;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* AnimationManager - orchestrates rendering of animated images.
|
||||
*
|
||||
* Current Workflow
|
||||
* When IIPMetrics.imageType reports an animated image the IIP handler
|
||||
* stores the image in the storage and registers an animation of it.
|
||||
*
|
||||
* The animation manager works mostly self-contained:
|
||||
* - RAF loop
|
||||
* - 3 frame ahead decoding
|
||||
* - own bitmap ownership handling
|
||||
*
|
||||
* There are a few intersections with the ImageStorage and the ImageRenderer,
|
||||
* which still need to be consolidated (TODOs):
|
||||
* - ImageStorage: API for announcing memory state, better eviction strategy
|
||||
* - ImageRenderer: API for better drawing primitives
|
||||
*
|
||||
* In particular the ownership model of ImageSpec bitmaps (actual and orig)
|
||||
* needs a better story. We prolly need here an API to flip bitmaps.
|
||||
*/
|
||||
export class AnimationManager implements IDisposable, IResetHandler {
|
||||
private _animations = new Map<number, IAnimation>();
|
||||
private _animationFrame: number | undefined = undefined;
|
||||
private _draws: IImageDraw[] = [];
|
||||
|
||||
|
||||
constructor(
|
||||
private _storage: ImageStorage,
|
||||
private _renderer: ImageRenderer,
|
||||
private _terminal: ITerminalExt
|
||||
) {}
|
||||
|
||||
|
||||
private _requestAF(cb: FrameRequestCallback): number | undefined {
|
||||
return this._terminal._core._coreBrowserService?.window.requestAnimationFrame(cb);
|
||||
}
|
||||
|
||||
|
||||
private _cancelAF(id: number): void | undefined {
|
||||
return this._terminal._core._coreBrowserService?.window.cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
|
||||
public dispose(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reset handler.
|
||||
* Stops all animations and frees all ressources.
|
||||
*/
|
||||
public reset(): void {
|
||||
for (const anim of this._animations.values()) {
|
||||
anim.inViewport = false;
|
||||
this._closeDecoder(anim);
|
||||
anim.cache.dispose();
|
||||
}
|
||||
this._animations.clear();
|
||||
this._draws = [];
|
||||
if (this._animationFrame) this._cancelAF(this._animationFrame);
|
||||
this._animationFrame = undefined;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Main hook to register an animated image for animation.
|
||||
*
|
||||
* @param id Image ID of the image storage the image was registered under.
|
||||
* @param data Raw data bytes of the image.
|
||||
* @param metrics Metrics of the image as returned by IIPMetrics.imageType.
|
||||
* @param w Output width in pixel.
|
||||
* @param h Output height in pixel.
|
||||
*/
|
||||
public async registerAnimation(id: number, data: Blob, metrics: IMetrics, w: number, h: number): Promise<void> {
|
||||
let decoder = await BlobDecoder(data, metrics.mime);
|
||||
const track = decoder.tracks.selectedTrack;
|
||||
if (!track || track.frameCount < 2) {
|
||||
decoder.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const frameCount = Math.min(track.frameCount, Constants.MAX_FRAMES);
|
||||
const frames: number[] = [];
|
||||
const timestamps: number[] = [];
|
||||
const durations: number[] = [];
|
||||
let elapsed = 0;
|
||||
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
try {
|
||||
const result = await decoder.decode({ frameIndex: i });
|
||||
const frame = result.image;
|
||||
const width = frame.displayWidth;
|
||||
const height = frame.displayHeight;
|
||||
const duration = (frame.duration ?? 0) / 1000;
|
||||
frame.close();
|
||||
// skip wrong dimensions and zero duration
|
||||
if (width !== metrics.width || height !== metrics.height || !duration) {
|
||||
if (durations.length) {
|
||||
durations[durations.length - 1] += duration;
|
||||
elapsed += duration;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
frames.push(i);
|
||||
timestamps.push(elapsed);
|
||||
durations.push(duration);
|
||||
elapsed += duration;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXME: still needs a consolidation of short frame durations:
|
||||
* We don't track v-sync, but just decode the next 3 follow-up frames.
|
||||
* This creates nonsense decoder pressure decoding frames,
|
||||
* that effectively never make it to the screen.
|
||||
* Currently our shortest time resolution is 1ms,
|
||||
* thus with 60 fps we would decode 15 1ms frames for no reason.
|
||||
* We prolly should introduce a lower bound of 8ms:
|
||||
* - in sync with 120 fps
|
||||
* - only every second frame is nonsense with 60 fps
|
||||
*/
|
||||
|
||||
if (frames.length < 2 || elapsed <= 0) {
|
||||
decoder.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFirefox) {
|
||||
decoder.close();
|
||||
decoder = await BlobDecoder(data, metrics.mime);
|
||||
}
|
||||
|
||||
const anim: IAnimation = {
|
||||
id, data, w, h, decoder,
|
||||
metrics,
|
||||
numFrames: frames.length,
|
||||
decoderPromise: undefined,
|
||||
cache: new BitmapBuffer(),
|
||||
frames,
|
||||
startTime: undefined,
|
||||
current: 0,
|
||||
actual: 0,
|
||||
scheduled: false, // set by _schedule
|
||||
inViewport: true, // start with active prefetching
|
||||
highest: -1,
|
||||
playtime: elapsed,
|
||||
timestamps,
|
||||
frameIdx: 0
|
||||
};
|
||||
this._animations.set(id, anim);
|
||||
this.viewportUpdated();
|
||||
// early schedule to give decoder time to fill the cache before RAF
|
||||
this._schedule(anim);
|
||||
this._animationFrame ??= this._requestAF(this._loop);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Remove the animation for the given image id.
|
||||
*/
|
||||
public unregisterAnimation(id: number): void {
|
||||
const a = this._animations.get(id);
|
||||
if (a) {
|
||||
a.inViewport = false;
|
||||
this._closeDecoder(a);
|
||||
a.cache.dispose();
|
||||
this._animations.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* onRender handler.
|
||||
* Scratches the viewport buffer to collect draws for animation RAF
|
||||
* and to pause/resume invisible/visible animations.
|
||||
*/
|
||||
public viewportUpdated(_range?: { start: number, end: number }): void {
|
||||
const rows = this._terminal.rows;
|
||||
const cols = this._terminal.cols;
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const actives: Set<number> = new Set();
|
||||
this._draws = [];
|
||||
for (let row = 0; row < rows; ++row) {
|
||||
const line = buffer.lines.get(row + buffer.ydisp);
|
||||
if (!line) break;
|
||||
for (let col = 0; col < cols; ++col) {
|
||||
const e = line.getExtended(col)?.payload;
|
||||
if (e instanceof ImageTileInfo) {
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
}
|
||||
const imgSpec = this._storage.getImage(imageId);
|
||||
if (e.tileId !== -1) {
|
||||
const startTile = e.tileId;
|
||||
const startCol = col;
|
||||
let count = 1;
|
||||
/**
|
||||
* merge tiles to the right into a single draw call, if:
|
||||
* - not at end of line
|
||||
* - cell has same image id
|
||||
* - cell has consecutive tile id
|
||||
*/
|
||||
while (++col < cols) {
|
||||
const nextE = line.getExtended(col)?.payload;
|
||||
if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
col--;
|
||||
if (imgSpec && this._animations.has(imageId)) {
|
||||
this._draws.push({ imageId, imgSpec, tileId: startTile, col: startCol, row, count });
|
||||
actives.add(imageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this._updateAnimations(actives);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the frameIdx for elapsed time.
|
||||
* Note: The returned value is not the frame ID,
|
||||
* but the index for IAnimation.frames.
|
||||
*/
|
||||
private _timedFrameIdx(a: IAnimation, elapsed: number): number {
|
||||
elapsed %= a.playtime;
|
||||
let low = 0;
|
||||
let high = a.timestamps.length - 1;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
const startTime = a.timestamps[mid];
|
||||
if (elapsed < startTime) {
|
||||
high = mid - 1;
|
||||
} else {
|
||||
if (mid === a.timestamps.length - 1 || elapsed < a.timestamps[mid + 1]) {
|
||||
return mid;
|
||||
}
|
||||
low = mid + 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause/resume decoding from viewport changes.
|
||||
*/
|
||||
private _updateAnimations(visibles: Set<number>): void {
|
||||
for (const [id, anim] of this._animations) {
|
||||
const isVisible = visibles.has(id);
|
||||
if (isVisible && !anim.inViewport) {
|
||||
/**
|
||||
* There are two strategies possible here:
|
||||
*
|
||||
* 1. Resume decoding and pumping frames as soon as possible.
|
||||
* 2. Delay resuming further by a debounce to prevent flooding
|
||||
* new decoder instances on heavy scrolling.
|
||||
*
|
||||
* Currently the code below does 1. as the impact is rather
|
||||
* small from tests and gives the better user experience.
|
||||
*/
|
||||
anim.inViewport = true;
|
||||
const frameIdx = this._timedFrameIdx(anim, performance.now() - anim.startTime!);
|
||||
anim.frameIdx = frameIdx;
|
||||
anim.current = anim.frames[frameIdx];
|
||||
if (!anim.decoder && !anim.decoderPromise) {
|
||||
anim.decoderPromise = BlobDecoder(anim.data, anim.metrics.mime);
|
||||
}
|
||||
this._schedule(anim);
|
||||
} else if (!isVisible && anim.inViewport) {
|
||||
anim.inViewport = false;
|
||||
anim.cache.clear();
|
||||
this._closeDecoder(anim);
|
||||
}
|
||||
}
|
||||
if (visibles.size && this._animationFrame === undefined) {
|
||||
this._animationFrame = this._requestAF(this._loop);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* RAF for animations in viewport.
|
||||
*/
|
||||
private _loop = (ts: number): void => {
|
||||
if (!this._animations.size) {
|
||||
this._animationFrame = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let mustRender = false;
|
||||
let anyVisible = false;
|
||||
|
||||
for (const anim of this._animations.values()) {
|
||||
if (!anim.inViewport) continue;
|
||||
|
||||
const spec = this._storage.getImage(anim.id);
|
||||
if (!spec) {
|
||||
this.unregisterAnimation(anim.id);
|
||||
continue;
|
||||
}
|
||||
anyVisible = true;
|
||||
anim.startTime ??= ts;
|
||||
const frameIdx = this._timedFrameIdx(anim, ts - anim.startTime);
|
||||
anim.frameIdx = frameIdx;
|
||||
const current = anim.frames[frameIdx];
|
||||
|
||||
if (current !== anim.current) {
|
||||
anim.current = current;
|
||||
const bm = anim.cache.pop(current);
|
||||
if (bm) {
|
||||
// FIXME: setting spec.actual delegates draws from onRender back to ImageStorage.render
|
||||
// --> should be avoided to prevent double draws within same AF
|
||||
// idea: create a mutex on image spec to tell storage.render to skip
|
||||
|
||||
// by swapping spec.actual we take ownership, thus have to close it here
|
||||
// but only if we have more than 4 frames (ringbuffer size is 4)
|
||||
// for <= 4 frames we move the bitmap back into cache
|
||||
// FIXME: create a bitmap swapping API in storage
|
||||
if (spec.actual instanceof ImageBitmap) {
|
||||
if (anim.numFrames > Constants.BUFFERSIZE) {
|
||||
spec.actual.close();
|
||||
} else {
|
||||
anim.cache.push(anim.actual, spec.actual);
|
||||
}
|
||||
}
|
||||
spec.actual = bm;
|
||||
anim.actual = current;
|
||||
mustRender = true;
|
||||
}
|
||||
this._schedule(anim);
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: merge clears and draws into col x row rects
|
||||
if (mustRender) {
|
||||
for (let i = 0; i < this._draws.length; ++i) {
|
||||
const d = this._draws[i];
|
||||
this._renderer.clearLines(d.row, d.row);
|
||||
this._renderer.draw(d.imgSpec, d.tileId, d.col, d.row, d.count);
|
||||
}
|
||||
}
|
||||
this._animationFrame = anyVisible ? this._requestAF(this._loop) : undefined;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Try to schedule frame decoding.
|
||||
*/
|
||||
private _schedule(a: IAnimation): void {
|
||||
if (!a.scheduled) {
|
||||
let needsSchedule = false;
|
||||
// FIXME: do we need a == 4 test too?
|
||||
if (a.numFrames > 3) {
|
||||
needsSchedule =
|
||||
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]) ||
|
||||
!a.cache.peek(a.frames[(a.frameIdx + 2) % a.numFrames]) ||
|
||||
!a.cache.peek(a.frames[(a.frameIdx + 3) % a.numFrames]);
|
||||
} else if (a.numFrames === 3) {
|
||||
needsSchedule =
|
||||
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]) ||
|
||||
!a.cache.peek(a.frames[(a.frameIdx + 2) % a.numFrames]);
|
||||
} else {
|
||||
needsSchedule =
|
||||
!a.cache.peek(a.frames[(a.frameIdx + 1) % a.numFrames]);
|
||||
}
|
||||
if (needsSchedule) {
|
||||
a.scheduled = true;
|
||||
setTimeout(() => this._decode(a), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decode up to next 3 frames.
|
||||
*/
|
||||
private async _decode(a: IAnimation): Promise<void> {
|
||||
if (!a.inViewport) {
|
||||
a.scheduled = false;
|
||||
return;
|
||||
}
|
||||
if (!a.decoder) {
|
||||
const decP = a.decoderPromise;
|
||||
a.decoderPromise = undefined;
|
||||
a.decoder = await (decP ?? BlobDecoder(a.data, a.metrics.mime));
|
||||
}
|
||||
const n1 = a.frames[(a.frameIdx + 1) % a.numFrames];
|
||||
const n2 = a.frames[(a.frameIdx + 2) % a.numFrames];
|
||||
const n3 = a.frames[(a.frameIdx + 3) % a.numFrames];
|
||||
for (const n of [n1, n2, n3]) {
|
||||
if (!a.cache.peek(n)) {
|
||||
if (isFirefox) {
|
||||
if (n <= a.highest) {
|
||||
a.highest = -1;
|
||||
a.decoder?.close();
|
||||
a.decoder = await BlobDecoder(a.data, a.metrics.mime);
|
||||
}
|
||||
a.highest = Math.max(n, a.highest);
|
||||
}
|
||||
let res: ImageDecodeResult;
|
||||
try {
|
||||
res = await a.decoder.decode({ frameIndex: n });
|
||||
} catch { continue; }
|
||||
let bm: ImageBitmap;
|
||||
try {
|
||||
// FIXME: we should not resize here yet
|
||||
// BETTER: rescale during draw:
|
||||
// - eases the GPU druing decoding
|
||||
// - rescales only what needs to be drawn
|
||||
// --> funneling down
|
||||
// FIXME: needs changes in image spec and rendering functions
|
||||
bm = await createImageBitmap(res.image, { resizeWidth: a.w, resizeHeight: a.h });
|
||||
} catch { continue; } finally { res?.image?.close(); }
|
||||
if (!a.inViewport) {
|
||||
bm.close();
|
||||
break;
|
||||
}
|
||||
a.cache.push(n, bm);
|
||||
}
|
||||
}
|
||||
a.scheduled = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Close the decoder defensively.
|
||||
*/
|
||||
private _closeDecoder(a: IAnimation): void {
|
||||
const decoder = a.decoder;
|
||||
a.decoder = undefined;
|
||||
try { decoder?.close(); } catch {}
|
||||
const decP = a.decoderPromise;
|
||||
a.decoderPromise = undefined;
|
||||
decP?.then(dec => dec && dec !== decoder ? dec.close() : null).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,15 @@
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||
import type { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { IIPImageStorage } from './IIPImageStorage';
|
||||
import type { IIPImageStorage } from './IIPImageStorage';
|
||||
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||
import type { AnimationManager } from 'AnimationManager';
|
||||
|
||||
// Local const enum mirror - esbuild can't inline const enums from external packages
|
||||
const enum DecoderConst {
|
||||
@@ -39,7 +40,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
private _header: IHeaderFields = DEFAULT_HEADER;
|
||||
private _dec: Base64Decoder;
|
||||
private _qoiDec: QoiDecoder;
|
||||
private _metrics = UNSUPPORTED_TYPE;
|
||||
private _isMultipart = false;
|
||||
private _abortMulti = false;
|
||||
|
||||
@@ -47,7 +47,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _storage: IIPImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
private readonly _coreTerminal: ITerminalExt,
|
||||
private readonly _aniManager: AnimationManager | undefined
|
||||
) {
|
||||
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
||||
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
||||
@@ -63,7 +64,6 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
|
||||
public start(): void {
|
||||
this._aborted = false;
|
||||
this._metrics = UNSUPPORTED_TYPE;
|
||||
this._hp.reset();
|
||||
}
|
||||
|
||||
@@ -120,14 +120,14 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
|
||||
if (seqType === SequenceType.REPORTCELLSIZE) {
|
||||
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
|
||||
let width = CELL_SIZE_DEFAULT.width;
|
||||
let height = CELL_SIZE_DEFAULT.height;
|
||||
let w = CELL_SIZE_DEFAULT.width;
|
||||
let h = CELL_SIZE_DEFAULT.height;
|
||||
if (this._renderer.dimensions) {
|
||||
width = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
|
||||
height = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
|
||||
w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
|
||||
h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
|
||||
}
|
||||
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
|
||||
const report = `\x1b]1337;ReportCellSize=${height.toFixed(3)};${width.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
|
||||
const report = `\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
|
||||
this._coreTerminal.input(report, false);
|
||||
return true;
|
||||
}
|
||||
@@ -154,17 +154,24 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
|
||||
// early exit condition chain
|
||||
let cond: number | boolean;
|
||||
let metrics = UNSUPPORTED_TYPE;
|
||||
if (cond = success) {
|
||||
if (cond = !this._dec.end()) {
|
||||
this._metrics = imageType(this._dec.data8);
|
||||
if (cond = this._metrics.mime !== 'unsupported') {
|
||||
w = this._metrics.width;
|
||||
h = this._metrics.height;
|
||||
metrics = imageType(this._dec.data8);
|
||||
if (cond = metrics.mime !== 'unsupported') {
|
||||
w = metrics.width;
|
||||
h = metrics.height;
|
||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||
[w, h] = this._resize(w, h).map(Math.floor);
|
||||
cond = w && h && w * h < this._opts.pixelLimit;
|
||||
} else {
|
||||
console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);
|
||||
}
|
||||
} else {
|
||||
console.warn('IIP: unsupported image type');
|
||||
}
|
||||
} else {
|
||||
console.warn('IIP: error during BASE64 decoding');
|
||||
}
|
||||
}
|
||||
if (!cond) {
|
||||
@@ -173,7 +180,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
}
|
||||
|
||||
let blob: Blob | ImageData;
|
||||
if (this._metrics.mime === 'image/qoi') {
|
||||
if (metrics.mime === 'image/qoi') {
|
||||
const data = this._qoiDec.decode(this._dec.data8);
|
||||
blob = new ImageData(
|
||||
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
||||
@@ -190,12 +197,20 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([this._dec.data8], { type: this._metrics.mime });
|
||||
blob = new Blob([this._dec.data8], { type: metrics.mime });
|
||||
}
|
||||
this._dec.release();
|
||||
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
||||
.then(bm => {
|
||||
this._storage.addImage(bm);
|
||||
const id = this._storage.addImage(bm);
|
||||
if (this._aniManager && metrics.animated) {
|
||||
this._aniManager.registerAnimation(id, blob as Blob, metrics, w, h).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
|
||||
// FIXME: try ImageDecoder here instead (e.g. some AVIF work in Firefox)
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export class IIPImageStorage {
|
||||
* Add an IIP image to storage.
|
||||
* Always uses scrolling mode — cursor advances past the image.
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||
this._storage.addImage(img, this._addImageOpts);
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
|
||||
return this._storage.addImage(img, this._addImageOpts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,26 +12,48 @@ const fs = require('fs');
|
||||
|
||||
|
||||
const TEST_IMAGES: [string, IMetrics][] = [
|
||||
['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
||||
['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
||||
['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48 }],
|
||||
['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
||||
['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
||||
['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48 }],
|
||||
['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
||||
['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
||||
['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48 }],
|
||||
['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
||||
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48 }],
|
||||
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
||||
['w3c_home.png', { mime: 'image/png', width: 72, height: 48 }],
|
||||
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
||||
['spinfox.png', { mime: 'image/png', width: 148, height: 148 }],
|
||||
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
|
||||
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
|
||||
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
|
||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
|
||||
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
|
||||
['w3c_home_256.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_256.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_256.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_2.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_2.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_2.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_animation.gif', { mime: 'image/gif', width: 72, height: 48, animated: true }],
|
||||
['w3c_home.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_gray.gif', { mime: 'image/gif', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_gray.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
|
||||
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||
['w3c_home.png', { mime: 'image/png', width: 72, height: 48, animated: false }],
|
||||
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48, animated: false }],
|
||||
['spinfox.png', { mime: 'image/png', width: 148, height: 148, animated: true }],
|
||||
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448, animated: false }],
|
||||
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600, animated: false }],
|
||||
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8, animated: false }],
|
||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000, animated: false }],
|
||||
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600, animated: false }],
|
||||
// VP8
|
||||
['1.webp', { mime: 'image/webp', width: 550, height: 368, animated: false }],
|
||||
['2.webp', { mime: 'image/webp', width: 550, height: 404, animated: false }],
|
||||
['3.webp', { mime: 'image/webp', width: 1280, height: 720, animated: false }],
|
||||
['4.webp', { mime: 'image/webp', width: 1024, height: 772, animated: false }],
|
||||
['5.webp', { mime: 'image/webp', width: 1024, height: 752, animated: false }],
|
||||
// VP8X
|
||||
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301, animated: false }],
|
||||
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395, animated: false }],
|
||||
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600, animated: false }],
|
||||
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163, animated: false }],
|
||||
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300, animated: false }],
|
||||
// VP8L
|
||||
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301, animated: false }],
|
||||
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395, animated: false }],
|
||||
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600, animated: false }],
|
||||
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163, animated: false }],
|
||||
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300, animated: false }],
|
||||
// some AVIF test images
|
||||
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799, animated: false }],
|
||||
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048, animated: false }],
|
||||
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024, animated: false }],
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -4,62 +4,140 @@
|
||||
*/
|
||||
|
||||
|
||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
|
||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'image/webp' | 'image/avif' | 'unsupported' | '';
|
||||
|
||||
export interface IMetrics {
|
||||
mime: ImageType;
|
||||
width: number;
|
||||
height: number;
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_TYPE: IMetrics = {
|
||||
mime: 'unsupported',
|
||||
width: 0,
|
||||
height: 0
|
||||
height: 0,
|
||||
animated: false
|
||||
};
|
||||
|
||||
export function imageType(d: Uint8Array): IMetrics {
|
||||
if (d.length < 24) {
|
||||
if (d.length < 32) {
|
||||
return UNSUPPORTED_TYPE;
|
||||
}
|
||||
const d32 = new Uint32Array(d.buffer, d.byteOffset, 6);
|
||||
const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
|
||||
// + first chunk must be IHDR
|
||||
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
|
||||
return {
|
||||
mime: 'image/png',
|
||||
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
|
||||
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
|
||||
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
|
||||
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23],
|
||||
animated: isAPNG(d)
|
||||
};
|
||||
}
|
||||
// JPEG: FF D8 FF
|
||||
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
|
||||
const [width, height] = jpgSize(d);
|
||||
return { mime: 'image/jpeg', width, height };
|
||||
return { mime: 'image/jpeg', width, height, animated: false };
|
||||
}
|
||||
// GIF: GIF87a or GIF89a
|
||||
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
|
||||
return {
|
||||
mime: 'image/gif',
|
||||
width: d[7] << 8 | d[6],
|
||||
height: d[9] << 8 | d[8]
|
||||
width: d[6] | d[7] << 8,
|
||||
height: d[8] | d[9] << 8,
|
||||
animated: isAGIF(d)
|
||||
};
|
||||
}
|
||||
// QOI: qoif
|
||||
if (d32[0] === 0x66696F71) {
|
||||
return {
|
||||
mime: 'image/qoi',
|
||||
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
|
||||
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
|
||||
width: d[4] << 24 | d[5] << 16 | d[ 6] << 8 | d[ 7],
|
||||
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11],
|
||||
animated: false
|
||||
};
|
||||
}
|
||||
// WEBP: RIFF | xxxx | WEBP | VP8x
|
||||
if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {
|
||||
switch (d[15]) {
|
||||
case 0x58: // Extended WebP VP8X --> "X"
|
||||
return {
|
||||
mime: 'image/webp',
|
||||
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
|
||||
height: (d[27] | d[28] << 8 | d[29] << 16) + 1,
|
||||
animated: (d[20] & 0x02) !== 0
|
||||
};
|
||||
case 0x4C: // Lossless WebP VP8L --> "L"
|
||||
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
|
||||
const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;
|
||||
return {
|
||||
mime: 'image/webp',
|
||||
width: (dim & 0x3FFF) + 1,
|
||||
height: (dim >>> 14 & 0x3FFF) + 1,
|
||||
animated: false
|
||||
};
|
||||
case 0x20: // Lossy WebP VP8 --> " "
|
||||
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
|
||||
return {
|
||||
mime: 'image/webp',
|
||||
width: (d[26] | d[27] << 8) & 0x3FFF,
|
||||
height: (d[28] | d[29] << 8) & 0x3FFF,
|
||||
animated: false
|
||||
};
|
||||
}
|
||||
return UNSUPPORTED_TYPE;
|
||||
}
|
||||
// AVIF: Box size | ftyp | avif/avis
|
||||
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
|
||||
let pos = -1;
|
||||
let anim = d32[2] === 0x73697661; // avis is meant to be animated
|
||||
// search for boxes within first 1024 bytes
|
||||
const limit = Math.min(d.length - 16, 1024);
|
||||
for (let i = 8; i < limit; i++) {
|
||||
// scan for ispe for dimensions
|
||||
if (pos === -1 && d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
|
||||
pos = i;
|
||||
}
|
||||
// scan for stsz or moov
|
||||
if (!anim) {
|
||||
const isStsz = d[i] === 0x73 && d[i + 1] === 0x74 && d[i + 2] === 0x73 && d[i + 3] === 0x7A;
|
||||
const isMoov = d[i] === 0x6D && d[i + 1] === 0x6F && d[i + 2] === 0x6F && d[i + 3] === 0x76;
|
||||
if (isStsz || isMoov) {
|
||||
anim = true;
|
||||
}
|
||||
}
|
||||
if (pos !== -1 && anim) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pos !== -1) {
|
||||
// dimensions are in BE at +8 (width) at +12 (height)
|
||||
const width =
|
||||
d[pos + 8] << 24 |
|
||||
d[pos + 9] << 16 |
|
||||
d[pos + 10] << 8 |
|
||||
d[pos + 11];
|
||||
const height =
|
||||
d[pos + 12] << 24 |
|
||||
d[pos + 13] << 16 |
|
||||
d[pos + 14] << 8 |
|
||||
d[pos + 15];
|
||||
if (width > 0 && height > 0) {
|
||||
return { mime: 'image/avif', width, height, animated: anim };
|
||||
}
|
||||
}
|
||||
return UNSUPPORTED_TYPE;
|
||||
}
|
||||
return UNSUPPORTED_TYPE;
|
||||
}
|
||||
|
||||
|
||||
function jpgSize(d: Uint8Array): [number, number] {
|
||||
const len = d.length;
|
||||
let i = 4;
|
||||
let blockLength = d[i] << 8 | d[i + 1];
|
||||
if (blockLength < 2) return [0, 0];
|
||||
while (true) {
|
||||
i += blockLength;
|
||||
if (i >= len) {
|
||||
@@ -80,5 +158,78 @@ function jpgSize(d: Uint8Array): [number, number] {
|
||||
}
|
||||
i += 2;
|
||||
blockLength = d[i] << 8 | d[i + 1];
|
||||
if (blockLength < 2) return [0, 0];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function isAGIF(data: Uint8Array): boolean {
|
||||
let pos = 10;
|
||||
const gPacked = data[pos];
|
||||
const hasGP = (gPacked & 0x80) !== 0;
|
||||
pos += 3;
|
||||
if (hasGP) {
|
||||
const gpSize = 2 << (gPacked & 0x07);
|
||||
pos += 3 * gpSize;
|
||||
}
|
||||
let gceCount = 0;
|
||||
while (pos < data.length) {
|
||||
const blockType = data[pos];
|
||||
if (blockType === 0x21) { // Extension Block
|
||||
if (pos + 1 >= data.length) return false;
|
||||
const extLabel = data[pos + 1];
|
||||
if (extLabel === 0xF9) { // Graphic Control Extension
|
||||
gceCount++;
|
||||
if (gceCount > 1) return true; // early exit on 2nd frame
|
||||
}
|
||||
pos += 2;
|
||||
while (pos < data.length) {
|
||||
const blockSize = data[pos];
|
||||
if (blockSize === 0x00) break;
|
||||
pos += blockSize + 1;
|
||||
}
|
||||
if (pos >= data.length) return false;
|
||||
pos++;
|
||||
} else if (blockType === 0x2C) { // Image Descriptor
|
||||
if (pos + 10 >= data.length) return false;
|
||||
pos += 10;
|
||||
const lPacked = data[pos - 1];
|
||||
if ((lPacked & 0x80) !== 0) {
|
||||
const lpSize = 2 << (lPacked & 0x07);
|
||||
pos += 3 * lpSize;
|
||||
}
|
||||
if (pos >= data.length) return false;
|
||||
pos++;
|
||||
while (pos < data.length) {
|
||||
const blockSize = data[pos];
|
||||
if (blockSize === 0x00) break;
|
||||
pos += blockSize + 1;
|
||||
}
|
||||
if (pos >= data.length) return false;
|
||||
pos++;
|
||||
} else if (blockType === 0x3B) {
|
||||
break;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function isAPNG(d: Uint8Array): boolean {
|
||||
let p = 8;
|
||||
while (p + 8 < d.length) {
|
||||
const length = (d[p] << 24 | d[p + 1] << 16 | d[p + 2] << 8 | d[p + 3]) >>> 0;
|
||||
// 'acTL' before 'IDAT': APNG
|
||||
if (d[p + 4] === 0x61 && d[p + 5] === 0x63 && d[p + 6] === 0x54 && d[p + 7] === 0x4c) {
|
||||
return true;
|
||||
}
|
||||
// 'IDAT' first: no APNG
|
||||
if (d[p + 4] === 0x49 && d[p + 5] === 0x44 && d[p + 6] === 0x41 && d[p + 7] === 0x54) {
|
||||
return false;
|
||||
}
|
||||
p += 12 + length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SixelHandler } from './SixelHandler';
|
||||
import { SixelImageStorage } from './SixelImageStorage';
|
||||
import { IIPImageStorage } from './IIPImageStorage';
|
||||
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
||||
import { AnimationManager } from './AnimationManager';
|
||||
|
||||
|
||||
/**
|
||||
@@ -70,7 +71,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 33554432,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 33554432
|
||||
kittySizeLimit: 33554432,
|
||||
animationSupport: true, // alpha
|
||||
animationPixelLimit: 4194304 // limit to 2048 * 2048 pixels
|
||||
};
|
||||
|
||||
// max palette size supported by the sixel lib (compile time setting)
|
||||
@@ -104,6 +107,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
private _disposables: IDisposable[] = [];
|
||||
private _terminal: ITerminalExt | undefined;
|
||||
private _handlers: Map<String, IResetHandler> = new Map();
|
||||
private _aniManager: AnimationManager | undefined;
|
||||
private readonly _onImageAdded = new Emitter<void>();
|
||||
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
|
||||
|
||||
@@ -144,6 +148,11 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
terminal.options.windowOptions = windowOps;
|
||||
}
|
||||
|
||||
if (this._opts.animationSupport && typeof ImageDecoder === 'function') {
|
||||
this._aniManager = new AnimationManager(this._storage, this._renderer, terminal);
|
||||
this._disposeLater(terminal.onRender(range => this._aniManager!.viewportUpdated(range)));
|
||||
}
|
||||
|
||||
this._disposeLater(
|
||||
this._renderer,
|
||||
this._storage,
|
||||
@@ -187,7 +196,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
// iTerm IIP handler
|
||||
if (this._opts.iipSupport) {
|
||||
const iipStorage = new IIPImageStorage(this._storage!);
|
||||
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);
|
||||
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal, this._aniManager);
|
||||
this._handlers.set('iip', iipHandler);
|
||||
this._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||
@@ -218,6 +227,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
for (const handler of this._handlers.values()) {
|
||||
handler.reset();
|
||||
}
|
||||
this._aniManager?.reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@
|
||||
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import {
|
||||
ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,
|
||||
IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,
|
||||
UnderlineStyle, IAddImageOpts
|
||||
import type {
|
||||
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts
|
||||
} from './Types';
|
||||
|
||||
import type { IBufferLine } from 'common/buffer/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
|
||||
// fallback default cell size
|
||||
export const CELL_SIZE_DEFAULT: ICellSize = {
|
||||
@@ -18,93 +17,12 @@ export const CELL_SIZE_DEFAULT: ICellSize = {
|
||||
height: 14
|
||||
};
|
||||
|
||||
/**
|
||||
* Extend extended attribute to also hold image tile information.
|
||||
*
|
||||
* Object definition is copied from base repo to fully mimick its behavior.
|
||||
* Image data is added as additional public properties `imageId` and `tileId`.
|
||||
*/
|
||||
class ExtendedAttrsImage implements IExtendedAttrsImage {
|
||||
private _ext: number = 0;
|
||||
public get ext(): number {
|
||||
if (this._urlId) {
|
||||
return (
|
||||
(this._ext & ~ExtFlags.UNDERLINE_STYLE) |
|
||||
(this.underlineStyle << 26)
|
||||
);
|
||||
}
|
||||
return this._ext;
|
||||
}
|
||||
public set ext(value: number) { this._ext = value; }
|
||||
|
||||
public get underlineStyle(): UnderlineStyle {
|
||||
// Always return the URL style if it has one
|
||||
if (this._urlId) {
|
||||
return UnderlineStyle.DASHED;
|
||||
}
|
||||
return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;
|
||||
}
|
||||
public set underlineStyle(value: UnderlineStyle) {
|
||||
this._ext &= ~ExtFlags.UNDERLINE_STYLE;
|
||||
this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;
|
||||
}
|
||||
|
||||
public get underlineColor(): number {
|
||||
return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
}
|
||||
public set underlineColor(value: number) {
|
||||
this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
}
|
||||
|
||||
public get underlineVariantOffset(): number {
|
||||
const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;
|
||||
if (val < 0) {
|
||||
return val ^ 0xFFFFFFF8;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
public set underlineVariantOffset(value: number) {
|
||||
this._ext &= ~ExtFlags.VARIANT_OFFSET;
|
||||
this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;
|
||||
}
|
||||
|
||||
private _urlId: number = 0;
|
||||
public get urlId(): number {
|
||||
return this._urlId;
|
||||
}
|
||||
public set urlId(value: number) {
|
||||
this._urlId = value;
|
||||
}
|
||||
|
||||
export class ImageTileInfo {
|
||||
constructor(
|
||||
ext: number = 0,
|
||||
urlId: number = 0,
|
||||
public imageId = -1,
|
||||
public tileId = -1
|
||||
) {
|
||||
this._ext = ext;
|
||||
this._urlId = urlId;
|
||||
}
|
||||
|
||||
public clone(): IExtendedAttrsImage {
|
||||
/**
|
||||
* Technically we dont need a clone variant of ExtendedAttrsImage,
|
||||
* as we never clone a cell holding image data.
|
||||
* Note: Clone is only meant to be used by the InputHandler for
|
||||
* sticky attributes, which is never the case for image data.
|
||||
* We still provide a proper clone method to reflect the full ext attr
|
||||
* state in case there are future use cases for clone.
|
||||
*/
|
||||
return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);
|
||||
}
|
||||
|
||||
public isEmpty(): boolean {
|
||||
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;
|
||||
public tileId = -1) {
|
||||
}
|
||||
}
|
||||
const EMPTY_ATTRS = new ExtendedAttrsImage();
|
||||
|
||||
|
||||
/**
|
||||
* ImageStorage - extension of CoreTerminal:
|
||||
@@ -126,6 +44,7 @@ export class ImageStorage implements IDisposable {
|
||||
private _needsFullClear = false;
|
||||
// hard limit of stored pixels (fallback limit of 10 MB)
|
||||
private _pixelLimit: number = 2500000;
|
||||
private _workCell: CellData = new CellData();
|
||||
|
||||
private _viewportMetrics: { cols: number, rows: number };
|
||||
public onImageAdded: (() => void) | undefined;
|
||||
@@ -224,6 +143,13 @@ export class ImageStorage implements IDisposable {
|
||||
this._fullyCleared = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve image spec from storage.
|
||||
*/
|
||||
public getImage(id: number): IImageSpec | undefined {
|
||||
return this._images.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image by its internal storage ID.
|
||||
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
||||
@@ -277,10 +203,10 @@ export class ImageStorage implements IDisposable {
|
||||
|
||||
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
||||
for (let row = 0; row < rows; ++row) {
|
||||
const line = buffer.lines.get(buffer.y + buffer.ybase);
|
||||
const line = buffer.lines.get(buffer.y + buffer.ybase)!;
|
||||
for (let col = 0; col < cols; ++col) {
|
||||
if (offset + col >= termCols) break;
|
||||
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
|
||||
this._writeToCell(line, offset + col, imageId, row * cols + col);
|
||||
tileCount++;
|
||||
}
|
||||
if (opts.scrolling) {
|
||||
@@ -427,55 +353,44 @@ export class ImageStorage implements IDisposable {
|
||||
const placeholderCalls: { col: number, row: number, count: number }[] = [];
|
||||
|
||||
// walk all cells in viewport and collect tiles found
|
||||
// Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag)
|
||||
// because text writes clear the BG flag but leave image tile data intact.
|
||||
// This lets top-layer images survive text overwrites (kitty C=1 behavior).
|
||||
for (let row = start; row <= end; ++row) {
|
||||
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt | undefined;
|
||||
if (!line) continue;
|
||||
const line = buffer.lines.get(row + buffer.ydisp);
|
||||
if (!line) return;
|
||||
for (let col = 0; col < cols; ++col) {
|
||||
let e: IExtendedAttrsImage;
|
||||
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
|
||||
e = line._extendedAttrs[col] ?? EMPTY_ATTRS;
|
||||
} else {
|
||||
const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
|
||||
if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) {
|
||||
const e = line.getExtended(col)?.payload;
|
||||
if (e instanceof ImageTileInfo) {
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
}
|
||||
e = maybeImg;
|
||||
}
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
}
|
||||
const imgSpec = this._images.get(imageId);
|
||||
if (e.tileId !== -1) {
|
||||
const startTile = e.tileId;
|
||||
const startCol = col;
|
||||
let count = 1;
|
||||
/**
|
||||
* merge tiles to the right into a single draw call, if:
|
||||
* - not at end of line
|
||||
* - cell has same image id
|
||||
* - cell has consecutive tile id
|
||||
* Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED.
|
||||
*/
|
||||
while (++col < cols) {
|
||||
const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
|
||||
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||
break;
|
||||
const imgSpec = this._images.get(imageId);
|
||||
if (e.tileId !== -1) {
|
||||
const startTile = e.tileId;
|
||||
const startCol = col;
|
||||
let count = 1;
|
||||
/**
|
||||
* merge tiles to the right into a single draw call, if:
|
||||
* - not at end of line
|
||||
* - cell has same image id
|
||||
* - cell has consecutive tile id
|
||||
*/
|
||||
while (++col < cols) {
|
||||
const nextE = line.getExtended(col)?.payload;
|
||||
if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
col--;
|
||||
if (imgSpec) {
|
||||
if (imgSpec.actual) {
|
||||
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
|
||||
col--;
|
||||
if (imgSpec) {
|
||||
if (imgSpec.actual) {
|
||||
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
|
||||
}
|
||||
} else if (this._opts.showPlaceholder) {
|
||||
placeholderCalls.push({ col: startCol, row, count });
|
||||
}
|
||||
} else if (this._opts.showPlaceholder) {
|
||||
placeholderCalls.push({ col: startCol, row, count });
|
||||
this._fullyCleared = false;
|
||||
}
|
||||
this._fullyCleared = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,12 +428,9 @@ export class ImageStorage implements IDisposable {
|
||||
const rows = buffer.lines.length;
|
||||
const oldCol = this._viewportMetrics.cols - 1;
|
||||
for (let row = 0; row < rows; ++row) {
|
||||
const line = buffer.lines.get(row) as IBufferLineExt | undefined;
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;
|
||||
const line = buffer.lines.get(row)!;
|
||||
const e = line.getExtended(oldCol)?.payload;
|
||||
if (e instanceof ImageTileInfo) {
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
@@ -534,8 +446,8 @@ export class ImageStorage implements IDisposable {
|
||||
}
|
||||
// expand only if right side is empty (nothing got wrapped from below)
|
||||
let hasData = false;
|
||||
for (let rightCol = oldCol + 1; rightCol < metrics.cols; ++rightCol) {
|
||||
if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {
|
||||
for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
|
||||
if (line.hasContent(rightCol)) {
|
||||
hasData = true;
|
||||
break;
|
||||
}
|
||||
@@ -547,7 +459,7 @@ export class ImageStorage implements IDisposable {
|
||||
const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);
|
||||
let lastTile = e.tileId;
|
||||
for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {
|
||||
this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);
|
||||
this._writeToCell(line, expandCol, imageId, ++lastTile);
|
||||
imgSpec.tileCount++;
|
||||
}
|
||||
}
|
||||
@@ -561,10 +473,10 @@ export class ImageStorage implements IDisposable {
|
||||
*/
|
||||
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
|
||||
if (e.imageId && e.imageId !== -1) {
|
||||
const line = buffer.lines.get(y);
|
||||
if (line) {
|
||||
const e = line.getExtended(x)?.payload;
|
||||
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1) {
|
||||
const orig = this._images.get(e.imageId)?.orig;
|
||||
if (window.ImageBitmap && orig instanceof ImageBitmap) {
|
||||
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
|
||||
@@ -581,10 +493,10 @@ export class ImageStorage implements IDisposable {
|
||||
*/
|
||||
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
|
||||
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
|
||||
const line = buffer.lines.get(y);
|
||||
if (line) {
|
||||
const e = line.getExtended(x)?.payload;
|
||||
if (e instanceof ImageTileInfo && e.imageId && e.imageId !== -1 && e.tileId !== -1) {
|
||||
const spec = this._images.get(e.imageId);
|
||||
if (spec) {
|
||||
return this._renderer.extractTile(spec, e.tileId);
|
||||
@@ -612,31 +524,31 @@ export class ImageStorage implements IDisposable {
|
||||
return used - current;
|
||||
}
|
||||
|
||||
private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {
|
||||
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
const old = line._extendedAttrs[x];
|
||||
if (old) {
|
||||
if (old.imageId !== undefined) {
|
||||
// found an old ExtendedAttrsImage, since we know that
|
||||
// they are always isolated instances (single cell usage),
|
||||
// we can re-use it and just update their id entries
|
||||
const oldSpec = this._images.get(old.imageId);
|
||||
if (oldSpec) {
|
||||
// early eviction for in-viewport overwrites
|
||||
oldSpec.tileCount--;
|
||||
}
|
||||
old.imageId = imageId;
|
||||
old.tileId = tileId;
|
||||
return;
|
||||
private _writeToCell(line: IBufferLine, x: number, imageId: number, tileId: number): void {
|
||||
const workCell = this._workCell;
|
||||
line.loadCell(x, workCell);
|
||||
if (this._workCell.hasExtendedAttrs()) {
|
||||
const old = workCell.extended.payload;
|
||||
if (old instanceof ImageTileInfo) {
|
||||
// found an old ExtendedAttrsImage, since we know that
|
||||
// they are always isolated instances (single cell usage),
|
||||
// we can re-use it and just update their id entries
|
||||
const oldSpec = this._images.get(old.imageId);
|
||||
if (oldSpec) {
|
||||
// early eviction for in-viewport overwrites
|
||||
oldSpec.tileCount--;
|
||||
}
|
||||
// found a plain ExtendedAttrs instance, clone it to new entry
|
||||
line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);
|
||||
old.imageId = imageId;
|
||||
old.tileId = tileId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// fall-through: always create new ExtendedAttrsImage entry
|
||||
line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;
|
||||
line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);
|
||||
// fall-through: always create new ExtendedAttrs entry
|
||||
const extattr = workCell.extended.clone();
|
||||
extattr.payload = new ImageTileInfo(imageId, tileId);
|
||||
workCell.extended = extattr;
|
||||
workCell.updateExtended();
|
||||
line.setCell(x, workCell);
|
||||
}
|
||||
|
||||
private _evictOnAlternate(): void {
|
||||
@@ -649,18 +561,16 @@ export class ImageStorage implements IDisposable {
|
||||
// re-count tiles on whole buffer
|
||||
const buffer = this._terminal._core.buffer;
|
||||
for (let y = 0; y < this._terminal.rows; ++y) {
|
||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||
const line = buffer.lines.get(y);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
for (let x = 0; x < this._terminal.cols; ++x) {
|
||||
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
const imgId = line._extendedAttrs[x]?.imageId;
|
||||
if (imgId) {
|
||||
const spec = this._images.get(imgId);
|
||||
if (spec) {
|
||||
spec.tileCount++;
|
||||
}
|
||||
const payload = line.getExtended(x)?.payload;
|
||||
if (payload instanceof ImageTileInfo) {
|
||||
const spec = this._images.get(payload.imageId);
|
||||
if (spec) {
|
||||
spec.tileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/b
|
||||
import type { AttributeData } from 'common/buffer/AttributeData';
|
||||
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||
import type { IInputHandler } from 'common/Types';
|
||||
import type { IBufferLine, IExtendedAttrs } from 'common/buffer/Types';
|
||||
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||
@@ -41,6 +40,8 @@ export interface IImageAddonOptions {
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
animationSupport: boolean;
|
||||
animationPixelLimit: number;
|
||||
}
|
||||
|
||||
export interface IResetHandler {
|
||||
@@ -48,6 +49,7 @@ export interface IResetHandler {
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Stub into private interfaces.
|
||||
* This should be kept in line with common libs.
|
||||
@@ -55,19 +57,6 @@ export interface IResetHandler {
|
||||
* have a somewhat reliable testing against code changes in the core repo.
|
||||
*/
|
||||
|
||||
// overloaded IExtendedAttrs to hold image refs
|
||||
export interface IExtendedAttrsImage extends IExtendedAttrs {
|
||||
imageId: number;
|
||||
tileId: number;
|
||||
clone(): IExtendedAttrsImage;
|
||||
}
|
||||
|
||||
/* eslint-disable */
|
||||
export interface IBufferLineExt extends IBufferLine {
|
||||
_extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined};
|
||||
_data: Uint32Array;
|
||||
}
|
||||
|
||||
interface IInputHandlerExt extends IInputHandler {
|
||||
_parser: IEscapeSequenceParser;
|
||||
_curAttrData: AttributeData;
|
||||
@@ -78,6 +67,7 @@ interface IInputHandlerExt extends IInputHandler {
|
||||
};
|
||||
onRequestReset(handler: () => void): IDisposable;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
export interface ICoreTerminalExt extends ITerminal {
|
||||
_themeService: IThemeService | undefined;
|
||||
@@ -89,8 +79,6 @@ export interface ICoreTerminalExt extends ITerminal {
|
||||
export interface ITerminalExt extends Terminal {
|
||||
_core: ICoreTerminalExt;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
|
||||
/**
|
||||
* Some storage definitions.
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface IImageAddonOptions {
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
animationSupport: boolean;
|
||||
animationPixelLimit: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
@@ -138,7 +140,9 @@ test.describe('ImageAddon', () => {
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 33554432,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 33554432
|
||||
kittySizeLimit: 33554432,
|
||||
animationSupport: true,
|
||||
animationPixelLimit: 4194304
|
||||
};
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||
});
|
||||
@@ -155,7 +159,9 @@ test.describe('ImageAddon', () => {
|
||||
iipSupport: false,
|
||||
iipSizeLimit: 1000,
|
||||
kittySupport: false,
|
||||
kittySizeLimit: 1000
|
||||
kittySizeLimit: 1000,
|
||||
animationSupport: false,
|
||||
animationPixelLimit: 1000
|
||||
};
|
||||
await ctx.page.evaluate(opts => {
|
||||
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
||||
@@ -435,6 +441,15 @@ test.describe('ImageAddon', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('text overwrite removes tiles', () => {
|
||||
test('sixel', async () => {
|
||||
await assertTextClearsTiles(SIXEL_SEQ_0);
|
||||
});
|
||||
test('iip', async () => {
|
||||
await assertTextClearsTiles(TESTDATA_IIP[0][0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -472,3 +487,20 @@ async function getOrigSize(id: number): Promise<[number, number]> {
|
||||
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
|
||||
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
|
||||
}
|
||||
|
||||
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
|
||||
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
|
||||
}
|
||||
|
||||
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
|
||||
await ctx.proxy.write('\x1b[H' + imageSeq);
|
||||
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true);
|
||||
ok(await hasTileAtBufferCell(0, 1));
|
||||
await ctx.proxy.write('\x1b[2;6H#######');
|
||||
for (let x = 5; x < 12; x++) {
|
||||
strictEqual(await hasTileAtBufferCell(x, 1), false);
|
||||
}
|
||||
ok(await hasTileAtBufferCell(0, 1));
|
||||
ok(await hasTileAtBufferCell(13, 1));
|
||||
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
|
||||
}
|
||||
|
||||
@@ -2213,6 +2213,21 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
await pollFor(ctx.page, 'window._imageAddedCount', 2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('text overwrite removes tiles', () => {
|
||||
test('a=T placement', async () => {
|
||||
await ctx.proxy.write(`\x1b[H\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
|
||||
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true);
|
||||
ok(await hasTileAtBufferCell(0, 1));
|
||||
await ctx.proxy.write('\x1b[2;6H#######');
|
||||
for (let x = 5; x < 12; x++) {
|
||||
strictEqual(await hasTileAtBufferCell(x, 1), false);
|
||||
}
|
||||
ok(await hasTileAtBufferCell(0, 1));
|
||||
ok(await hasTileAtBufferCell(13, 1));
|
||||
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -2262,3 +2277,7 @@ async function getPixels(col: number, row: number, x: number, y: number, w: numb
|
||||
return Array.from(ctx2d.getImageData(x, y, w, h).data);
|
||||
}, [col, row, x, y, w, h]);
|
||||
}
|
||||
|
||||
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
|
||||
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
|
||||
}
|
||||
|
||||
@@ -142,12 +142,14 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
||||
}
|
||||
prevResult = result;
|
||||
results.push(prevResult);
|
||||
result = this._engine.find(
|
||||
term,
|
||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
|
||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
|
||||
searchOptions
|
||||
);
|
||||
const cols = this._terminal.cols;
|
||||
let nextCol = prevResult.col + prevResult.size;
|
||||
let nextRow = prevResult.row;
|
||||
if (nextCol >= cols) {
|
||||
nextRow += Math.floor(nextCol / cols);
|
||||
nextCol = nextCol % cols;
|
||||
}
|
||||
result = this._engine.find(term, nextRow, nextCol, searchOptions);
|
||||
}
|
||||
|
||||
this._resultTracker.updateResults(results, this._highlightLimit);
|
||||
|
||||
@@ -414,6 +414,20 @@ test.describe('Search Tests', () => {
|
||||
});
|
||||
|
||||
test.describe('Regression tests', () => {
|
||||
test('should advance highlight-all scan by buffer match size for wide characters', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.calls = [];
|
||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||
`);
|
||||
await ctx.proxy.resize(3, 5);
|
||||
await ctx.proxy.write('𝄞𝄞𝄞');
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('𝄞', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 3, resultIndex: 0 }
|
||||
]);
|
||||
await ctx.proxy.resize(80, 24);
|
||||
});
|
||||
|
||||
test.describe('#2444 wrapped line content not being found', () => {
|
||||
let fixture: string;
|
||||
test.beforeAll(async () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -389,8 +409,9 @@ export class GlyphRenderer extends Disposable {
|
||||
gl.bindTexture(gl.TEXTURE_2D, this._atlasTextures[i].texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas.pages[i].canvas);
|
||||
gl.generateMipmap(gl.TEXTURE_2D);
|
||||
this._atlasTextures[i].version = atlas.pages[i].version;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1080,17 +1104,33 @@ class AtlasPage {
|
||||
size: number,
|
||||
sourcePages?: AtlasPage[]
|
||||
) {
|
||||
if (sourcePages) {
|
||||
for (const p of sourcePages) {
|
||||
this._glyphs.push(...p.glyphs);
|
||||
this._usedPixels += p._usedPixels;
|
||||
}
|
||||
}
|
||||
this.canvas = createCanvas(document, size, size);
|
||||
// The canvas needs alpha because we use clearColor to convert the background color to alpha.
|
||||
// It might also contain some characters with transparent backgrounds if allowTransparency is
|
||||
// set.
|
||||
this.ctx = throwIfFalsy(this.canvas.getContext('2d', { alpha: true }));
|
||||
if (sourcePages) {
|
||||
if (sourcePages.length === 4) {
|
||||
// optimized for quadmerge
|
||||
this._glyphs = this._glyphs.concat(
|
||||
sourcePages[0].glyphs,
|
||||
sourcePages[1].glyphs,
|
||||
sourcePages[2].glyphs,
|
||||
sourcePages[3].glyphs
|
||||
);
|
||||
this._usedPixels = sourcePages[0]._usedPixels +
|
||||
sourcePages[1]._usedPixels +
|
||||
sourcePages[2]._usedPixels +
|
||||
sourcePages[3]._usedPixels;
|
||||
} else {
|
||||
// fallback for non quadmerges (should never be used)
|
||||
for (let i = 0; i < sourcePages.length; ++i) {
|
||||
this._glyphs = this._glyphs.concat(sourcePages[i].glyphs);
|
||||
this._usedPixels += sourcePages[i]._usedPixels;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -456,7 +456,18 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
for (y = start; y <= end; y++) {
|
||||
row = y + terminal.buffer.ydisp;
|
||||
line = terminal.buffer.lines.get(row)!;
|
||||
const bufferLine = terminal.buffer.lines.get(row);
|
||||
if (!bufferLine) {
|
||||
this._model.lineLengths[y] = 0;
|
||||
for (x = 0; x < terminal.cols; x++) {
|
||||
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
modelUpdated = true;
|
||||
this._nullModelCell(x, y, j, 0, 0, 0);
|
||||
}
|
||||
this._setRowBlinkState(y, false);
|
||||
continue;
|
||||
}
|
||||
line = bufferLine;
|
||||
let rowHasBlinkingCells = false;
|
||||
this._model.lineLengths[y] = 0;
|
||||
isCursorRow = cursorY === row;
|
||||
@@ -587,13 +598,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
// Null out non-first cells
|
||||
for (x++; x <= lastCharX; x++) {
|
||||
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0);
|
||||
this._model.cells[j] = NULL_CELL_CODE;
|
||||
// Don't re-resolve the cell color since multi-colored ligature backgrounds are not
|
||||
// supported
|
||||
this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
this._nullModelCell(x, y, j, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext);
|
||||
}
|
||||
x--; // Go back to the previous update cell for next iteration
|
||||
}
|
||||
@@ -607,6 +614,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._updateTextBlinkState();
|
||||
}
|
||||
|
||||
private _nullModelCell(x: number, y: number, cellIndex: number, bg: number, fg: number, ext: number): void {
|
||||
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, bg, fg, ext, NULL_CELL_CHAR, 0, 0);
|
||||
this._model.cells[cellIndex] = NULL_CELL_CODE;
|
||||
this._model.cells[cellIndex + RenderModelConstants.BG_OFFSET] = bg;
|
||||
this._model.cells[cellIndex + RenderModelConstants.FG_OFFSET] = fg;
|
||||
this._model.cells[cellIndex + RenderModelConstants.EXT_OFFSET] = ext;
|
||||
}
|
||||
|
||||
private _resetBlinkingRowState(): void {
|
||||
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
|
||||
this._rowHasBlinkingCellsCount = 0;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { createPatternCanvas } from './CustomGlyphRasterizer';
|
||||
|
||||
describe('CustomGlyphRasterizer', () => {
|
||||
describe('createPatternCanvas', () => {
|
||||
it('prefers an offscreen canvas without using the DOM canvas factory', () => {
|
||||
const expectedCanvas = {} as OffscreenCanvas;
|
||||
const calls: string[] = [];
|
||||
|
||||
const canvas = createPatternCanvas(
|
||||
2,
|
||||
3,
|
||||
(width, height) => {
|
||||
calls.push(`offscreen:${width}x${height}`);
|
||||
return expectedCanvas;
|
||||
},
|
||||
() => {
|
||||
calls.push('dom');
|
||||
return {} as HTMLCanvasElement;
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual({ canvas, calls }, {
|
||||
canvas: expectedCanvas,
|
||||
calls: ['offscreen:2x3']
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the main-realm DOM canvas factory when OffscreenCanvas is unavailable', () => {
|
||||
const expectedCanvas = {} as HTMLCanvasElement;
|
||||
const calls: string[] = [];
|
||||
|
||||
const canvas = createPatternCanvas(
|
||||
4,
|
||||
5,
|
||||
undefined,
|
||||
(width, height) => {
|
||||
calls.push(`dom:${width}x${height}`);
|
||||
return expectedCanvas;
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual({ canvas, calls }, {
|
||||
canvas: expectedCanvas,
|
||||
calls: ['dom:4x5']
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,29 @@ import type { ILogService } from 'common/services/Services';
|
||||
import { customGlyphDefinitions } from './CustomGlyphDefinitions';
|
||||
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
|
||||
|
||||
type PatternCanvas = HTMLCanvasElement | OffscreenCanvas;
|
||||
type PatternCanvasFactory = (width: number, height: number) => PatternCanvas;
|
||||
|
||||
const createOffscreenPatternCanvas: PatternCanvasFactory | undefined = typeof OffscreenCanvas === 'undefined'
|
||||
? undefined
|
||||
: (width, height) => new OffscreenCanvas(width, height);
|
||||
|
||||
const createDomPatternCanvas: PatternCanvasFactory = (width, height) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
};
|
||||
|
||||
export function createPatternCanvas(
|
||||
width: number,
|
||||
height: number,
|
||||
offscreenCanvasFactory: PatternCanvasFactory | undefined = createOffscreenPatternCanvas,
|
||||
domCanvasFactory: PatternCanvasFactory = createDomPatternCanvas
|
||||
): PatternCanvas {
|
||||
return offscreenCanvasFactory?.(width, height) ?? domCanvasFactory(width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try drawing a custom block element or box drawing character, returning whether it was
|
||||
* successfully drawn.
|
||||
@@ -458,9 +481,9 @@ function drawPatternChar(
|
||||
if (!pattern) {
|
||||
const width = charDefinition[0].length;
|
||||
const height = charDefinition.length;
|
||||
const tmpCanvas = ctx.canvas.ownerDocument.createElement('canvas');
|
||||
tmpCanvas.width = width;
|
||||
tmpCanvas.height = height;
|
||||
// The atlas canvas can be adopted into another document, so temporary resources must not use
|
||||
// its mutable ownerDocument.
|
||||
const tmpCanvas = createPatternCanvas(width, height);
|
||||
const tmpCtx = throwIfFalsy(tmpCanvas.getContext('2d'));
|
||||
const imageData = new ImageData(width, height);
|
||||
|
||||
|
||||
@@ -0,0 +1,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,77 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { platform } from 'os';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||
|
||||
interface ITestRendererWithAtlasCanvas {
|
||||
readonly _charAtlas?: {
|
||||
readonly _tmpCanvas?: HTMLCanvasElement;
|
||||
};
|
||||
}
|
||||
|
||||
async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
|
||||
const renderPromise = new Promise<void>(resolve => {
|
||||
const disposable = ctx.proxy.onRender(() => {
|
||||
disposable.dispose();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await ctx.proxy.write(data);
|
||||
await renderPromise;
|
||||
}
|
||||
|
||||
test.describe('WebGL custom glyphs', () => {
|
||||
if (platform() === 'linux') {
|
||||
test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit');
|
||||
}
|
||||
|
||||
test('pattern glyphs render after the terminal is adopted into another document', async ({ browser }) => {
|
||||
const ctx = await createTestContext(browser);
|
||||
const errors: string[] = [];
|
||||
const onError = (error: Error): void => { errors.push(error.message); };
|
||||
ctx.page.on('pageerror', onError);
|
||||
try {
|
||||
await openTerminal(ctx);
|
||||
await ctx.page.evaluate(`
|
||||
window.addon = new window.WebglAddon({ customGlyphs: true });
|
||||
window.term.loadAddon(window.addon);
|
||||
`);
|
||||
await ctx.page.evaluate(() => {
|
||||
const frame = document.createElement('iframe');
|
||||
document.body.appendChild(frame);
|
||||
const auxiliaryDocument = frame.contentDocument;
|
||||
if (!auxiliaryDocument?.body || !window.term.element) {
|
||||
throw new Error('Auxiliary document and terminal element must be available');
|
||||
}
|
||||
const renderer = window.term._core?._renderService?._renderer?.value as ITestRendererWithAtlasCanvas | undefined;
|
||||
const atlasCanvas = renderer?._charAtlas?._tmpCanvas;
|
||||
if (!atlasCanvas) {
|
||||
throw new Error('Texture atlas canvas must be available');
|
||||
}
|
||||
// Model the atlas state after an uncached glyph has attached the measurement canvas.
|
||||
window.term.element.appendChild(atlasCanvas);
|
||||
auxiliaryDocument.body.appendChild(window.term.element);
|
||||
if (atlasCanvas.ownerDocument !== auxiliaryDocument) {
|
||||
throw new Error('Texture atlas canvas must be adopted into the auxiliary document');
|
||||
}
|
||||
auxiliaryDocument.createElement = () => {
|
||||
throw new Error('Not allowed to create elements in the auxiliary document');
|
||||
};
|
||||
});
|
||||
|
||||
await writeAndWaitForRender(ctx, '\u2591');
|
||||
await writeAndWaitForRender(ctx, 'X');
|
||||
|
||||
const line = await ctx.page.evaluate(() => window.term.buffer.active.getLine(0)?.translateToString(true));
|
||||
expect(errors, `renderer must not create pattern canvases in the auxiliary document: ${errors[0] ?? ''}`).toEqual([]);
|
||||
expect(line).toBe('\u2591X');
|
||||
} finally {
|
||||
ctx.page.off('pageerror', onError);
|
||||
await ctx.page.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
dlIip.appendChild(dtIip);
|
||||
this._addDdWithButton(dlIip, 'image-demo3', 'palette (png File)');
|
||||
this._addDdWithButton(dlIip, 'image-demo4', 'dice (qoi MultipartFile)');
|
||||
this._addDdWithButton(dlIip, 'image-demo5', 'rose (webp File)');
|
||||
this._addDdWithButton(dlIip, 'image-demo6', 'kimono (avif MultipartFile)');
|
||||
container.appendChild(dlIip);
|
||||
|
||||
// Kitty demos
|
||||
@@ -173,11 +175,15 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
document.getElementById('image-demo2')!.addEventListener('click',
|
||||
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
|
||||
document.getElementById('image-demo3')!.addEventListener('click',
|
||||
iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
||||
iipDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/palette.png'));
|
||||
document.getElementById('image-demo4')!.addEventListener('click',
|
||||
iipDemoMulti('https://raw.githubusercontent.com/jerch/xterm-wasm-parts/master/fixtures/qoi/dice.qoi'));
|
||||
iipDemoMulti('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/dice.qoi'));
|
||||
document.getElementById('image-demo5')!.addEventListener('click',
|
||||
iipDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/1_webp_a.webp'));
|
||||
document.getElementById('image-demo6')!.addEventListener('click',
|
||||
iipDemoMulti('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/testimages/kimono.crop.avif'));
|
||||
document.getElementById('image-demo-kitty1')!.addEventListener('click',
|
||||
kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
||||
kittyDemo('https://raw.githubusercontent.com/xtermjs/xterm.js/master/addons/addon-image/fixture/palette.png'));
|
||||
|
||||
// demo for image retrieval API
|
||||
this._terminal.element!.addEventListener('click', (ev: MouseEvent) => {
|
||||
|
||||
@@ -11,8 +11,35 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
|
||||
public readonly label = 'webgl';
|
||||
|
||||
private _textureAtlasContainer!: HTMLElement;
|
||||
private _stressRunning = false;
|
||||
private _scrollOutput: HTMLInputElement | undefined;
|
||||
|
||||
public build(container: HTMLElement): void {
|
||||
const stressButtons = document.createElement('div');
|
||||
const stressStart = document.createElement('button');
|
||||
stressStart.id = 'stress-start';
|
||||
stressStart.textContent = 'atlas stress start';
|
||||
stressStart.addEventListener('click', () => this._stress());
|
||||
stressButtons.appendChild(stressStart);
|
||||
|
||||
const stressStop = document.createElement('button');
|
||||
stressStop.id = 'stress-stop';
|
||||
stressStop.textContent = 'atlas stress stop';
|
||||
stressStop.addEventListener('click', () => this._stressRunning = false);
|
||||
stressButtons.appendChild(stressStop);
|
||||
container.appendChild(stressButtons);
|
||||
|
||||
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';
|
||||
zoomCheckbox.id = 'texture-atlas-zoom';
|
||||
@@ -48,4 +75,33 @@ export class WebglWindow extends BaseWindow implements IControlWindow {
|
||||
canvas.style.width = `${canvas.width / dpr}px`;
|
||||
canvas.style.height = `${canvas.height / dpr}px`;
|
||||
}
|
||||
|
||||
private async _stress(): Promise<void> {
|
||||
const TEXT = '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~';
|
||||
if (this._stressRunning) {
|
||||
return;
|
||||
}
|
||||
this._stressRunning = true;
|
||||
const scrollOutput = !!(this._scrollOutput?.checked);
|
||||
for (let r = 0; r < 256; r += 2) {
|
||||
for (let g = 0; g < 256; g += 2) {
|
||||
let s: string[] = [];
|
||||
for (let b = 0; b < 256; b += 2) {
|
||||
if (!this._stressRunning) {
|
||||
return;
|
||||
}
|
||||
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 (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));
|
||||
}
|
||||
s = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,9 +663,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -680,9 +680,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -697,9 +697,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -714,9 +714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -731,9 +731,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -748,9 +748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -765,9 +765,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -782,9 +782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -799,9 +799,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -816,9 +816,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -833,9 +833,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -850,9 +850,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -867,9 +867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -884,9 +884,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -901,9 +901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -918,9 +918,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -935,9 +935,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -952,9 +952,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -969,9 +969,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -986,9 +986,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1003,9 +1003,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1020,9 +1020,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1037,9 +1037,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1054,9 +1054,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1071,9 +1071,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1088,9 +1088,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -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"
|
||||
@@ -3869,9 +3869,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -3882,32 +3882,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
@@ -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');
|
||||
@@ -1072,10 +1073,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
* Clear the entire buffer, making the prompt line the new first line.
|
||||
*/
|
||||
public clear(): void {
|
||||
if (this.buffer.ybase === 0 && this.buffer.y === 0) {
|
||||
// Don't clear if it's already clear
|
||||
return;
|
||||
}
|
||||
this.buffer.clearAllMarkers();
|
||||
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
|
||||
this.buffer.lines.length = 1;
|
||||
|
||||
@@ -536,9 +536,14 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
for (let y = start; y <= end; y++) {
|
||||
const row = y + buffer.ydisp;
|
||||
const rowElement = this._rowElements[y];
|
||||
if (!rowElement) {
|
||||
continue;
|
||||
}
|
||||
const lineData = buffer.lines.get(row);
|
||||
if (!rowElement || !lineData) {
|
||||
break;
|
||||
if (!lineData) {
|
||||
rowElement.replaceChildren();
|
||||
this._setRowBlinkState(y, false);
|
||||
continue;
|
||||
}
|
||||
rowElement.replaceChildren(
|
||||
...this._rowFactory.createRow(
|
||||
@@ -610,9 +615,14 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
for (let i = y; i <= y2; ++i) {
|
||||
const row = i + buffer.ydisp;
|
||||
const rowElement = this._rowElements[i];
|
||||
if (!rowElement) {
|
||||
continue;
|
||||
}
|
||||
const bufferline = buffer.lines.get(row);
|
||||
if (!rowElement || !bufferline) {
|
||||
break;
|
||||
if (!bufferline) {
|
||||
rowElement.replaceChildren();
|
||||
this._setRowBlinkState(i, false);
|
||||
continue;
|
||||
}
|
||||
rowElement.replaceChildren(
|
||||
...this._rowFactory.createRow(
|
||||
|
||||
@@ -6,16 +6,14 @@
|
||||
import jsdom = require('jsdom');
|
||||
import { assert } from 'chai';
|
||||
import { DomRendererRowFactory } from './DomRendererRowFactory';
|
||||
import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants';
|
||||
import { DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from '../../../common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from '../../../common/buffer/BufferLineStringCache';
|
||||
import { IBufferLine } from '../../../common/buffer/Types';
|
||||
import { CellData } from '../../../common/buffer/CellData';
|
||||
import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from '../../../common/TestUtils.test';
|
||||
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from '../../TestUtils.test';
|
||||
import { TestWidthCache } from './WidthCache.test';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
describe('DomRendererRowFactory', () => {
|
||||
let dom: jsdom.JSDOM;
|
||||
@@ -517,7 +515,7 @@ describe('DomRendererRowFactory', () => {
|
||||
}
|
||||
|
||||
function createEmptyLineData(cols: number): IBufferLine {
|
||||
const lineData = new BufferLine(TEST_STRING_CACHE, cols);
|
||||
const lineData = new BufferLine(cols);
|
||||
for (let i = 0; i < cols; i++) {
|
||||
lineData.setCell(i, NULL_CELL_DATA);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,13 @@ describe('SelectionModel', () => {
|
||||
assert.deepEqual(model.finalSelectionStart, undefined);
|
||||
assert.deepEqual(model.finalSelectionEnd, undefined);
|
||||
});
|
||||
it('should reset selection start to origin when start row is trimmed', () => {
|
||||
model.selectionStart = [50, 0];
|
||||
model.selectionEnd = [10, 2];
|
||||
assert.equal(model.handleTrim(1), true);
|
||||
assert.deepEqual(model.finalSelectionStart, [0, 0]);
|
||||
assert.deepEqual(model.finalSelectionEnd, [10, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalSelectionStart', () => {
|
||||
|
||||
@@ -135,9 +135,10 @@ export class SelectionModel {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the selection start is trimmed, ensure the start column is 0.
|
||||
// If the selection start row is trimmed away, reset to the buffer origin.
|
||||
if (this.selectionStart && this.selectionStart[1] < 0) {
|
||||
this.selectionStart[1] = 0;
|
||||
this.selectionStart = [0, 0];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ import { assert } from 'chai';
|
||||
import { ICharacterJoinerService } from './Services';
|
||||
import { CharacterJoinerService } from './CharacterJoinerService';
|
||||
import { BufferLine } from '../../common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache';
|
||||
import { IBufferLine } from '../../common/buffer/Types';
|
||||
import { CellData } from '../../common/buffer/CellData';
|
||||
import { MockBufferService, createCellData } from '../../common/TestUtils.test';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
describe('CharacterJoinerService', () => {
|
||||
let service: ICharacterJoinerService;
|
||||
@@ -25,7 +23,7 @@ describe('CharacterJoinerService', () => {
|
||||
lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]]));
|
||||
|
||||
lines.set(3, lineData([['no joined ranges']]));
|
||||
lines.set(4, new BufferLine(TEST_STRING_CACHE, 0));
|
||||
lines.set(4, new BufferLine(0));
|
||||
lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
|
||||
const line6 = lineData([['wi']]);
|
||||
line6.resize(line6.length + 1, createCellData(0, '¥', 2));
|
||||
@@ -270,7 +268,7 @@ describe('CharacterJoinerService', () => {
|
||||
type IPartialLineData = ([string] | [string, number]);
|
||||
|
||||
function lineData(data: IPartialLineData[]): IBufferLine {
|
||||
const tline = new BufferLine(TEST_STRING_CACHE, 0);
|
||||
const tline = new BufferLine(0);
|
||||
for (let i = 0; i < data.length; ++i) {
|
||||
const line = data[i][0];
|
||||
const attr = (data[i][1] || 0) as number;
|
||||
|
||||
@@ -100,6 +100,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
|
||||
const ranges: [number, number][] = [];
|
||||
const lineStr = line.translateToString(true);
|
||||
const trimmedLength = line.getTrimmedLength();
|
||||
|
||||
// Because some cells can be represented by multiple javascript characters,
|
||||
// we track the cell and the string indexes separately. This allows us to
|
||||
@@ -111,7 +112,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
let rangeAttrFG = line.getFg(0);
|
||||
let rangeAttrBG = line.getBg(0);
|
||||
|
||||
for (let x = 0; x < line.getTrimmedLength(); x++) {
|
||||
for (let x = 0; x < trimmedLength; x++) {
|
||||
line.loadCell(x, this._workCell);
|
||||
|
||||
if (this._workCell.getWidth() === 0) {
|
||||
@@ -147,7 +148,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
}
|
||||
|
||||
// Process any trailing ranges.
|
||||
if (this._bufferService.cols - rangeStartColumn > 1) {
|
||||
if (trimmedLength - rangeStartColumn > 1) {
|
||||
const joinedRanges = this._getJoinedRanges(
|
||||
lineStr,
|
||||
rangeStartStringIndex,
|
||||
@@ -216,7 +217,8 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let x = startCol; x < this._bufferService.cols; x++) {
|
||||
const trimmedLength = line.getTrimmedLength();
|
||||
for (let x = startCol; x < trimmedLength; x++) {
|
||||
const width = line.getWidth(x);
|
||||
const length = line.getString(x).length || WHITESPACE_CELL_CHAR.length;
|
||||
|
||||
@@ -264,7 +266,7 @@ export class CharacterJoinerService implements ICharacterJoinerService {
|
||||
// If there is still a range left at the end, it must extend all the way to
|
||||
// the end of the line.
|
||||
if (currentRange) {
|
||||
currentRange[1] = this._bufferService.cols;
|
||||
currentRange[1] = trimmedLength;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,13 +9,11 @@ import { SelectionModel } from '../selection/SelectionModel';
|
||||
import { IBuffer, IBufferLine } from '../../common/buffer/Types';
|
||||
import { MockBufferService, MockOptionsService, MockCoreService, MockMouseStateService, createCellData } from '../../common/TestUtils.test';
|
||||
import { BufferLine } from '../../common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache';
|
||||
import { IBufferService, IOptionsService } from '../../common/services/Services';
|
||||
import { MockCoreBrowserService, MockMouseService, MockRenderService } from '../TestUtils.test';
|
||||
import { CellData } from '../../common/buffer/CellData';
|
||||
import { IRenderService } from './Services';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
class TestSelectionService extends SelectionService {
|
||||
constructor(
|
||||
@@ -60,7 +58,7 @@ describe('SelectionService', () => {
|
||||
});
|
||||
|
||||
function stringToRow(text: string): IBufferLine {
|
||||
const result = new BufferLine(TEST_STRING_CACHE, text.length);
|
||||
const result = new BufferLine(text.length);
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
result.setCell(i, createCellData(0, text.charAt(i), 1));
|
||||
}
|
||||
@@ -68,7 +66,7 @@ describe('SelectionService', () => {
|
||||
}
|
||||
|
||||
function stringArrayToRow(chars: string[]): IBufferLine {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, chars.length);
|
||||
const line = new BufferLine(chars.length);
|
||||
chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1)));
|
||||
return line;
|
||||
}
|
||||
@@ -123,7 +121,7 @@ describe('SelectionService', () => {
|
||||
[0, 'o', 1, 'o'.charCodeAt(0)],
|
||||
[0, 'o', 1, 'o'.charCodeAt(0)]
|
||||
];
|
||||
const line = new BufferLine(TEST_STRING_CACHE, data.length);
|
||||
const line = new BufferLine(data.length);
|
||||
for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i]));
|
||||
buffer.lines.set(0, line);
|
||||
// Ensure wide characters take up 2 columns
|
||||
|
||||
@@ -704,7 +704,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
if (this._activeSelectionMode !== SelectionMode.COLUMN) {
|
||||
this._model.selectionEnd[0] = this._bufferService.cols;
|
||||
}
|
||||
this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows, buffer.lines.length - 1);
|
||||
this._model.selectionEnd[1] = Math.min(buffer.ydisp + this._bufferService.rows - 1, buffer.lines.length - 1);
|
||||
} else {
|
||||
if (this._activeSelectionMode !== SelectionMode.COLUMN) {
|
||||
this._model.selectionEnd[0] = 0;
|
||||
|
||||
@@ -27,11 +27,13 @@ export class Emitter<T> {
|
||||
}
|
||||
|
||||
const entry = { fn: listener, thisArgs };
|
||||
this._listeners = this._listeners.slice();
|
||||
this._listeners.push(entry);
|
||||
|
||||
const result = toDisposable(() => {
|
||||
const idx = this._listeners.indexOf(entry);
|
||||
if (idx !== -1) {
|
||||
this._listeners = this._listeners.slice();
|
||||
this._listeners.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
@@ -50,23 +52,16 @@ export class Emitter<T> {
|
||||
}
|
||||
|
||||
public fire(event: T): void {
|
||||
if (this._disposed) {
|
||||
if (this._disposed || !this._listeners.length) {
|
||||
return;
|
||||
}
|
||||
switch (this._listeners.length) {
|
||||
case 0: return;
|
||||
case 1: {
|
||||
const { fn, thisArgs } = this._listeners[0];
|
||||
fn.call(thisArgs, event);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
// Snapshot listeners to allow modifications during iteration (2+ listeners)
|
||||
const listeners = this._listeners.slice();
|
||||
for (const { fn, thisArgs } of listeners) {
|
||||
fn.call(thisArgs, event);
|
||||
}
|
||||
}
|
||||
if (this._listeners.length === 1) {
|
||||
this._listeners[0].fn.call(this._listeners[0].thisArgs, event);
|
||||
return;
|
||||
}
|
||||
const listeners = this._listeners;
|
||||
for (let i = 0, len = listeners.length; i < len; ++i) {
|
||||
listeners[i].fn.call(listeners[i].thisArgs, event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ export class AttributeData implements IAttributeData {
|
||||
*/
|
||||
export class ExtendedAttrs implements IExtendedAttrs {
|
||||
private _ext: number = 0;
|
||||
public payload: Object | undefined;
|
||||
public get ext(): number {
|
||||
if (this._urlId) {
|
||||
return (
|
||||
@@ -207,6 +208,6 @@ export class ExtendedAttrs implements IExtendedAttrs {
|
||||
* that needs to be persistant in the buffer.
|
||||
*/
|
||||
public isEmpty(): boolean {
|
||||
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0;
|
||||
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.payload === undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,24 +8,14 @@ import { Buffer } from './Buffer';
|
||||
import { CircularList } from '../CircularList';
|
||||
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from '../TestUtils.test';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine';
|
||||
import { BufferLineStringCache } from './BufferLineStringCache';
|
||||
import { CellData } from './CellData';
|
||||
import { ExtendedAttrs } from './AttributeData';
|
||||
|
||||
|
||||
const INIT_COLS = 80;
|
||||
const INIT_ROWS = 24;
|
||||
const INIT_SCROLLBACK = 1000;
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
class TestBuffer extends Buffer {
|
||||
public getStringCache(): BufferLineStringCache {
|
||||
return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache;
|
||||
}
|
||||
|
||||
public getStringCacheClearTimeout(): unknown {
|
||||
return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value;
|
||||
}
|
||||
}
|
||||
class TestBuffer extends Buffer {}
|
||||
|
||||
describe('Buffer', () => {
|
||||
let optionsService: MockOptionsService;
|
||||
@@ -1202,7 +1192,7 @@ describe('Buffer', () => {
|
||||
|
||||
describe ('translateBufferLineToString', () => {
|
||||
it('should handle selecting a section of ascii text', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 4);
|
||||
const line = new BufferLine(4);
|
||||
line.setCell(0, createCellData(0, 'a', 1));
|
||||
line.setCell(1, createCellData(0, 'b', 1));
|
||||
line.setCell(2, createCellData(0, 'c', 1));
|
||||
@@ -1214,7 +1204,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle a cut-off double width character by including it', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
const line = new BufferLine(3);
|
||||
line.setCell(0, createCellData(0, '語', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
line.setCell(2, createCellData(0, 'a', 1));
|
||||
@@ -1225,7 +1215,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle a zero width character in the middle of the string by not including it', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
const line = new BufferLine(3);
|
||||
line.setCell(0, createCellData(0, '語', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
line.setCell(2, createCellData(0, 'a', 1));
|
||||
@@ -1242,7 +1232,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle single width emojis', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 2);
|
||||
const line = new BufferLine(2);
|
||||
line.setCell(0, createCellData(0, '😁', 1));
|
||||
line.setCell(1, createCellData(0, 'a', 1));
|
||||
buffer.lines.set(0, line);
|
||||
@@ -1255,7 +1245,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle double width emojis', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 2);
|
||||
const line = new BufferLine(2);
|
||||
line.setCell(0, createCellData(0, '😁', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
buffer.lines.set(0, line);
|
||||
@@ -1266,7 +1256,7 @@ describe('Buffer', () => {
|
||||
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
|
||||
assert.equal(str2, '😁');
|
||||
|
||||
const line2 = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
const line2 = new BufferLine(3);
|
||||
line2.setCell(0, createCellData(0, '😁', 2));
|
||||
line2.setCell(1, createCellData(0, '', 0));
|
||||
line2.setCell(2, createCellData(0, 'a', 1));
|
||||
@@ -1277,99 +1267,6 @@ describe('Buffer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('line string cache cleanup', () => {
|
||||
it('should clear shared cache entries with a single timer', () => {
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
const originalDateNow = Date.now;
|
||||
let timeoutId = 0;
|
||||
let now = 0;
|
||||
const clearedTimeouts: number[] = [];
|
||||
const scheduledTimeouts = new Map<number, { delay: number, fire: () => void }>();
|
||||
(globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => {
|
||||
const id = ++timeoutId;
|
||||
scheduledTimeouts.set(id, {
|
||||
delay: timeout ?? 0,
|
||||
fire: () => {
|
||||
scheduledTimeouts.delete(id);
|
||||
handler();
|
||||
}
|
||||
});
|
||||
return id as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout;
|
||||
(globalThis as any).clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
|
||||
const numericId = id as unknown as number;
|
||||
clearedTimeouts.push(numericId);
|
||||
scheduledTimeouts.delete(numericId);
|
||||
}) as typeof clearTimeout;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
|
||||
buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1));
|
||||
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`);
|
||||
|
||||
const cache = buffer.getStringCache();
|
||||
assert.equal(cache.entries.size, 2);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.equal([...scheduledTimeouts.values()][0].delay, 15000);
|
||||
const initialTimerCreationCount = timeoutId;
|
||||
|
||||
now = 5000;
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(timeoutId, initialTimerCreationCount);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.deepEqual(clearedTimeouts, []);
|
||||
|
||||
now = 15000;
|
||||
[...scheduledTimeouts.values()][0].fire();
|
||||
assert.equal(timeoutId, initialTimerCreationCount + 1);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.equal([...scheduledTimeouts.values()][0].delay, 5000);
|
||||
|
||||
now = 20000;
|
||||
[...scheduledTimeouts.values()][0].fire();
|
||||
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(cache.entries.size, 1);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
it('should reset line string cache state on clear and resize', () => {
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
|
||||
buffer.translateBufferLineToString(0, false);
|
||||
|
||||
const cache = buffer.getStringCache();
|
||||
assert.equal(cache.entries.size, 1);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
|
||||
buffer.clear();
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
|
||||
buffer.translateBufferLineToString(0, false);
|
||||
assert.equal(cache.entries.size, 1);
|
||||
|
||||
buffer.resize(INIT_COLS - 1, INIT_ROWS);
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory cleanup after shrinking', () => {
|
||||
it('should realign memory from idle task execution', async () => {
|
||||
buffer.fillViewportRows();
|
||||
|
||||
@@ -10,7 +10,6 @@ import { ICharset } from '../Types';
|
||||
import { IAttributeData, IBuffer, IBufferLine, ICellData } from './Types';
|
||||
import { ExtendedAttrs } from './AttributeData';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine';
|
||||
import { BufferLineStringCache } from './BufferLineStringCache';
|
||||
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow';
|
||||
import { CellData } from './CellData';
|
||||
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from './Constants';
|
||||
@@ -52,7 +51,6 @@ export class Buffer extends Disposable implements IBuffer {
|
||||
private _isClearing: boolean = false;
|
||||
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
|
||||
private _memoryCleanupPosition = 0;
|
||||
private readonly _stringCache: BufferLineStringCache;
|
||||
|
||||
constructor(
|
||||
private _hasScrollback: boolean,
|
||||
@@ -70,7 +68,6 @@ export class Buffer extends Disposable implements IBuffer {
|
||||
this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
|
||||
this._register(toDisposable(() => this._memoryCleanupQueue.clear()));
|
||||
this._register(toDisposable(() => this.clearAllMarkers()));
|
||||
this._stringCache = this._register(new BufferLineStringCache());
|
||||
}
|
||||
|
||||
public getNullCell(attr?: IAttributeData): ICellData {
|
||||
@@ -100,7 +97,7 @@ export class Buffer extends Disposable implements IBuffer {
|
||||
}
|
||||
|
||||
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
|
||||
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped);
|
||||
return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped);
|
||||
}
|
||||
|
||||
public get hasScrollback(): boolean {
|
||||
@@ -145,7 +142,6 @@ export class Buffer extends Disposable implements IBuffer {
|
||||
* Clears the buffer to its initial state, discarding all previous data.
|
||||
*/
|
||||
public clear(): void {
|
||||
this._stringCache.clear();
|
||||
this.ydisp = 0;
|
||||
this.ybase = 0;
|
||||
this.y = 0;
|
||||
@@ -164,7 +160,6 @@ export class Buffer extends Disposable implements IBuffer {
|
||||
public resize(newCols: number, newRows: number): void {
|
||||
// store reference to null cell with default attrs
|
||||
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
|
||||
this._stringCache.clear();
|
||||
|
||||
// count bufferlines with overly big memory to be cleaned afterwards
|
||||
let dirtyMemoryLines = 0;
|
||||
@@ -199,7 +194,7 @@ export class Buffer extends Disposable implements IBuffer {
|
||||
if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) {
|
||||
// Just add the new missing rows on Windows as conpty reprints the screen with its
|
||||
// view of the world. Once a line enters scrollback for conpty it remains there
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
this.lines.push(new BufferLine(newCols, nullCell, false));
|
||||
} else {
|
||||
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) {
|
||||
// There is room above the buffer and there are no empty elements below the line,
|
||||
@@ -213,7 +208,7 @@ export class Buffer extends Disposable implements IBuffer {
|
||||
} else {
|
||||
// Add a blank line if there is no buffer left at the top to scroll to, or if there
|
||||
// are blank lines after the cursor
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
this.lines.push(new BufferLine(newCols, nullCell, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,7 +349,7 @@ export class Buffer extends Disposable implements IBuffer {
|
||||
}
|
||||
if (this.lines.length < newRows) {
|
||||
// Add an extra row at the bottom of the viewport
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
this.lines.push(new BufferLine(newCols, nullCell, false));
|
||||
}
|
||||
} else {
|
||||
if (this.ydisp === this.ybase) {
|
||||
|
||||
@@ -4,19 +4,16 @@
|
||||
*/
|
||||
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from './Constants';
|
||||
import { BufferLine } from './BufferLine';
|
||||
import { BufferLineStringCache } from './BufferLineStringCache';
|
||||
import { CellData } from './CellData';
|
||||
import { CharData, IBufferLine, ICellData } from './Types';
|
||||
import { assert } from 'chai';
|
||||
import { AttributeData } from './AttributeData';
|
||||
import { createCellData, NULL_CELL_DATA, extendedAttributes } from '../TestUtils.test';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
|
||||
class TestBufferLine extends BufferLine {
|
||||
constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) {
|
||||
super(TEST_STRING_CACHE, cols, fillCellData, isWrapped);
|
||||
super(cols, fillCellData, isWrapped);
|
||||
}
|
||||
|
||||
public get combined(): {[index: number]: string} {
|
||||
@@ -24,19 +21,19 @@ class TestBufferLine extends BufferLine {
|
||||
}
|
||||
|
||||
public get cachedString(): string | undefined {
|
||||
return this._getStringCacheEntry(false)?.value;
|
||||
return this._cacheValid ? this._cache : undefined;
|
||||
}
|
||||
|
||||
public set cachedString(value: string | undefined) {
|
||||
this._getStringCacheEntry(true)!.value = value;
|
||||
this._cache = value ?? '';
|
||||
}
|
||||
|
||||
public get isCachedStringTrimmed(): boolean {
|
||||
return this._getStringCacheEntry(false)?.isTrimmed ?? false;
|
||||
return this._cacheTrimmed;
|
||||
}
|
||||
|
||||
public set isCachedStringTrimmed(value: boolean) {
|
||||
this._getStringCacheEntry(true)!.isTrimmed = value;
|
||||
this._cacheTrimmed = value;
|
||||
}
|
||||
|
||||
public toArray(): CharData[] {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AttributeData } from './AttributeData';
|
||||
import { CellData } from './CellData';
|
||||
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from './Constants';
|
||||
import { stringFromCodePoint } from '../input/TextDecoder';
|
||||
import { StringBuilder } from '../StringBuilder';
|
||||
|
||||
// Buffer memory layout:
|
||||
//
|
||||
@@ -37,24 +36,20 @@ const enum Cell {
|
||||
BG = 2 // currently unused
|
||||
}
|
||||
|
||||
|
||||
interface IExtendedAttrsExt extends IExtendedAttrs {
|
||||
_ext: number;
|
||||
_urlId: number;
|
||||
}
|
||||
|
||||
|
||||
export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
let $startIndex = 0;
|
||||
const $workCell = new CellData();
|
||||
const $translateToStringBuilder = new StringBuilder();
|
||||
const $extended = DEFAULT_ATTR_DATA.extended.clone() as IExtendedAttrsExt;
|
||||
|
||||
export interface IBufferLineStringCacheEntry {
|
||||
value: string | undefined;
|
||||
isTrimmed: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface IBufferLineStringCache {
|
||||
generation: number;
|
||||
allocateEntry(): IBufferLineStringCacheEntry;
|
||||
touch?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed array based bufferline implementation.
|
||||
@@ -77,11 +72,14 @@ export class BufferLine implements IBufferLine {
|
||||
protected _combined: {[index: number]: string} = {};
|
||||
/** Sparse cache; only read when `HAS_EXTENDED` is set in `_data`. */
|
||||
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
|
||||
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
|
||||
public length: number;
|
||||
|
||||
/** line text cache */
|
||||
protected _cacheValid = false;
|
||||
protected _cache: string = '';
|
||||
protected _cacheTrimmed = false;
|
||||
|
||||
constructor(
|
||||
protected readonly _stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData?: ICellData,
|
||||
public isWrapped: boolean = false
|
||||
@@ -118,7 +116,7 @@ export class BufferLine implements IBufferLine {
|
||||
* @deprecated
|
||||
*/
|
||||
public set(index: number, value: CharData): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
|
||||
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
|
||||
this._combined[index] = value[1];
|
||||
@@ -210,21 +208,30 @@ export class BufferLine implements IBufferLine {
|
||||
} else {
|
||||
cell.combinedData = '';
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
cell.extended = this._extendedAttrs[index]!;
|
||||
} else {
|
||||
// Do not mutate cell.extended in place: it may still reference this line's map entry from a
|
||||
// prior loadCell into a reused CellData (e.g. $workCell during insert/delete).
|
||||
cell.extended = DEFAULT_ATTR_DATA.extended.clone();
|
||||
}
|
||||
cell.extended = this.getExtended(index);
|
||||
return cell;
|
||||
}
|
||||
|
||||
public getExtended(index: number): IExtendedAttrs {
|
||||
$startIndex = index * Constants.CELL_INDICIES;
|
||||
if (this._data[$startIndex + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
return this._extendedAttrs[index]!;
|
||||
}
|
||||
// Do not mutate cell.extended in place: it may still reference this line's map entry from a
|
||||
// prior loadCell into a reused CellData (e.g. $workCell during insert/delete).
|
||||
// We use $extended as blueprint and reset the internals
|
||||
// mimicking the ctor to avoid a new allocation.
|
||||
$extended._ext = 0;
|
||||
$extended._urlId = 0;
|
||||
$extended.payload = undefined;
|
||||
return $extended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data at `index` to `cell`.
|
||||
*/
|
||||
public setCell(index: number, cell: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
this._combined[index] = cell.combinedData;
|
||||
}
|
||||
@@ -242,13 +249,14 @@ export class BufferLine implements IBufferLine {
|
||||
* it gets an optimized access method.
|
||||
*/
|
||||
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
if (attrs.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[index] = attrs.extended;
|
||||
}
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg;
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg;
|
||||
const $idx = index * Constants.CELL_INDICIES;
|
||||
this._data[$idx + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
|
||||
this._data[$idx + Cell.FG] = attrs.fg;
|
||||
this._data[$idx + Cell.BG] = attrs.bg;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,7 +266,7 @@ export class BufferLine implements IBufferLine {
|
||||
* by the previous `setDataFromCodePoint` call, we can omit it here.
|
||||
*/
|
||||
public addCodepointToCell(index: number, codePoint: number, width: number): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
// we already have a combined string, simply add
|
||||
@@ -285,7 +293,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
pos %= this.length;
|
||||
|
||||
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char
|
||||
@@ -313,7 +321,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
pos %= this.length;
|
||||
if (n < this.length - pos) {
|
||||
for (let i = 0; i < this.length - pos - n; ++i) {
|
||||
@@ -340,7 +348,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
|
||||
if (respectProtect) {
|
||||
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
|
||||
@@ -380,7 +388,7 @@ export class BufferLine implements IBufferLine {
|
||||
* excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD).
|
||||
*/
|
||||
public resize(cols: number, fillCellData: ICellData): boolean {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
if (cols === this.length) {
|
||||
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
@@ -440,7 +448,7 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** fill a line with fillCharData */
|
||||
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
|
||||
if (respectProtect) {
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
@@ -458,8 +466,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
/** alter to a full copy of line */
|
||||
public copyFrom(line: BufferLine): void {
|
||||
this._invalidateStringCache();
|
||||
public copyFrom(line: BufferLine, blank?: boolean): void {
|
||||
if (this.length !== line.length) {
|
||||
this._data = new Uint32Array(line._data);
|
||||
} else {
|
||||
@@ -467,16 +474,29 @@ export class BufferLine implements IBufferLine {
|
||||
this._data.set(line._data);
|
||||
}
|
||||
this.length = line.length;
|
||||
this._copySparseMapsFrom(line);
|
||||
if (blank) {
|
||||
// a blank line may never hold combined or extended attrs,
|
||||
// thus we can skip handling them
|
||||
this._combined = {};
|
||||
this._extendedAttrs = {};
|
||||
} else {
|
||||
this._copySparseMapsFrom(line);
|
||||
}
|
||||
this._cache = '';
|
||||
this._cacheValid = false;
|
||||
this.isWrapped = line.isWrapped;
|
||||
}
|
||||
|
||||
/** create a new clone */
|
||||
public clone(): IBufferLine {
|
||||
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
|
||||
public clone(blank?: boolean): IBufferLine {
|
||||
const newLine = new BufferLine(0, undefined, false);
|
||||
newLine._data = new Uint32Array(this._data);
|
||||
newLine.length = this.length;
|
||||
newLine._copySparseMapsFrom(this);
|
||||
if (!blank) {
|
||||
// a blank line may never hold combined or extended attrs,
|
||||
// thus we can skip handling them
|
||||
newLine._copySparseMapsFrom(this);
|
||||
}
|
||||
newLine.isWrapped = this.isWrapped;
|
||||
return newLine;
|
||||
}
|
||||
@@ -500,7 +520,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
|
||||
this._invalidateStringCache();
|
||||
this._cacheValid = false;
|
||||
const srcData = src._data;
|
||||
if (applyInReverse) {
|
||||
for (let cell = length - 1; cell >= 0; cell--) {
|
||||
@@ -534,17 +554,13 @@ export class BufferLine implements IBufferLine {
|
||||
* returned string, the corresponding entries in `outColumns` will have the same column number.
|
||||
*/
|
||||
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
|
||||
const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
|
||||
if (isCanonicalRequest) {
|
||||
this._stringCache.touch?.();
|
||||
}
|
||||
const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined;
|
||||
if (isCanonicalRequest && stringCacheEntry?.value !== undefined) {
|
||||
const isCanonical = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
|
||||
if (isCanonical && this._cacheValid) {
|
||||
if (trimRight) {
|
||||
return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd();
|
||||
return this._cacheTrimmed ? this._cache : this._cache.trimEnd();
|
||||
}
|
||||
if (!stringCacheEntry.isTrimmed) {
|
||||
return stringCacheEntry.value;
|
||||
if (!this._cacheTrimmed) {
|
||||
return this._cache;
|
||||
}
|
||||
}
|
||||
startCol = startCol ?? 0;
|
||||
@@ -555,12 +571,12 @@ export class BufferLine implements IBufferLine {
|
||||
if (outColumns) {
|
||||
outColumns.length = 0;
|
||||
}
|
||||
$translateToStringBuilder.reset();
|
||||
const cellContents: string[] = [];
|
||||
while (startCol < endCol) {
|
||||
const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT];
|
||||
const cp = content & Content.CODEPOINT_MASK;
|
||||
const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
|
||||
$translateToStringBuilder.append(chars);
|
||||
cellContents.push(chars);
|
||||
if (outColumns) {
|
||||
for (let i = 0; i < chars.length; ++i) {
|
||||
outColumns.push(startCol);
|
||||
@@ -571,39 +587,15 @@ export class BufferLine implements IBufferLine {
|
||||
if (outColumns) {
|
||||
outColumns.push(startCol);
|
||||
}
|
||||
const result = $translateToStringBuilder.toString();
|
||||
$translateToStringBuilder.reset();
|
||||
if (isCanonicalRequest) {
|
||||
const cacheEntry = this._getStringCacheEntry(true)!;
|
||||
cacheEntry.value = result;
|
||||
cacheEntry.isTrimmed = !!trimRight;
|
||||
const result = cellContents.join('');
|
||||
if (isCanonical) {
|
||||
this._cache = result;
|
||||
this._cacheValid = true;
|
||||
this._cacheTrimmed = !!trimRight;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined {
|
||||
const cachedEntry = this._stringCacheEntryRef?.deref();
|
||||
if (cachedEntry) {
|
||||
if (cachedEntry.generation === this._stringCache.generation) {
|
||||
return cachedEntry;
|
||||
}
|
||||
}
|
||||
if (!createIfNeeded) {
|
||||
return undefined;
|
||||
}
|
||||
const cacheEntry = this._stringCache.allocateEntry();
|
||||
this._stringCacheEntryRef = new WeakRef(cacheEntry);
|
||||
return cacheEntry;
|
||||
}
|
||||
|
||||
private _invalidateStringCache(): void {
|
||||
const cacheEntry = this._getStringCacheEntry(false);
|
||||
if (cacheEntry) {
|
||||
cacheEntry.value = undefined;
|
||||
cacheEntry.isTrimmed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy sparse map entries for a single cell when `_data` flags require them. */
|
||||
private _copyCellMapsFrom(src: BufferLine, srcCol: number, destCol: number): void {
|
||||
const srcStart = srcCol * Constants.CELL_INDICIES;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from './BufferLine';
|
||||
import { disposableTimeout } from '../Async';
|
||||
import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../Lifecycle';
|
||||
|
||||
const enum Constants {
|
||||
CACHE_TTL_MS = 15000
|
||||
}
|
||||
|
||||
export class BufferLineStringCache extends Disposable implements IBufferLineStringCache {
|
||||
public generation: number = 0;
|
||||
public readonly entries: Set<IBufferLineStringCacheEntry> = new Set();
|
||||
private readonly _clearTimeout = this._register(new MutableDisposable<IDisposable>());
|
||||
private _lastAccessTimestamp: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._register(toDisposable(() => this.entries.clear()));
|
||||
}
|
||||
|
||||
public touch(): void {
|
||||
this._scheduleClear();
|
||||
}
|
||||
|
||||
public allocateEntry(): IBufferLineStringCacheEntry {
|
||||
const entry: IBufferLineStringCacheEntry = {
|
||||
value: undefined,
|
||||
isTrimmed: false,
|
||||
generation: this.generation
|
||||
};
|
||||
this.entries.add(entry);
|
||||
this._scheduleClear();
|
||||
return entry;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._clearTimeout.clear();
|
||||
this._lastAccessTimestamp = 0;
|
||||
this.generation++;
|
||||
for (const entry of this.entries) {
|
||||
entry.value = undefined;
|
||||
entry.isTrimmed = false;
|
||||
}
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
private _scheduleClear(): void {
|
||||
this._lastAccessTimestamp = Date.now();
|
||||
if (this._clearTimeout.value) {
|
||||
return;
|
||||
}
|
||||
this._scheduleClearTimeout(Constants.CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
private _scheduleClearTimeout(timeoutMs: number): void {
|
||||
this._clearTimeout.value = disposableTimeout(() => {
|
||||
const elapsed = Date.now() - this._lastAccessTimestamp;
|
||||
if (elapsed >= Constants.CACHE_TTL_MS) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed);
|
||||
}, timeoutMs);
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,16 @@
|
||||
import { assert } from 'chai';
|
||||
import { CircularList } from '../CircularList';
|
||||
import { BufferLine } from './BufferLine';
|
||||
import { BufferLineStringCache } from './BufferLineStringCache';
|
||||
import { CellData } from './CellData';
|
||||
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Constants';
|
||||
import { reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow';
|
||||
import { IBufferLine } from './Types';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
describe('BufferReflow', () => {
|
||||
describe('reflowSmallerGetNewLineLengths', () => {
|
||||
it('should return correct line lengths for a small line with wide characters', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 4);
|
||||
const line = new BufferLine(4);
|
||||
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line.set(1, [0, '', 0, 0]);
|
||||
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
|
||||
@@ -26,7 +24,7 @@ describe('BufferReflow', () => {
|
||||
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语');
|
||||
});
|
||||
it('should return correct line lengths for a large line with wide characters', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 12);
|
||||
const line = new BufferLine(12);
|
||||
for (let i = 0; i < 12; i += 4) {
|
||||
line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]);
|
||||
@@ -48,7 +46,7 @@ describe('BufferReflow', () => {
|
||||
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语');
|
||||
});
|
||||
it('should return correct line lengths for a string with wide and single characters', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 6);
|
||||
const line = new BufferLine(6);
|
||||
line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line.set(2, [0, '', 0, 0]);
|
||||
@@ -62,14 +60,14 @@ describe('BufferReflow', () => {
|
||||
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b');
|
||||
});
|
||||
it('should return correct line lengths for a wrapped line with wide and single characters', () => {
|
||||
const line1 = new BufferLine(TEST_STRING_CACHE, 6);
|
||||
const line1 = new BufferLine(6);
|
||||
line1.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line1.set(2, [0, '', 0, 0]);
|
||||
line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]);
|
||||
line1.set(4, [0, '', 0, 0]);
|
||||
line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]);
|
||||
const line2 = new BufferLine(TEST_STRING_CACHE, 6, undefined, true);
|
||||
const line2 = new BufferLine(6, undefined, true);
|
||||
line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line2.set(2, [0, '', 0, 0]);
|
||||
@@ -84,7 +82,7 @@ describe('BufferReflow', () => {
|
||||
assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b');
|
||||
});
|
||||
it('should work on lines ending in null space', () => {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 5);
|
||||
const line = new BufferLine(5);
|
||||
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
|
||||
line.set(1, [0, '', 0, 0]);
|
||||
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
|
||||
@@ -102,7 +100,7 @@ describe('BufferReflow', () => {
|
||||
function createWrappedLines(chars: string): CircularList<IBufferLine> {
|
||||
const lines = new CircularList<IBufferLine>(chars.length);
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 1);
|
||||
const line = new BufferLine(1);
|
||||
line.set(0, [0, chars[i], 1, chars.charCodeAt(i)]);
|
||||
line.isWrapped = i > 0;
|
||||
lines.push(line);
|
||||
|
||||
@@ -82,45 +82,4 @@ describe('BufferSet', () => {
|
||||
assert.equal(bufferSet.alt.markers.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('should dispose previous buffers on reset', () => {
|
||||
const oldNormal = bufferSet.normal as any;
|
||||
oldNormal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
|
||||
oldNormal.translateBufferLineToString(0, false);
|
||||
|
||||
const oldCache = oldNormal._stringCache;
|
||||
assert.equal(oldCache.entries.size, 1);
|
||||
assert.notEqual(oldCache._clearTimeout.value, undefined);
|
||||
|
||||
bufferSet.reset();
|
||||
|
||||
assert.notEqual(bufferSet.normal, oldNormal);
|
||||
assert.equal(oldCache.entries.size, 0);
|
||||
assert.equal(oldCache._clearTimeout.value, undefined);
|
||||
});
|
||||
|
||||
it('should dispose both buffers when disposed', () => {
|
||||
const normal = bufferSet.normal as any;
|
||||
normal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
|
||||
normal.translateBufferLineToString(0, false);
|
||||
|
||||
bufferSet.activateAltBuffer();
|
||||
const alt = bufferSet.alt as any;
|
||||
alt.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
|
||||
alt.translateBufferLineToString(0, false);
|
||||
|
||||
const normalCache = normal._stringCache;
|
||||
const altCache = alt._stringCache;
|
||||
assert.notEqual(normalCache._clearTimeout.value, undefined);
|
||||
assert.notEqual(altCache._clearTimeout.value, undefined);
|
||||
|
||||
bufferSet.dispose();
|
||||
|
||||
assert.equal(normalCache.entries.size, 0);
|
||||
assert.equal(altCache.entries.size, 0);
|
||||
assert.equal(normalCache._clearTimeout.value, undefined);
|
||||
assert.equal(altCache._clearTimeout.value, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface IExtendedAttrs {
|
||||
underlineColor: number;
|
||||
underlineVariantOffset: number;
|
||||
urlId: number;
|
||||
payload: Object | undefined;
|
||||
clone(): IExtendedAttrs;
|
||||
isEmpty(): boolean;
|
||||
}
|
||||
@@ -131,8 +132,8 @@ export interface IBufferLine {
|
||||
resize(cols: number, fill: ICellData): boolean;
|
||||
cleanupMemory(): number;
|
||||
fill(fillCellData: ICellData, respectProtect?: boolean): void;
|
||||
copyFrom(line: IBufferLine): void;
|
||||
clone(): IBufferLine;
|
||||
copyFrom(line: IBufferLine, blank?: boolean): void;
|
||||
clone(blank?: boolean): IBufferLine;
|
||||
getTrimmedLength(): number;
|
||||
getNoBgTrimmedLength(): number;
|
||||
translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string;
|
||||
@@ -146,6 +147,7 @@ export interface IBufferLine {
|
||||
getCodePoint(index: number): number;
|
||||
isCombined(index: number): number;
|
||||
getString(index: number): string;
|
||||
getExtended(index: number): IExtendedAttrs;
|
||||
}
|
||||
|
||||
export interface IMarker extends IDisposable {
|
||||
|
||||
@@ -79,7 +79,7 @@ function assertDecodedRange(
|
||||
}
|
||||
const target = new Uint32Array(count);
|
||||
const length = decode(input, target);
|
||||
assert.equal(length, count);
|
||||
assert.strictEqual(length, count);
|
||||
let mismatchIndex = -1;
|
||||
let index = 0;
|
||||
for (let i = min; i < max; ++i) {
|
||||
@@ -92,8 +92,8 @@ function assertDecodedRange(
|
||||
}
|
||||
index++;
|
||||
}
|
||||
assert.equal(mismatchIndex, -1);
|
||||
assert.equal(outputToString(target, length), input);
|
||||
assert.strictEqual(mismatchIndex, -1);
|
||||
assert.strictEqual(outputToString(target, length), input);
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 8192;
|
||||
@@ -116,9 +116,9 @@ describe('text encodings', () => {
|
||||
const data = new Uint32Array(s.length);
|
||||
for (let i = 0; i < s.length; ++i) {
|
||||
data[i] = s.charCodeAt(i);
|
||||
assert.equal(stringFromCodePoint(data[i]), s[i]);
|
||||
assert.strictEqual(stringFromCodePoint(data[i]), s[i]);
|
||||
}
|
||||
assert.equal(utf32ToString(data), s);
|
||||
assert.strictEqual(utf32ToString(data), s);
|
||||
});
|
||||
|
||||
describe('StringToUtf32 decoder', () => {
|
||||
@@ -159,7 +159,7 @@ describe('text encodings', () => {
|
||||
const decoder = new StringToUtf32();
|
||||
const target = new Uint32Array(5);
|
||||
const length = decoder.decode(String.fromCharCode(0xFEFF), target);
|
||||
assert.equal(length, 0);
|
||||
assert.strictEqual(length, 0);
|
||||
decoder.clear();
|
||||
});
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe('text encodings', () => {
|
||||
const target = new Uint32Array(500);
|
||||
for (let i = 0; i < TEST_STRINGS.length; ++i) {
|
||||
const length = decoder.decode(TEST_STRINGS[i], target);
|
||||
assert.equal(toString(target, length), TEST_STRINGS[i]);
|
||||
assert.strictEqual(toString(target, length), TEST_STRINGS[i]);
|
||||
decoder.clear();
|
||||
}
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(input[i], target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -226,7 +226,7 @@ describe('text encodings', () => {
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF));
|
||||
const length = decoder.decode(utf8Data, target);
|
||||
assert.equal(length, 0);
|
||||
assert.strictEqual(length, 0);
|
||||
decoder.clear();
|
||||
});
|
||||
});
|
||||
@@ -237,7 +237,7 @@ describe('text encodings', () => {
|
||||
for (let i = 0; i < TEST_STRINGS.length; ++i) {
|
||||
const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]);
|
||||
const length = decoder.decode(utf8Data, target);
|
||||
assert.equal(toString(target, length), TEST_STRINGS[i]);
|
||||
assert.strictEqual(toString(target, length), TEST_STRINGS[i]);
|
||||
decoder.clear();
|
||||
}
|
||||
});
|
||||
@@ -252,7 +252,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert(decoded, 'ÄÖÜßöäü');
|
||||
assert.strictEqual(decoded, 'ÄÖÜßöäü');
|
||||
});
|
||||
|
||||
it('2/3 byte sequences - advance by 1', () => {
|
||||
@@ -264,7 +264,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert(decoded, 'Āր܀߀ö€ä€ü');
|
||||
assert.strictEqual(decoded, 'Āր܀߀ö€ä€ü');
|
||||
});
|
||||
|
||||
it('2/3/4 byte sequences - advance by 1', () => {
|
||||
@@ -276,7 +276,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 1), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
});
|
||||
|
||||
it('2/3/4 byte sequences - advance by 2', () => {
|
||||
@@ -288,7 +288,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
});
|
||||
|
||||
it('2/3/4 byte sequences - advance by 3', () => {
|
||||
@@ -300,7 +300,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 3), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
assert.strictEqual(decoded, 'Ä€𝄞Ö𝄞€Ü𝄞€');
|
||||
});
|
||||
|
||||
it('BOMs (3 byte sequences) - advance by 2', () => {
|
||||
@@ -312,7 +312,7 @@ describe('text encodings', () => {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.equal(decoded, '');
|
||||
assert.strictEqual(decoded, '');
|
||||
});
|
||||
|
||||
it('test break after 3 bytes - issue #2495', () => {
|
||||
@@ -320,11 +320,37 @@ describe('text encodings', () => {
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = fromByteString('\xf0\xa0\x9c\x8e');
|
||||
let written = decoder.decode(utf8Data.slice(0, 3), target);
|
||||
assert.equal(written, 0);
|
||||
assert.strictEqual(written, 0);
|
||||
written = decoder.decode(utf8Data.slice(3), target);
|
||||
assert.equal(written, 1);
|
||||
assert(toString(target, written), '𠜎');
|
||||
assert.strictEqual(written, 1);
|
||||
assert.strictEqual(toString(target, written), '𠜎');
|
||||
});
|
||||
|
||||
describe('0x80 not swallowed in continuation', () => {
|
||||
it('A—B', () => {
|
||||
const decoder = new Utf8ToUtf32();
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = new TextEncoder().encode('A—BA—BA—BA—BA—B');
|
||||
let decoded = '';
|
||||
for (let i = 0; i < utf8Data.length; i += 2) {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'A—BA—BA—BA—BA—B');
|
||||
});
|
||||
it('A𐀀B', () => {
|
||||
const decoder = new Utf8ToUtf32();
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = new TextEncoder().encode('A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B');
|
||||
let decoded = '';
|
||||
for (let i = 0; i < utf8Data.length; i += 2) {
|
||||
const written = decoder.decode(utf8Data.slice(i, i + 2), target);
|
||||
decoded += toString(target, written);
|
||||
}
|
||||
assert.strictEqual(decoded, 'A𐀀BA𐀀BA𐀀BA𐀀BA𐀀B');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,9 +158,9 @@ export class Utf8ToUtf32 {
|
||||
cp &= ((((cp & 0xE0) === 0xC0)) ? 0x1F : (((cp & 0xF0) === 0xE0)) ? 0x0F : 0x07);
|
||||
let pos = 0;
|
||||
let tmp: number;
|
||||
while ((tmp = this.interim[++pos] & 0x3F) && pos < 4) {
|
||||
while ((tmp = this.interim[++pos]) && pos < 4) {
|
||||
cp <<= 6;
|
||||
cp |= tmp;
|
||||
cp |= tmp & 0x3F;
|
||||
}
|
||||
// missing bytes - read ahead from input
|
||||
const type = (((this.interim[0] & 0xE0) === 0xC0)) ? 2 : (((this.interim[0] & 0xF0) === 0xE0)) ? 3 : 4;
|
||||
|
||||
@@ -86,12 +86,12 @@ export class BufferService extends Disposable implements IBufferService {
|
||||
// Insert the line using the fastest method
|
||||
if (bottomRow === buffer.lines.length - 1) {
|
||||
if (willBufferBeTrimmed) {
|
||||
buffer.lines.recycle().copyFrom(newLine);
|
||||
buffer.lines.recycle().copyFrom(newLine, true);
|
||||
} else {
|
||||
buffer.lines.push(newLine.clone());
|
||||
buffer.lines.push(newLine.clone(true));
|
||||
}
|
||||
} else {
|
||||
buffer.lines.splice(bottomRow + 1, 0, newLine.clone());
|
||||
buffer.lines.splice(bottomRow + 1, 0, newLine.clone(true));
|
||||
}
|
||||
|
||||
// Only adjust ybase and ydisp when the buffer is not trimmed
|
||||
@@ -113,7 +113,7 @@ export class BufferService extends Disposable implements IBufferService {
|
||||
// scrollback, instead we can just shift them in-place.
|
||||
const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */;
|
||||
buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1);
|
||||
buffer.lines.set(bottomRow, newLine.clone());
|
||||
buffer.lines.set(bottomRow, newLine.clone(true));
|
||||
}
|
||||
|
||||
// Move the viewport to the bottom of the buffer unless the user is
|
||||
|
||||
@@ -214,7 +214,7 @@ export class DecorationLineCache extends Disposable {
|
||||
}
|
||||
|
||||
private _handleBufferLinesTrim(amount: number): void {
|
||||
if (amount <= 0) {
|
||||
if (amount <= 0 || !this._decorationsByLine.size) {
|
||||
return;
|
||||
}
|
||||
const newMap = new Map<number, IInternalDecoration[]>();
|
||||
|
||||
@@ -99,10 +99,6 @@ export class Terminal extends CoreTerminal {
|
||||
* Clear the entire buffer, making the prompt line the new first line.
|
||||
*/
|
||||
public clear(): void {
|
||||
if (this.buffer.ybase === 0 && this.buffer.y === 0) {
|
||||
// Don't clear if it's already clear
|
||||
return;
|
||||
}
|
||||
this.buffer.clearAllMarkers();
|
||||
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
|
||||
this.buffer.lines.length = 1;
|
||||
|
||||