Compare commits
457
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fab7bc3c5a | ||
|
|
f9b043274d | ||
|
|
8481bfd8dd | ||
|
|
32fdce592b | ||
|
|
3113eaefaa | ||
|
|
a56dc02cc7 | ||
|
|
e3399b1db8 | ||
|
|
8aab310366 | ||
|
|
9f232d981b | ||
|
|
1efd80cd17 | ||
|
|
1701dcef90 | ||
|
|
55dbe3b65e | ||
|
|
53a98a720a | ||
|
|
b69ae4b3c9 | ||
|
|
3cdbc77386 | ||
|
|
f57b8e1534 | ||
|
|
c8f11d63b5 | ||
|
|
3d929a1db0 | ||
|
|
2fe3fd13a1 | ||
|
|
f33f502267 | ||
|
|
7c24c58007 | ||
|
|
596634ac41 | ||
|
|
0c8271db49 | ||
|
|
f3f4dcaf9f | ||
|
|
64718444e2 | ||
|
|
ea75b99e2b | ||
|
|
564113e053 | ||
|
|
b3de9fa6db | ||
|
|
1f054097f3 | ||
|
|
a250987508 | ||
|
|
21c99d0574 | ||
|
|
0b6fd2e8b2 | ||
|
|
4226e64245 | ||
|
|
a374c6c5af | ||
|
|
454e0749c2 | ||
|
|
59264f1f5b | ||
|
|
16ef64f8bf | ||
|
|
a0f93cdcc4 | ||
|
|
5b911db9cf | ||
|
|
560c0ce3a1 | ||
|
|
94958c2489 | ||
|
|
6426573f45 | ||
|
|
5ab59506aa | ||
|
|
096707d736 | ||
|
|
967d3f59e6 | ||
|
|
9a70816dad | ||
|
|
8e7e4f16e5 | ||
|
|
a9a2a8e3c1 | ||
|
|
92a1bfcf2e | ||
|
|
be38252e37 | ||
|
|
0b590dacd6 | ||
|
|
1faa41eac1 | ||
|
|
fa5eb33cdb | ||
|
|
3494f6b5b0 | ||
|
|
58fc09ff0e | ||
|
|
a47e4905e6 | ||
|
|
1252c69ccc | ||
|
|
be3dd80171 | ||
|
|
2db34c4955 | ||
|
|
8854430df4 | ||
|
|
26e771d075 | ||
|
|
fd13893687 | ||
|
|
5ef8001413 | ||
|
|
e2d1420ff7 | ||
|
|
cf1484d7fd | ||
|
|
e12f007597 | ||
|
|
3a4f9e235d | ||
|
|
7b05c8a1b2 | ||
|
|
b45167e00b | ||
|
|
bc42be2074 | ||
|
|
bc26cc16c1 | ||
|
|
80468eeb4c | ||
|
|
69b11a1a48 | ||
|
|
98d7c25bd5 | ||
|
|
6d1f12cb7b | ||
|
|
4500485e79 | ||
|
|
b4bd92d62c | ||
|
|
1a01e9634f | ||
|
|
e35a9c1e90 | ||
|
|
1dd113b38a | ||
|
|
536eeb4db0 | ||
|
|
3f3f6abfb9 | ||
|
|
c961408bf7 | ||
|
|
6fd6003d6c | ||
|
|
d407bf561f | ||
|
|
a1a8d1abae | ||
|
|
f34d0a255c | ||
|
|
a2cd1ab29c | ||
|
|
99550840d2 | ||
|
|
b0f6aed4a1 | ||
|
|
878e3fc52d | ||
|
|
f04679a42e | ||
|
|
6ebcf79358 | ||
|
|
d4acf2fa45 | ||
|
|
ddf2b7d091 | ||
|
|
5684f95c8e | ||
|
|
cd3dcc52b2 | ||
|
|
936442c813 | ||
|
|
dfb65ca66f | ||
|
|
35c91cbf2d | ||
|
|
587856e2ca | ||
|
|
56a7195487 | ||
|
|
4aa04c6b3f | ||
|
|
28eadddb79 | ||
|
|
59d1ec7bf0 | ||
|
|
7c82c1a815 | ||
|
|
04a0b7347c | ||
|
|
5d8da19c44 | ||
|
|
17ad33dc3d | ||
|
|
f76c7aa8ea | ||
|
|
43e8365f0e | ||
|
|
c7a94fc0bc | ||
|
|
4bb9c06b5f | ||
|
|
00e54532b3 | ||
|
|
e96133603b | ||
|
|
b2003861ec | ||
|
|
38f77fd4ca | ||
|
|
d3352cf1d5 | ||
|
|
0c4ec34df8 | ||
|
|
743852b60c | ||
|
|
2069176528 | ||
|
|
acd2472961 | ||
|
|
c2ecfe7446 | ||
|
|
5656443163 | ||
|
|
38174907f3 | ||
|
|
1b95efa8c8 | ||
|
|
91fcb46a1a | ||
|
|
ce1f5f9608 | ||
|
|
1d569f873d | ||
|
|
bf8af4a871 | ||
|
|
f8e9a81d0e | ||
|
|
7711d780c2 | ||
|
|
e89fbf2979 | ||
|
|
db244ea5a8 | ||
|
|
fdcd1c2720 | ||
|
|
f8cef535c7 | ||
|
|
57b48f6048 | ||
|
|
5ad56446b8 | ||
|
|
ec0c93fd5a | ||
|
|
be339bf796 | ||
|
|
fbb2a9710f | ||
|
|
7294124736 | ||
|
|
2c3fad39af | ||
|
|
edb4f89dd8 | ||
|
|
224c113b64 | ||
|
|
3e0c7d18e5 | ||
|
|
95d9224b8c | ||
|
|
7499a2181d | ||
|
|
7dc820f5fa | ||
|
|
e59a6c1f7b | ||
|
|
f3e2c08a5d | ||
|
|
b138bf38d0 | ||
|
|
c6e4627b45 | ||
|
|
d91045dc5f | ||
|
|
41dfdba2cf | ||
|
|
ba33decb68 | ||
|
|
17e3b84bcc | ||
|
|
182ff2f635 | ||
|
|
bcb4e5298b | ||
|
|
42facac2d9 | ||
|
|
2ab0f10a26 | ||
|
|
280dc0a36a | ||
|
|
ebe8cb749a | ||
|
|
c28f3c8d73 | ||
|
|
3bd7ac083a | ||
|
|
30b42838fd | ||
|
|
80e832e417 | ||
|
|
470947abd8 | ||
|
|
bb49a80ace | ||
|
|
65b87e0f40 | ||
|
|
39fc0f259e | ||
|
|
b787ac8b1b | ||
|
|
457efbf55d | ||
|
|
42270a0b68 | ||
|
|
090b77f354 | ||
|
|
534ce1365b | ||
|
|
f757c965ee | ||
|
|
4aa02f3fd9 | ||
|
|
f43273b3f0 | ||
|
|
c06c3025f2 | ||
|
|
1064e4f783 | ||
|
|
fad4821fd9 | ||
|
|
306c3d73ec | ||
|
|
8f2bb32c9c | ||
|
|
7b6c814566 | ||
|
|
adc9dea083 | ||
|
|
d565a2750e | ||
|
|
c5ab83d697 | ||
|
|
c62e6dd765 | ||
|
|
29a07f1d8f | ||
|
|
88cef97049 | ||
|
|
0e823004e6 | ||
|
|
df3422fb0b | ||
|
|
4ec85e67ba | ||
|
|
1a67b6f4b3 | ||
|
|
afaed108e4 | ||
|
|
c27d6bb462 | ||
|
|
9c4f0f96ca | ||
|
|
18c370a154 | ||
|
|
850f4ad738 | ||
|
|
b665f6259a | ||
|
|
d195dc9a3b | ||
|
|
4e5994d8a2 | ||
|
|
4a0cb5ae6b | ||
|
|
9e72a4b6d3 | ||
|
|
f484584b70 | ||
|
|
5a6427e867 | ||
|
|
8519ac6d99 | ||
|
|
ae611694de | ||
|
|
e749cb6125 | ||
|
|
8df996f9e7 | ||
|
|
51f6186352 | ||
|
|
ae5c41c518 | ||
|
|
17e7dafa17 | ||
|
|
df9fb7e473 | ||
|
|
41d9c4080f | ||
|
|
f100e93ba5 | ||
|
|
1d3825d341 | ||
|
|
57315e6cc9 | ||
|
|
2be4fc68e5 | ||
|
|
66fabfc232 | ||
|
|
3e2a42eb29 | ||
|
|
6a47d09b4c | ||
|
|
b36001ee7b | ||
|
|
e64f25a0dd | ||
|
|
3bcb57545e | ||
|
|
dc726a2ae6 | ||
|
|
06e5ac7ab4 | ||
|
|
c6e3492a4a | ||
|
|
86291b6e96 | ||
|
|
c8c8b3a7fe | ||
|
|
50e90ddf8a | ||
|
|
772df0cb98 | ||
|
|
aa93189cc5 | ||
|
|
7059a07c09 | ||
|
|
c5c6fc2a64 | ||
|
|
e0add7fe4b | ||
|
|
b0f5bc2d28 | ||
|
|
f82a8029d2 | ||
|
|
a2f321c887 | ||
|
|
eb58cefa8e | ||
|
|
d92ca87cbe | ||
|
|
c5a11220c1 | ||
|
|
6f55150485 | ||
|
|
c8dddddbbb | ||
|
|
224167bbbe | ||
|
|
740fa44ae4 | ||
|
|
636baa8d79 | ||
|
|
44a27dc958 | ||
|
|
dc1ae4514b | ||
|
|
6ba731d73b | ||
|
|
2d3909be2c | ||
|
|
1e189625fa | ||
|
|
80debafa11 | ||
|
|
1023e40428 | ||
|
|
5c47e6dd9a | ||
|
|
4ebb3fffe9 | ||
|
|
093e81b9a7 | ||
|
|
e04dd2501f | ||
|
|
d911b40a47 | ||
|
|
3dbfa7ce3e | ||
|
|
6287c6ddfe | ||
|
|
52d4b2719c | ||
|
|
7615cd210a | ||
|
|
4d4b994c35 | ||
|
|
7fa8b7a069 | ||
|
|
a2e51e8d36 | ||
|
|
f89253d24b | ||
|
|
7aeee95346 | ||
|
|
66fcdb2583 | ||
|
|
2a18600d18 | ||
|
|
5ef4f86543 | ||
|
|
874e767b40 | ||
|
|
605a7c6e30 | ||
|
|
802decb046 | ||
|
|
79a95dbfd8 | ||
|
|
75ba88c51b | ||
|
|
f9cea732f2 | ||
|
|
494cf1f2e1 | ||
|
|
645865efec | ||
|
|
83e3657a82 | ||
|
|
53554a05f7 | ||
|
|
51c8d8abf8 | ||
|
|
05ea5ac324 | ||
|
|
9e27c7debb | ||
|
|
4dc934ce7c | ||
|
|
b2eff5ac67 | ||
|
|
31c53bc9c2 | ||
|
|
0d2975884f | ||
|
|
e8b5fd3c23 | ||
|
|
7abcca7e73 | ||
|
|
92339ac9dd | ||
|
|
ad4a6e8cb9 | ||
|
|
a094340afe | ||
|
|
cc7e3bbbf7 | ||
|
|
73d87b2c20 | ||
|
|
c5bf395b10 | ||
|
|
9d24099aa1 | ||
|
|
066c227436 | ||
|
|
32ad3a18bf | ||
|
|
d43b19c260 | ||
|
|
7dd0dac859 | ||
|
|
7e70d6ad78 | ||
|
|
893cdd494a | ||
|
|
2794e32b3f | ||
|
|
7d69e730ca | ||
|
|
da51d9608c | ||
|
|
3a84bcb7ae | ||
|
|
d9082215e7 | ||
|
|
41938b307e | ||
|
|
785c3cb336 | ||
|
|
ea7e958959 | ||
|
|
915b0b81a1 | ||
|
|
4e9abe7d96 | ||
|
|
84aca0b912 | ||
|
|
d82d42ef9a | ||
|
|
4d2f2e0d8e | ||
|
|
56986cb7eb | ||
|
|
4946436997 | ||
|
|
bf83ea06e4 | ||
|
|
b7e535e2ec | ||
|
|
8e12e69e4e | ||
|
|
849a9a53ca | ||
|
|
ee7d84bdd3 | ||
|
|
9c8de633e9 | ||
|
|
9c5420371b | ||
|
|
22778b5b49 | ||
|
|
cbe59a17ae | ||
|
|
76671cecb6 | ||
|
|
a11fcd2e0b | ||
|
|
b28b5d0c2b | ||
|
|
685434ca84 | ||
|
|
7fe89a1c1a | ||
|
|
eaebe2ab15 | ||
|
|
32553b4135 | ||
|
|
6c2465d745 | ||
|
|
5922123b9e | ||
|
|
eb65910c1b | ||
|
|
f0aeb70c92 | ||
|
|
9deec7aabf | ||
|
|
18cd9858ed | ||
|
|
6fd7ecc812 | ||
|
|
ff72c00219 | ||
|
|
0911688295 | ||
|
|
62ce318a07 | ||
|
|
08ae141ef7 | ||
|
|
ba7e775a17 | ||
|
|
487b27f512 | ||
|
|
5d120de3d5 | ||
|
|
3ab299af7f | ||
|
|
6e375cb588 | ||
|
|
7a6e724bfe | ||
|
|
69ee4ad23b | ||
|
|
253d1e9beb | ||
|
|
4b77e601cd | ||
|
|
34e0179355 | ||
|
|
bd722e7b07 | ||
|
|
cccdd447a0 | ||
|
|
446bcb1f75 | ||
|
|
4316263457 | ||
|
|
4a35a4880d | ||
|
|
ad730f2d54 | ||
|
|
9c7c163dd4 | ||
|
|
8674bc5e82 | ||
|
|
d552be8c45 | ||
|
|
082a51127f | ||
|
|
89100f6a3d | ||
|
|
8149d4481f | ||
|
|
2d9b21f5c7 | ||
|
|
f43985c422 | ||
|
|
9f6c7d7ced | ||
|
|
aa7716a333 | ||
|
|
1432e20082 | ||
|
|
f1b0a96094 | ||
|
|
49096a21fd | ||
|
|
63035678ec | ||
|
|
503abee8ae | ||
|
|
4350536685 | ||
|
|
9feb963cc9 | ||
|
|
f4042876c6 | ||
|
|
c294695444 | ||
|
|
fe0952bb83 | ||
|
|
5c2b19f2c8 | ||
|
|
5e9b2cbc14 | ||
|
|
3a59c8ec2b | ||
|
|
fb25eb8f79 | ||
|
|
e7a4578933 | ||
|
|
a05a3321d5 | ||
|
|
26ce9bd4d3 | ||
|
|
1c1aac0da8 | ||
|
|
beeebca1a4 | ||
|
|
fd1e53c63f | ||
|
|
18262c87d9 | ||
|
|
2285ac6ab8 | ||
|
|
5302e62c90 | ||
|
|
af6731b533 | ||
|
|
5b9b531012 | ||
|
|
b0118dfd3a | ||
|
|
6813fc175e | ||
|
|
409f049f39 | ||
|
|
9e1b89651a | ||
|
|
89980fc565 | ||
|
|
16b5da5c4c | ||
|
|
4812abb24f | ||
|
|
5f3a4646be | ||
|
|
d63445be94 | ||
|
|
1400398abb | ||
|
|
2331abe6bd | ||
|
|
b66128b239 | ||
|
|
c727240f16 | ||
|
|
649ce22081 | ||
|
|
73adfdfd56 | ||
|
|
1fc8a4687d | ||
|
|
c29e48c917 | ||
|
|
70fe22993b | ||
|
|
4f3a7331bd | ||
|
|
6f3ce7b0fe | ||
|
|
fdb7346f3a | ||
|
|
783c4a34d2 | ||
|
|
8a1760b924 | ||
|
|
211db9d067 | ||
|
|
ec6ad20e33 | ||
|
|
47235f47fe | ||
|
|
8d6ff3a27b | ||
|
|
55281022a0 | ||
|
|
ea50fbdd1e | ||
|
|
87bf81cf96 | ||
|
|
f155c65e5a | ||
|
|
413c87cbf9 | ||
|
|
1620f8f5a8 | ||
|
|
e769f80a14 | ||
|
|
6651c4ca23 | ||
|
|
cb78a53f57 | ||
|
|
1c7b9f6d79 | ||
|
|
0d535100e4 | ||
|
|
5e19624e71 | ||
|
|
671425bf5a | ||
|
|
df5393ec45 | ||
|
|
b2553c17b4 | ||
|
|
0cea95d3b3 | ||
|
|
163868ff60 | ||
|
|
f4166a87ba | ||
|
|
f7a353cd34 | ||
|
|
7ec41471da | ||
|
|
f5e81afba7 | ||
|
|
a12061c899 | ||
|
|
d6c872bb2c | ||
|
|
a2cb36e6dd | ||
|
|
114c66c86c | ||
|
|
fe9650ce16 | ||
|
|
dedf3b1595 | ||
|
|
2158827b25 | ||
|
|
b406797dc7 | ||
|
|
6e35b44f02 | ||
|
|
375be97615 | ||
|
|
c66b0ed168 | ||
|
|
38913e4ae1 |
@@ -1,7 +1,11 @@
|
|||||||
---
|
---
|
||||||
applyTo: '**/*.benchmark.ts'
|
name: benchmark
|
||||||
|
description: Run and author benchmark tests in xterm.js. Use when editing benchmark files, working in `*.benchmark.ts`, or when asked how to run the full benchmark suite, a single benchmark file, or a single benchmark case.
|
||||||
---
|
---
|
||||||
# Benchmark run instructions
|
|
||||||
|
# Benchmark Run Instructions
|
||||||
|
|
||||||
|
## Running Benchmarks
|
||||||
|
|
||||||
- Full suite: `npm run benchmark`
|
- Full suite: `npm run benchmark`
|
||||||
- Single benchmark file:
|
- Single benchmark file:
|
||||||
@@ -11,8 +15,14 @@ applyTo: '**/*.benchmark.ts'
|
|||||||
- Use `-t` to get the path, then:
|
- Use `-t` to get the path, then:
|
||||||
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
|
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
|
||||||
|
|
||||||
When writing instructions, use `RuntimeCase` to measure pure runtime in ms, use `ThroughputRuntimeCase` when measuring throughput in MB/s.
|
## Benchmark Case Selection
|
||||||
|
|
||||||
|
When writing benchmark instructions:
|
||||||
|
|
||||||
|
- Use `RuntimeCase` to measure pure runtime in ms.
|
||||||
|
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
|
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
|
||||||
- Keep `NODE_PATH=./out` (handled by the npm script).
|
- Keep `NODE_PATH=./out` (handled by the npm script).
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
---
|
---
|
||||||
applyTo: '**/*.test.ts'
|
name: unit-test
|
||||||
|
description: Write and review xterm.js unit tests with project conventions. Use when editing `*.test.ts` files or when asked for test authoring guidelines.
|
||||||
---
|
---
|
||||||
When writing unit tests follow these rules:
|
|
||||||
|
# Unit Test Instructions
|
||||||
|
|
||||||
|
When writing unit tests, follow these rules:
|
||||||
|
|
||||||
- When writing unit tests for addons, always create a real xterm.js instance instead of mocking it.
|
- When writing unit tests for addons, always create a real xterm.js instance instead of mocking it.
|
||||||
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
|
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"SessionStart": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "npm run agent:setup-repo"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+32
-40
@@ -11,9 +11,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
./addons/addon-webgl/out/* \
|
./addons/addon-webgl/out/* \
|
||||||
./addons/addon-webgl/out-*st/*
|
./addons/addon-webgl/out-*st/*
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: build-artifacts
|
name: build-artifacts
|
||||||
path: compressed-build.zip
|
path: compressed-build.zip
|
||||||
@@ -79,9 +79,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -100,16 +100,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: build-artifacts
|
name: build-artifacts
|
||||||
- name: Unzip artifacts
|
- name: Unzip artifacts
|
||||||
@@ -129,6 +129,7 @@ jobs:
|
|||||||
exit $EXIT_CODE
|
exit $EXIT_CODE
|
||||||
|
|
||||||
test-unit:
|
test-unit:
|
||||||
|
needs: build
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -136,21 +137,16 @@ jobs:
|
|||||||
runs-on: [ubuntu, macos, windows]
|
runs-on: [ubuntu, macos, windows]
|
||||||
runs-on: ${{ matrix.runs-on }}-latest
|
runs-on: ${{ matrix.runs-on }}-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}.x
|
node-version: ${{ matrix.node-version }}.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
- name: Wait for build job
|
- uses: actions/download-artifact@v7
|
||||||
uses: NathanFirmo/wait-for-other-job@v1.1.1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
job: build
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: build-artifacts
|
name: build-artifacts
|
||||||
- name: Unzip artifacts
|
- name: Unzip artifacts
|
||||||
@@ -166,6 +162,7 @@ jobs:
|
|||||||
run: npm run test-unit --forbid-only
|
run: npm run test-unit --forbid-only
|
||||||
|
|
||||||
test-integration:
|
test-integration:
|
||||||
|
needs: build
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -174,9 +171,9 @@ jobs:
|
|||||||
browser: [chromium, firefox, webkit]
|
browser: [chromium, firefox, webkit]
|
||||||
runs-on: ${{ matrix.runs-on }}
|
runs-on: ${{ matrix.runs-on }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}.x
|
node-version: ${{ matrix.node-version }}.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -185,12 +182,7 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
- name: Install playwright
|
- name: Install playwright
|
||||||
run: npx playwright install --with-deps ${{ matrix.browser }}
|
run: npx playwright install --with-deps ${{ matrix.browser }}
|
||||||
- name: Wait for build job
|
- uses: actions/download-artifact@v7
|
||||||
uses: NathanFirmo/wait-for-other-job@v1.1.1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
job: build
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: build-artifacts
|
name: build-artifacts
|
||||||
- name: Unzip artifacts
|
- name: Unzip artifacts
|
||||||
@@ -207,31 +199,31 @@ jobs:
|
|||||||
- name: Build demo server
|
- name: Build demo server
|
||||||
run: npm run esbuild-demo-server
|
run: npm run esbuild-demo-server
|
||||||
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness
|
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core
|
||||||
- name: Integration tests (addon-attach)
|
- name: Integration tests (addon-attach)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach
|
||||||
- name: Integration tests (addon-clipboard)
|
- name: Integration tests (addon-clipboard)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard
|
||||||
- name: Integration tests (addon-fit)
|
- name: Integration tests (addon-fit)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit
|
||||||
- name: Integration tests (addon-image)
|
- name: Integration tests (addon-image)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image
|
||||||
- name: Integration tests (addon-progress)
|
- name: Integration tests (addon-progress)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress
|
||||||
- name: Integration tests (addon-search)
|
- name: Integration tests (addon-search)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search
|
||||||
- name: Integration tests (addon-serialize)
|
- name: Integration tests (addon-serialize)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize
|
||||||
- name: Integration tests (addon-unicode-graphemes)
|
- name: Integration tests (addon-unicode-graphemes)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes
|
||||||
- name: Integration tests (addon-unicode11)
|
- name: Integration tests (addon-unicode11)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11
|
||||||
- name: Integration tests (addon-web-fonts)
|
- name: Integration tests (addon-web-fonts)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts
|
||||||
- name: Integration tests (addon-web-links)
|
- name: Integration tests (addon-web-links)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links
|
||||||
- name: Integration tests (addon-webgl)
|
- name: Integration tests (addon-webgl)
|
||||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
|
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl
|
||||||
|
|
||||||
release-dry-run:
|
release-dry-run:
|
||||||
needs: build
|
needs: build
|
||||||
@@ -240,9 +232,9 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
node-version: [22]
|
node-version: [22]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}.x
|
node-version: ${{ matrix.node-version }}.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -251,7 +243,7 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
- name: Install playwright
|
- name: Install playwright
|
||||||
run: npx playwright install
|
run: npx playwright install
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: build-artifacts
|
name: build-artifacts
|
||||||
- name: Unzip artifacts
|
- name: Unzip artifacts
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v5
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v2
|
uses: github/codeql-action/init@v4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v2
|
uses: github/codeql-action/autobuild@v4
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v4
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
@@ -17,11 +17,16 @@ docs/
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
/.idea/
|
/.idea/
|
||||||
.env
|
.env
|
||||||
|
mise.toml
|
||||||
build/
|
build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
yarn.lock
|
yarn.lock
|
||||||
test-results/
|
test-results/
|
||||||
|
|
||||||
|
# Tracing/debug files
|
||||||
|
.cursor/nal-trace/
|
||||||
|
.cursor/*.log
|
||||||
|
|
||||||
# Keep bundled code out of Git
|
# Keep bundled code out of Git
|
||||||
dist/
|
dist/
|
||||||
demo/dist/
|
demo/dist/
|
||||||
|
|||||||
+374
@@ -0,0 +1,374 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": [
|
||||||
|
"import",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "off"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"typeAware": true,
|
||||||
|
"maxWarnings": 0
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"addons/*/src/third-party/*.ts",
|
||||||
|
"**/out/*",
|
||||||
|
"**/out-test/*",
|
||||||
|
"**/out-esbuild/*",
|
||||||
|
"**/out-esbuild-test/*",
|
||||||
|
"**/inwasm-sdks/*",
|
||||||
|
"**/typings/*.d.ts",
|
||||||
|
"**/node_modules",
|
||||||
|
"**/*.js",
|
||||||
|
"**/*.mjs"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"constructor-super": "error",
|
||||||
|
"for-direction": "error",
|
||||||
|
"getter-return": "error",
|
||||||
|
"no-async-promise-executor": "error",
|
||||||
|
"no-case-declarations": "error",
|
||||||
|
"no-class-assign": "error",
|
||||||
|
"no-compare-neg-zero": "error",
|
||||||
|
"no-cond-assign": "error",
|
||||||
|
"no-const-assign": "error",
|
||||||
|
"no-constant-binary-expression": "error",
|
||||||
|
"no-constant-condition": "error",
|
||||||
|
"no-control-regex": "error",
|
||||||
|
"no-debugger": "error",
|
||||||
|
"no-delete-var": "error",
|
||||||
|
"no-dupe-class-members": "error",
|
||||||
|
"no-dupe-else-if": "error",
|
||||||
|
"no-dupe-keys": "error",
|
||||||
|
"no-duplicate-case": "error",
|
||||||
|
"no-empty": "error",
|
||||||
|
"no-empty-character-class": "error",
|
||||||
|
"no-empty-pattern": "error",
|
||||||
|
"no-empty-static-block": "error",
|
||||||
|
"no-ex-assign": "error",
|
||||||
|
"no-extra-boolean-cast": "error",
|
||||||
|
"no-fallthrough": "error",
|
||||||
|
"no-func-assign": "error",
|
||||||
|
"no-global-assign": "error",
|
||||||
|
"no-import-assign": "error",
|
||||||
|
"no-invalid-regexp": "error",
|
||||||
|
"no-irregular-whitespace": "error",
|
||||||
|
"no-loss-of-precision": "error",
|
||||||
|
"no-misleading-character-class": "error",
|
||||||
|
"no-new-native-nonconstructor": "error",
|
||||||
|
"no-nonoctal-decimal-escape": "error",
|
||||||
|
"no-obj-calls": "error",
|
||||||
|
"no-prototype-builtins": "error",
|
||||||
|
"no-redeclare": "error",
|
||||||
|
"no-regex-spaces": "error",
|
||||||
|
"no-self-assign": "error",
|
||||||
|
"no-setter-return": "error",
|
||||||
|
"no-shadow-restricted-names": "error",
|
||||||
|
"no-sparse-arrays": "error",
|
||||||
|
"no-this-before-super": "error",
|
||||||
|
"no-unassigned-vars": "error",
|
||||||
|
"no-unexpected-multiline": "error",
|
||||||
|
"no-unreachable": "error",
|
||||||
|
"no-unsafe-finally": "error",
|
||||||
|
"no-unsafe-negation": "error",
|
||||||
|
"no-unsafe-optional-chaining": "error",
|
||||||
|
"no-unused-labels": "error",
|
||||||
|
"no-unused-private-class-members": "error",
|
||||||
|
"no-unused-vars": "error",
|
||||||
|
"no-useless-backreference": "error",
|
||||||
|
"no-useless-catch": "error",
|
||||||
|
"no-useless-escape": "error",
|
||||||
|
"no-with": "error",
|
||||||
|
"preserve-caught-error": "error",
|
||||||
|
"require-yield": "error",
|
||||||
|
"use-isnan": "error",
|
||||||
|
"valid-typeof": "error",
|
||||||
|
"no-array-constructor": "error",
|
||||||
|
"no-unused-expressions": "error",
|
||||||
|
"typescript/ban-ts-comment": "error",
|
||||||
|
"typescript/no-duplicate-enum-values": "error",
|
||||||
|
"typescript/no-empty-object-type": "error",
|
||||||
|
"typescript/no-explicit-any": "error",
|
||||||
|
"typescript/no-extra-non-null-assertion": "error",
|
||||||
|
"typescript/no-misused-new": "error",
|
||||||
|
"typescript/no-namespace": "error",
|
||||||
|
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||||
|
"typescript/no-require-imports": "error",
|
||||||
|
"typescript/no-this-alias": "error",
|
||||||
|
"typescript/no-unnecessary-type-constraint": "error",
|
||||||
|
"typescript/no-unsafe-declaration-merging": "error",
|
||||||
|
"typescript/no-unsafe-function-type": "error",
|
||||||
|
"typescript/no-wrapper-object-types": "error",
|
||||||
|
"typescript/prefer-as-const": "error",
|
||||||
|
"typescript/prefer-namespace-keyword": "error",
|
||||||
|
"typescript/triple-slash-reference": "error",
|
||||||
|
"import/no-cycle": "error"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.mts",
|
||||||
|
"**/*.cts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"constructor-super": "off",
|
||||||
|
"getter-return": "off",
|
||||||
|
"no-class-assign": "off",
|
||||||
|
"no-const-assign": "off",
|
||||||
|
"no-dupe-class-members": "off",
|
||||||
|
"no-dupe-keys": "off",
|
||||||
|
"no-func-assign": "off",
|
||||||
|
"no-import-assign": "off",
|
||||||
|
"no-new-native-nonconstructor": "off",
|
||||||
|
"no-obj-calls": "off",
|
||||||
|
"no-redeclare": "off",
|
||||||
|
"no-setter-return": "off",
|
||||||
|
"no-this-before-super": "off",
|
||||||
|
"no-unreachable": "off",
|
||||||
|
"no-unsafe-negation": "off",
|
||||||
|
"no-var": "error",
|
||||||
|
"no-with": "off",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"prefer-rest-params": "error",
|
||||||
|
"prefer-spread": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*.ts"
|
||||||
|
],
|
||||||
|
"jsPlugins": [
|
||||||
|
"@stylistic/eslint-plugin",
|
||||||
|
{
|
||||||
|
"name": "jsdoc-js",
|
||||||
|
"specifier": "eslint-plugin-jsdoc"
|
||||||
|
},
|
||||||
|
"oxlint-plugin-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@stylistic/comma-spacing": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"before": false,
|
||||||
|
"after": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/indent": [
|
||||||
|
"warn",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"@stylistic/semi": [
|
||||||
|
"warn",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"@stylistic/quotes": [
|
||||||
|
"warn",
|
||||||
|
"single",
|
||||||
|
{
|
||||||
|
"allowTemplateLiterals": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/member-delimiter-style": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"multiline": {
|
||||||
|
"delimiter": "semi",
|
||||||
|
"requireLast": true
|
||||||
|
},
|
||||||
|
"singleline": {
|
||||||
|
"delimiter": "comma",
|
||||||
|
"requireLast": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/type-annotation-spacing": "warn",
|
||||||
|
"@stylistic/space-unary-ops": "warn",
|
||||||
|
"@stylistic/keyword-spacing": "warn",
|
||||||
|
"@stylistic/max-len": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"code": 1000,
|
||||||
|
"comments": 100,
|
||||||
|
"ignoreTrailingComments": true,
|
||||||
|
"ignoreUrls": true,
|
||||||
|
"ignorePattern": "^ *((?<vt_comment>(//|\\*) @vt)|(?<table_comment>\\* \\| )|(?<commented_code>// ))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/new-parens": "warn",
|
||||||
|
"@stylistic/no-extra-semi": "error",
|
||||||
|
"@stylistic/no-trailing-spaces": "warn",
|
||||||
|
"eslint-js/object-curly-spacing": [
|
||||||
|
"warn",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"@stylistic/spaced-comment": [
|
||||||
|
"warn",
|
||||||
|
"always",
|
||||||
|
{
|
||||||
|
"markers": [
|
||||||
|
"/"
|
||||||
|
],
|
||||||
|
"exceptions": [
|
||||||
|
"-"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"jsdoc-js/check-alignment": "warn",
|
||||||
|
"jsdoc-js/check-param-names": "warn",
|
||||||
|
"jsdoc-js/multiline-blocks": "warn",
|
||||||
|
"jsdoc-js/no-multi-asterisks": "warn",
|
||||||
|
"eslint-js/one-var": [
|
||||||
|
"warn",
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"eslint-js/no-restricted-syntax": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"selector": "CallExpression[callee.name='requestAnimationFrame']",
|
||||||
|
"message": "The global requestAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "CallExpression[callee.name='cancelAnimationFrame']",
|
||||||
|
"message": "The global cancelAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "CallExpression > MemberExpression[object.name='window'][property.name='requestAnimationFrame']",
|
||||||
|
"message": "window.requestAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "CallExpression > MemberExpression[object.name='window'][property.name='cancelAnimationFrame']",
|
||||||
|
"message": "window.cancelAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "MemberExpression[object.name='window'][property.name='devicePixelRatio']",
|
||||||
|
"message": "window.devicePixelRatio should be avoided, get it from ICoreBrowserService."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"curly": [
|
||||||
|
"warn",
|
||||||
|
"multi-line"
|
||||||
|
],
|
||||||
|
"eqeqeq": [
|
||||||
|
"warn",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"no-duplicate-imports": "warn",
|
||||||
|
"no-else-return": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allowElseIf": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-eval": "warn",
|
||||||
|
"no-irregular-whitespace": "warn",
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
".*\\/out\\/.*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-unsafe-finally": "warn",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"no-var": "warn",
|
||||||
|
"no-empty": "off",
|
||||||
|
"no-empty-pattern": "off",
|
||||||
|
"no-cond-assign": "off",
|
||||||
|
"no-case-declarations": "off",
|
||||||
|
"for-direction": "off",
|
||||||
|
"no-prototype-builtins": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
"no-self-assign": "off",
|
||||||
|
"no-async-promise-executor": "off",
|
||||||
|
"prefer-rest-params": "off",
|
||||||
|
"no-control-regex": "off",
|
||||||
|
"no-fallthrough": "off",
|
||||||
|
"prefer-spread": "off",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
"no-unused-expressions": "off",
|
||||||
|
"typescript/array-type": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"default": "array",
|
||||||
|
"readonly": "generic"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/consistent-type-assertions": "warn",
|
||||||
|
"typescript/consistent-type-definitions": "warn",
|
||||||
|
"typescript/explicit-function-return-type": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allowExpressions": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/explicit-member-accessibility": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"accessibility": "explicit",
|
||||||
|
"overrides": {
|
||||||
|
"constructors": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/no-confusing-void-expression": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"ignoreArrowShorthand": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/no-useless-constructor": "warn",
|
||||||
|
"typescript/prefer-nullish-coalescing": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"ignorePrimitives": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/prefer-namespace-keyword": "warn",
|
||||||
|
"typescript/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"vars": "all",
|
||||||
|
"args": "none"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/no-require-imports": "off",
|
||||||
|
"typescript/no-explicit-any": "off",
|
||||||
|
"typescript/no-wrapper-object-types": "off",
|
||||||
|
"typescript/no-empty-object-type": "off",
|
||||||
|
"typescript/no-unsafe-function-type": "off",
|
||||||
|
"typescript/no-this-alias": "off",
|
||||||
|
"typescript/no-namespace": "off",
|
||||||
|
"typescript/no-duplicate-enum-values": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*.api.ts",
|
||||||
|
"**/*.test.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"eslint-js/object-curly-spacing": "off",
|
||||||
|
"@stylistic/max-len": "off",
|
||||||
|
"typescript/no-unused-vars": "off",
|
||||||
|
"typescript/explicit-function-return-type": "off",
|
||||||
|
"typescript/explicit-member-accessibility": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"demo/**/*.ts",
|
||||||
|
"**/*.benchmark.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"typescript/prefer-nullish-coalescing": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"options": {
|
||||||
|
"maxWarnings": 0
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"typings/**/*.d.ts"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"jsPlugins": [
|
||||||
|
"@stylistic/eslint-plugin",
|
||||||
|
{
|
||||||
|
"name": "jsdoc-js",
|
||||||
|
"specifier": "eslint-plugin-jsdoc"
|
||||||
|
},
|
||||||
|
"oxlint-plugin-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@stylistic/indent": [
|
||||||
|
"warn",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"@stylistic/semi": [
|
||||||
|
"warn",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"@stylistic/quotes": [
|
||||||
|
"warn",
|
||||||
|
"single",
|
||||||
|
{
|
||||||
|
"allowTemplateLiterals": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/member-delimiter-style": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"multiline": {
|
||||||
|
"delimiter": "semi",
|
||||||
|
"requireLast": true
|
||||||
|
},
|
||||||
|
"singleline": {
|
||||||
|
"delimiter": "comma",
|
||||||
|
"requireLast": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/type-annotation-spacing": "warn",
|
||||||
|
"@stylistic/keyword-spacing": "warn",
|
||||||
|
"@stylistic/max-len": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"code": 1000,
|
||||||
|
"comments": 80,
|
||||||
|
"ignoreUrls": true,
|
||||||
|
"ignorePattern": "^ *(?<ps_description>\\* Ps=)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/no-extra-semi": "error",
|
||||||
|
"@stylistic/no-trailing-spaces": "warn",
|
||||||
|
"eslint-js/object-curly-spacing": [
|
||||||
|
"warn",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"@stylistic/spaced-comment": [
|
||||||
|
"warn",
|
||||||
|
"always",
|
||||||
|
{
|
||||||
|
"markers": [
|
||||||
|
"/"
|
||||||
|
],
|
||||||
|
"exceptions": [
|
||||||
|
"-"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/comma-dangle": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"objects": "never",
|
||||||
|
"arrays": "never",
|
||||||
|
"functions": "never"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@stylistic/eol-last": "warn",
|
||||||
|
"curly": [
|
||||||
|
"warn",
|
||||||
|
"multi-line"
|
||||||
|
],
|
||||||
|
"eqeqeq": [
|
||||||
|
"warn",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"jsdoc-js/check-alignment": "warn",
|
||||||
|
"jsdoc-js/check-param-names": "warn",
|
||||||
|
"no-irregular-whitespace": "warn",
|
||||||
|
"typescript/array-type": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"default": "array",
|
||||||
|
"readonly": "generic"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/explicit-function-return-type": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allowExpressions": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/prefer-namespace-keyword": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
-3
@@ -15,9 +15,6 @@
|
|||||||
"--recursive",
|
"--recursive",
|
||||||
"${workspaceRoot}/out/**/*.test.js"
|
"${workspaceRoot}/out/**/*.test.js"
|
||||||
],
|
],
|
||||||
"env": {
|
|
||||||
"NODE_PATH": "${workspaceRoot}/out"
|
|
||||||
},
|
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"outFiles": [ "${workspaceRoot}/out/**/*.js" ],
|
"outFiles": [ "${workspaceRoot}/out/**/*.js" ],
|
||||||
"internalConsoleOptions": "openOnSessionStart"
|
"internalConsoleOptions": "openOnSessionStart"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ npm run build && npm run esbuild # Build all TypeScript and bundle
|
|||||||
- Integration tests: `npm run test-integration` (Playwright across Chrome/Firefox/WebKit)
|
- Integration tests: `npm run test-integration` (Playwright across Chrome/Firefox/WebKit)
|
||||||
- Integration tests by file: `npm run test-integration -- test/playwright/InputHandler.test.ts`. Never use grep to filter tests, it doesn't work
|
- Integration tests by file: `npm run test-integration -- test/playwright/InputHandler.test.ts`. Never use grep to filter tests, it doesn't work
|
||||||
- Integration tests by addon: `npm run test-integration -- --suite=addon-search`. Suites always follow the format `addon-<something>`
|
- Integration tests by addon: `npm run test-integration -- --suite=addon-search`. Suites always follow the format `addon-<something>`
|
||||||
|
- Lint: `npm run lint` (oxlint with type-aware rules, then ESLint for `naming-convention` only), `npm run lint-api` for `typings/`, `npm run lint-fix` for oxlint auto-fix
|
||||||
- Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them
|
- Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them
|
||||||
|
|
||||||
## Addon Development Pattern
|
## Addon Development Pattern
|
||||||
@@ -57,6 +58,13 @@ export class MyAddon implements ITerminalAddon {
|
|||||||
- Proposed APIs require `allowProposedApi: true` option
|
- Proposed APIs require `allowProposedApi: true` option
|
||||||
- Constructor-only options (cols, rows) cannot be changed after instantiation
|
- Constructor-only options (cols, rows) cannot be changed after instantiation
|
||||||
|
|
||||||
|
**Disposable Management**:
|
||||||
|
- When a disposable object can be replaced over time, prefer a registered `MutableDisposable` over manual dispose/reassign logic.
|
||||||
|
- Register it on the owning class (for example, `this._register(new MutableDisposable())`) and assign through `.value`; this automatically disposes the previous value and avoids accidentally leaking resources.
|
||||||
|
|
||||||
|
**TypeScript Constants**:
|
||||||
|
- Prefer `const enum` over top-level `const` declarations for primitive constants when appropriate, since values are inlined and avoid runtime property lookups.
|
||||||
|
|
||||||
**Testing Utilities**: Use `TestUtils.ts` helpers:
|
**Testing Utilities**: Use `TestUtils.ts` helpers:
|
||||||
- `openTerminal(ctx, options)` for setup
|
- `openTerminal(ctx, options)` for setup
|
||||||
- `pollFor(page, fn, expectedValue)` for async assertions
|
- `pollFor(page, fn, expectedValue)` for async assertions
|
||||||
@@ -91,3 +99,15 @@ const cell = line?.getCell(0);
|
|||||||
## Writing unit tests
|
## Writing unit tests
|
||||||
|
|
||||||
- Unit tests live alongside the source code file of the thing it's testing with a .test.ts suffix.
|
- Unit tests live alongside the source code file of the thing it's testing with a .test.ts suffix.
|
||||||
|
|
||||||
|
## Cursor Cloud specific instructions
|
||||||
|
|
||||||
|
**Demo server**: Start with `npm start` (port 3000). The demo server uses node-pty to spawn real shell sessions over WebSocket. Integration tests auto-start it via Playwright's `webServer` config, so you don't need to start it manually for `npm run test-integration`.
|
||||||
|
|
||||||
|
**Build before testing**: Always run `npm run build && npm run esbuild` before `npm run test-unit`. Integration tests also need `npm run esbuild-demo-client` and `npm run esbuild-demo-server`. The update script handles this automatically on session start.
|
||||||
|
|
||||||
|
**No external services**: This project has zero external dependencies (no databases, Docker, or APIs). Everything runs locally with Node.js.
|
||||||
|
|
||||||
|
**TypeScript compiler**: The project uses `tsgo` (native TypeScript compiler preview) rather than standard `tsc`. It's installed via the `@typescript/native-preview` package.
|
||||||
|
|
||||||
|
**Lint only changed files**: Prefer `npm run lint-changes` over `npm run lint` when iterating on code changes — it's significantly faster.
|
||||||
+1
-1
@@ -39,7 +39,7 @@ The preferred way to report bugs or request features is to use
|
|||||||
|
|
||||||
It's pretty common for maintainers of large open source projects to suffer from burnout, especially when needing to triage a large number of incoming issues instead of actually building things. Here are some of the steps we take to try mitigate this:
|
It's pretty common for maintainers of large open source projects to suffer from burnout, especially when needing to triage a large number of incoming issues instead of actually building things. Here are some of the steps we take to try mitigate this:
|
||||||
|
|
||||||
- Support questions live in [GH discussions](https://github.com/xtermjs/xterm.js/discussions), issues may be transfered there without further comment and core maintainers may or may not participate in discussions.
|
- Support questions live in [GH discussions](https://github.com/xtermjs/xterm.js/discussions), issues may be transferred there without further comment and core maintainers may or may not participate in discussions.
|
||||||
- Issues are strictly for well defined features or bugs that are _actionable_.
|
- Issues are strictly for well defined features or bugs that are _actionable_.
|
||||||
- Sometimes features are out of scope. A common example of this is a niche feature that the pricipal implementation ([VS Code](https://code.visualstudio.com/)) won't leverage and therefore would be difficult to maintain and likely suffer from bitrot. The reporter may not agree with this, but you could always create an addon if that works or maintain your own fork if it comes to that.
|
- Sometimes features are out of scope. A common example of this is a niche feature that the pricipal implementation ([VS Code](https://code.visualstudio.com/)) won't leverage and therefore would be difficult to maintain and likely suffer from bitrot. The reporter may not agree with this, but you could always create an addon if that works or maintain your own fork if it comes to that.
|
||||||
- If a feature does not have a clear way forward or needs more discussion it may be closed ro moved to a discussion.
|
- If a feature does not have a clear way forward or needs more discussion it may be closed ro moved to a discussion.
|
||||||
|
|||||||
@@ -24,7 +24,36 @@ First, you need to install the module. We ship exclusively through [npm](https:/
|
|||||||
npm install --save @xterm/xterm
|
npm install --save @xterm/xterm
|
||||||
```
|
```
|
||||||
|
|
||||||
To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to the head of your HTML page. Then create a `<div id="terminal"></div>` onto which xterm can attach itself. Finally, instantiate the `Terminal` object and then call the `open` function with the DOM object of the `div`.
|
The recommended way to load xterm.js with the ES module syntax, either using TypeScript or a modern JS tooling. Note that both CommonJS and ES module files are shipped with the npm package.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
```
|
||||||
|
|
||||||
|
Then instantiate a `Terminal` object, open it on an element and write to it:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const term = new Terminal();
|
||||||
|
term.open(document.getElementById('terminal'));
|
||||||
|
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
|
||||||
|
```
|
||||||
|
|
||||||
|
Most use cases will hook up input and output to a pseudoterminal API such as [node-pty](https://www.npmjs.com/package/node-pty) which will drive the terminal.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
pty.onData(data => term.write(data));
|
||||||
|
term.onData(data => pty.write(data));
|
||||||
|
```
|
||||||
|
|
||||||
|
Then make sure to also include the `css/xterm.css` file, for example in HTML:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standalone example
|
||||||
|
|
||||||
|
Here is a complete standalone example in a HTML file:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -36,7 +65,7 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
|
|||||||
<body>
|
<body>
|
||||||
<div id="terminal"></div>
|
<div id="terminal"></div>
|
||||||
<script>
|
<script>
|
||||||
var term = new Terminal();
|
const term = new Terminal();
|
||||||
term.open(document.getElementById('terminal'));
|
term.open(document.getElementById('terminal'));
|
||||||
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
|
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
|
||||||
</script>
|
</script>
|
||||||
@@ -44,14 +73,6 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
|
|||||||
</html>
|
</html>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Importing
|
|
||||||
|
|
||||||
The recommended way to load xterm.js is via the ES6 module syntax:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { Terminal } from '@xterm/xterm';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Addons
|
### Addons
|
||||||
|
|
||||||
Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project:
|
Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project:
|
||||||
@@ -94,7 +115,7 @@ Since xterm.js is typically implemented as a developer tool, generally only mode
|
|||||||
|
|
||||||
### Node.js Support
|
### Node.js Support
|
||||||
|
|
||||||
We also publish [`xterm-headless`](https://www.npmjs.com/package/xterm-headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
|
We also publish [`@xterm/headless`](https://www.npmjs.com/package/@xterm/headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -229,6 +250,11 @@ Xterm.js is used in many world-class applications to provide great terminal expe
|
|||||||
- [**ecmaOS**](https://ecmaos.sh): A kernel and suite of applications tying modern web technologies into a browser-based operating system.
|
- [**ecmaOS**](https://ecmaos.sh): A kernel and suite of applications tying modern web technologies into a browser-based operating system.
|
||||||
- [**LabEx**](https://labex.io): Interactive learning platform with hands-on labs and xterm.js-based online terminals, focused on learn-by-doing approach.
|
- [**LabEx**](https://labex.io): Interactive learning platform with hands-on labs and xterm.js-based online terminals, focused on learn-by-doing approach.
|
||||||
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
|
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
|
||||||
|
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
|
||||||
|
- [**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.
|
||||||
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
|
- [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.
|
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.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
});
|
});
|
||||||
test.afterAll(async () => await ctx.page.close());
|
test.afterAll(async () => await ctx.page.close());
|
||||||
|
|
||||||
test.describe('Search Tests', () => {
|
test.describe('AttachAddon', () => {
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
await ctx.proxy.reset();
|
await ctx.proxy.reset();
|
||||||
|
|||||||
@@ -21,8 +21,5 @@
|
|||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
"start": "node ../../demo/start"
|
"start": "node ../../demo/start"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"js-base64": "^3.7.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
|
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||||
import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard';
|
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard';
|
||||||
import { Base64 as JSBase64 } from 'js-base64';
|
|
||||||
|
|
||||||
export class ClipboardAddon implements ITerminalAddon {
|
export class ClipboardAddon implements ITerminalAddon {
|
||||||
private _terminal?: Terminal;
|
private _terminal?: Terminal;
|
||||||
@@ -25,7 +24,7 @@ export class ClipboardAddon implements ITerminalAddon {
|
|||||||
return this._disposable?.dispose();
|
return this._disposable?.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _readText(sel: ClipboardSelectionType, data: string): void {
|
private _readText(sel: string, data: string): void {
|
||||||
const b64 = this._base64.encodeText(data);
|
const b64 = this._base64.encodeText(data);
|
||||||
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
|
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
|
||||||
}
|
}
|
||||||
@@ -36,7 +35,7 @@ export class ClipboardAddon implements ITerminalAddon {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pc = args[0] as ClipboardSelectionType;
|
const pc = args[0];
|
||||||
const pd = args[1];
|
const pd = args[1];
|
||||||
if (pd === '?') {
|
if (pd === '?') {
|
||||||
const text = this._provider.readText(pc);
|
const text = this._provider.readText(pc);
|
||||||
@@ -70,30 +69,53 @@ export class ClipboardAddon implements ITerminalAddon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BrowserClipboardProvider implements IClipboardProvider {
|
export class BrowserClipboardProvider implements IClipboardProvider {
|
||||||
public async readText(selection: ClipboardSelectionType): Promise<string> {
|
public readText(selection: string): Promise<string> {
|
||||||
if (selection !== 'c') {
|
|
||||||
return Promise.resolve('');
|
|
||||||
}
|
|
||||||
return navigator.clipboard.readText();
|
return navigator.clipboard.readText();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
|
public writeText(selection: string, text: string): Promise<void> {
|
||||||
if (selection !== 'c') {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return navigator.clipboard.writeText(text);
|
return navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export class Base64 implements IBase64 {
|
||||||
public encodeText(data: string): string {
|
public encodeText(data: string): string {
|
||||||
return JSBase64.encode(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 {
|
public decodeText(data: string): string {
|
||||||
const text = JSBase64.decode(data);
|
if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) {
|
||||||
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
|
try {
|
||||||
|
return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data));
|
||||||
|
} catch {}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return text;
|
try {
|
||||||
|
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 '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ test.describe('ClipboardAddon', () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({}, testInfo) => {
|
test.beforeEach(async ({}, testInfo) => {
|
||||||
// DEBT: This test doesn't work since the migration to @playwright/test
|
// DEBT: This test doesn't work since the migration to @playwright/test
|
||||||
if (ctx.browser.browserType().name() !== 'chromium') {
|
if (ctx.browser.browserType().name() === 'webkit') {
|
||||||
testInfo.skip();
|
testInfo.skip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => {
|
|||||||
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
}
|
}
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.term.reset()
|
window.term.reset();
|
||||||
window.clipboard?.dispose();
|
window.clipboard?.dispose();
|
||||||
window.clipboard = new ClipboardAddon();
|
window.clipboard = new ClipboardAddon();
|
||||||
window.term.loadAddon(window.clipboard);
|
window.term.loadAddon(window.clipboard);
|
||||||
@@ -48,6 +48,14 @@ test.describe('ClipboardAddon', () => {
|
|||||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
|
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
|
||||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
||||||
});
|
});
|
||||||
|
test('primary selection', async () => {
|
||||||
|
await ctx.proxy.write(`\x1b]52;p;${testDataEncoded}\x07`);
|
||||||
|
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
||||||
|
});
|
||||||
|
test('empty selection (default)', async () => {
|
||||||
|
await ctx.proxy.write(`\x1b]52;;${testDataEncoded}\x07`);
|
||||||
|
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
||||||
|
});
|
||||||
test('invalid base64 string', async () => {
|
test('invalid base64 string', async () => {
|
||||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`);
|
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`);
|
||||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
|
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
|
||||||
@@ -60,19 +68,46 @@ test.describe('ClipboardAddon', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('read data', async function (): Promise<any> {
|
test.describe('read data', async function (): Promise<any> {
|
||||||
test('simple string', async () => {
|
test.beforeAll(async () => {
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.data = [];
|
window.data = [];
|
||||||
window.term.onData(e => data.push(e));
|
window.term.onData(e => data.push(e));
|
||||||
`);
|
`);
|
||||||
|
});
|
||||||
|
test('simple string', async () => {
|
||||||
|
await ctx.page.evaluate('window.data.length = 0;');
|
||||||
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
|
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
|
||||||
await ctx.proxy.write(`\x1b]52;c;?\x07`);
|
await ctx.proxy.write(`\x1b]52;c;?\x07`);
|
||||||
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]);
|
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]);
|
||||||
});
|
});
|
||||||
|
test('primary selection', async () => {
|
||||||
|
await ctx.page.evaluate('window.data.length = 0;');
|
||||||
|
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
|
||||||
|
await ctx.proxy.write(`\x1b]52;p;?\x07`);
|
||||||
|
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;p;${testDataEncoded}\x07`]);
|
||||||
|
});
|
||||||
test('clear clipboard', async () => {
|
test('clear clipboard', async () => {
|
||||||
await ctx.proxy.write(`\x1b]52;c;!\x07`);
|
await ctx.proxy.write(`\x1b]52;c;!\x07`);
|
||||||
await ctx.proxy.write(`\x1b]52;c;?\x07`);
|
await ctx.proxy.write(`\x1b]52;c;?\x07`);
|
||||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
|
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`]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-21
@@ -30,18 +30,6 @@ declare module '@xterm/addon-clipboard' {
|
|||||||
public dispose(): void
|
public dispose(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clipboard selection type. This is used to specify which selection buffer to
|
|
||||||
* read or write to.
|
|
||||||
* - SYSTEM `c`: The system clipboard.
|
|
||||||
* - PRIMARY `p`: The primary clipboard. This is provided for compatibility
|
|
||||||
* with Linux X11.
|
|
||||||
*/
|
|
||||||
export const enum ClipboardSelectionType {
|
|
||||||
SYSTEM = 'c',
|
|
||||||
PRIMARY = 'p',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IBase64 {
|
export interface IBase64 {
|
||||||
/**
|
/**
|
||||||
* Converts a utf-8 string to a base64 string.
|
* Converts a utf-8 string to a base64 string.
|
||||||
@@ -78,36 +66,39 @@ declare module '@xterm/addon-clipboard' {
|
|||||||
export interface IClipboardProvider {
|
export interface IClipboardProvider {
|
||||||
/**
|
/**
|
||||||
* Gets the clipboard content.
|
* Gets the clipboard content.
|
||||||
* @param selection The clipboard selection to read.
|
* @param selection The clipboard selection to read (see OSC 52 spec of xterm).
|
||||||
* @returns A promise that resolves with clipboard selection data.
|
* @returns A promise that resolves with clipboard selection data.
|
||||||
*/
|
*/
|
||||||
readText(selection: ClipboardSelectionType): string | Promise<string>;
|
readText(selection: string): string | Promise<string>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the clipboard content.
|
* Sets the clipboard content.
|
||||||
* @param selection The clipboard selection to set.
|
* @param selection The clipboard selection to set (see OSC 52 spec of xterm).
|
||||||
* @param data The clipboard text to write.
|
* @param data The clipboard text to write.
|
||||||
*/
|
*/
|
||||||
writeText(selection: ClipboardSelectionType, text: string): void | Promise<void>;
|
writeText(selection: string, text: string): void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The clipboard provider interface that enables xterm.js to access the system clipboard.
|
* The clipboard provider interface that enables xterm.js to access the browser clipboard.
|
||||||
|
*
|
||||||
|
* Note that this clipboard provider does not implement own cut buffers as xterm does,
|
||||||
|
* but redirect the selection parameter always to navigator.clipboard.
|
||||||
*/
|
*/
|
||||||
export class BrowserClipboardProvider implements IClipboardProvider{
|
export class BrowserClipboardProvider implements IClipboardProvider{
|
||||||
/**
|
/**
|
||||||
* Reads text from the clipboard.
|
* Reads text from the clipboard.
|
||||||
* @param selection The selection type to read from.
|
* @param selection The selection type to read from (always refers to navigator.clipboard).
|
||||||
* @returns A promise that resolves with the text from the clipboard.
|
* @returns A promise that resolves with the text from the clipboard.
|
||||||
*/
|
*/
|
||||||
public readText(selection: ClipboardSelectionType): Promise<string>;
|
public readText(selection: string): Promise<string>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes text to the clipboard.
|
* Writes text to the clipboard.
|
||||||
* @param selection The selection type to write to.
|
* @param selection The selection type to write to (always refers to navigator.clipboard).
|
||||||
* @param data The text to write to the clipboard.
|
* @param data The text to write to the clipboard.
|
||||||
* @returns A promise that resolves when the text has been written to the clipboard.
|
* @returns A promise that resolves when the text has been written to the clipboard.
|
||||||
*/
|
*/
|
||||||
public writeText(selection: ClipboardSelectionType, data: string): Promise<void>;
|
public writeText(selection: string, data: string): Promise<void>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ interface ITerminalDimensions {
|
|||||||
cols: number;
|
cols: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MINIMUM_COLS = 2;
|
const enum Constants {
|
||||||
const MINIMUM_ROWS = 1;
|
MINIMUM_COLS = 2,
|
||||||
|
MINIMUM_ROWS = 1
|
||||||
|
}
|
||||||
|
|
||||||
function getWindow(e: Node): Window {
|
function getWindow(e: Node): Window {
|
||||||
if (e?.ownerDocument?.defaultView) {
|
if (e?.ownerDocument?.defaultView) {
|
||||||
@@ -48,10 +50,7 @@ export class FitAddon implements ITerminalAddon, IFitApi {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force a full render
|
this._terminal.resize(dims.cols, dims.rows);
|
||||||
if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
|
|
||||||
this._terminal.resize(dims.cols, dims.rows);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public proposeDimensions(): ITerminalDimensions | undefined {
|
public proposeDimensions(): ITerminalDimensions | undefined {
|
||||||
@@ -75,22 +74,22 @@ export class FitAddon implements ITerminalAddon, IFitApi {
|
|||||||
: (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
|
: (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
|
||||||
|
|
||||||
const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement);
|
const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement);
|
||||||
const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
|
const parentElementHeight = Math.max(0, parseInt(parentElementStyle.getPropertyValue('height'), 10) || 0);
|
||||||
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
|
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width'), 10) || 0);
|
||||||
const elementStyle = _getComputedStyle(this._terminal.element);
|
const elementStyle = _getComputedStyle(this._terminal.element);
|
||||||
const elementPadding = {
|
const elementPadding = {
|
||||||
top: parseInt(elementStyle.getPropertyValue('padding-top')),
|
top: parseInt(elementStyle.getPropertyValue('padding-top'), 10) || 0,
|
||||||
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
|
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom'), 10) || 0,
|
||||||
right: parseInt(elementStyle.getPropertyValue('padding-right')),
|
right: parseInt(elementStyle.getPropertyValue('padding-right'), 10) || 0,
|
||||||
left: parseInt(elementStyle.getPropertyValue('padding-left'))
|
left: parseInt(elementStyle.getPropertyValue('padding-left'), 10) || 0
|
||||||
};
|
};
|
||||||
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
|
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
|
||||||
const elementPaddingHor = elementPadding.right + elementPadding.left;
|
const elementPaddingHor = elementPadding.right + elementPadding.left;
|
||||||
const availableHeight = parentElementHeight - elementPaddingVer;
|
const availableHeight = parentElementHeight - elementPaddingVer;
|
||||||
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
|
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
|
||||||
const geometry = {
|
const geometry = {
|
||||||
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
|
cols: Math.max(Constants.MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
|
||||||
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
|
rows: Math.max(Constants.MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
|
||||||
};
|
};
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ test.describe('FitAddon', () => {
|
|||||||
strictEqual(await ctx.proxy.cols, 2);
|
strictEqual(await ctx.proxy.cols, 2);
|
||||||
strictEqual(await ctx.proxy.rows, 1);
|
strictEqual(await ctx.proxy.rows, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('same dimensions', async function(): Promise<any> {
|
||||||
|
await setDimensions();
|
||||||
|
await ctx.page.evaluate(`window.fit.fit()`);
|
||||||
|
const cols: number = await ctx.proxy.cols;
|
||||||
|
const rows: number = await ctx.proxy.rows;
|
||||||
|
// Calling fit() again at the same container size should not throw
|
||||||
|
await ctx.page.evaluate(`window.fit.fit()`);
|
||||||
|
strictEqual(await ctx.proxy.cols, cols);
|
||||||
|
strictEqual(await ctx.proxy.rows, rows);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
## @xterm/addon-image
|
## @xterm/addon-image
|
||||||
|
|
||||||
Inline image output in xterm.js. Supports SIXEL and iTerm's inline image protocol (IIP).
|
Inline image output in xterm.js. Supports SIXEL, iTerm's inline image protocol (IIP)
|
||||||
|
and partially Kitty's terminal graphics prtocol (TGP).
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
@@ -25,14 +26,14 @@ const customSettings: IImageAddonOptions = {
|
|||||||
pixelLimit: 16777216, // max. pixel size of a single image
|
pixelLimit: 16777216, // max. pixel size of a single image
|
||||||
sixelSupport: true, // enable sixel support
|
sixelSupport: true, // enable sixel support
|
||||||
sixelScrolling: true, // whether to scroll on image output
|
sixelScrolling: true, // whether to scroll on image output
|
||||||
sixelPaletteLimit: 256, // initial sixel palette size
|
sixelPaletteLimit: 4096, // initial sixel palette size
|
||||||
sixelSizeLimit: 25000000, // size limit of a single sixel sequence
|
sixelSizeLimit: 33554432, // size limit of a single sixel sequence
|
||||||
storageLimit: 128, // FIFO storage limit in MB
|
storageLimit: 128, // FIFO storage limit in MB
|
||||||
showPlaceholder: true, // whether to show a placeholder for evicted images
|
showPlaceholder: true, // whether to show a placeholder for evicted images
|
||||||
iipSupport: true, // enable iTerm IIP support
|
iipSupport: true, // enable iTerm IIP support
|
||||||
iipSizeLimit: 20000000, // size limit of a single IIP sequence
|
iipSizeLimit: 33554432, // size limit of a single IIP sequence
|
||||||
kittySupport: true, // enable Kitty graphics support
|
kittySupport: true, // enable Kitty graphics support
|
||||||
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
|
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialization
|
// initialization
|
||||||
@@ -43,9 +44,6 @@ terminal.loadAddon(imageAddon);
|
|||||||
|
|
||||||
### General Notes
|
### General Notes
|
||||||
|
|
||||||
- *IMPORTANT:* The worker approach as done in previous releases got removed.
|
|
||||||
The addon contructor no longer expects a worker path as first argument.
|
|
||||||
|
|
||||||
- By default the addon will activate these `windowOptions` reports on the terminal:
|
- By default the addon will activate these `windowOptions` reports on the terminal:
|
||||||
- getWinSizePixels (CSI 14 t)
|
- getWinSizePixels (CSI 14 t)
|
||||||
- getCellSizePixels (CSI 16 t)
|
- getCellSizePixels (CSI 16 t)
|
||||||
@@ -73,7 +71,7 @@ terminal.loadAddon(imageAddon);
|
|||||||
|
|
||||||
- **Cursor Positioning**
|
- **Cursor Positioning**
|
||||||
If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode).
|
If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode).
|
||||||
Other cursor positioning modes as used by xterm or mintty are not supported.
|
Other cursor positioning modes as used by xterm or mintty are not supported for sixel.
|
||||||
|
|
||||||
- **SIXEL Palette Handling**
|
- **SIXEL Palette Handling**
|
||||||
By default the addon limits the palette size to 256 registers (as demanded by the DEC specification).
|
By default the addon limits the palette size to 256 registers (as demanded by the DEC specification).
|
||||||
@@ -92,7 +90,7 @@ terminal.loadAddon(imageAddon);
|
|||||||
therefore palette animations cannot be used.
|
therefore palette animations cannot be used.
|
||||||
|
|
||||||
- **SIXEL Raster Attributes Handling**
|
- **SIXEL Raster Attributes Handling**
|
||||||
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extend. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extend. Both is discouraged by the >30 years old specification.
|
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extent. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extent. Both is discouraged by the >30 years old specification.
|
||||||
|
|
||||||
Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel.
|
Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel.
|
||||||
|
|
||||||
@@ -100,17 +98,24 @@ terminal.loadAddon(imageAddon);
|
|||||||
Set by default, change it with `{iipSupport: true}`.
|
Set by default, change it with `{iipSupport: true}`.
|
||||||
|
|
||||||
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
|
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
|
||||||
- Supported formats: PNG, JPEG and GIF
|
- Supported formats: PNG, JPEG, GIF and QOI
|
||||||
- No animation support.
|
- No animation support.
|
||||||
- Image type hinting is not supported (always deducted from data header).
|
- Image type hinting is not supported (always deducted from data header).
|
||||||
- File download is not supported.
|
- File download is not supported.
|
||||||
- Filename gets parsed but not used.
|
- Filename gets parsed but not used.
|
||||||
- Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed).
|
- Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed).
|
||||||
- Payload size may not exceed CEIL(sizeParameter * 4 / 3).
|
|
||||||
- Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`).
|
- Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`).
|
||||||
- Image pixel size is restricted by `pixelLimit` (pre- and post resizing).
|
- Image pixel size is restricted by `pixelLimit` (pre- and post resizing).
|
||||||
- Size parameter is restricted by `iipSizeLimit`.
|
- Playload size is restricted by `iipSizeLimit`.
|
||||||
- Cursor positioning behaves the same as for sixel (see above).
|
- size parameter is not mandatory anymore.
|
||||||
|
- Cursor gets positioned at the next cell of the last image line.
|
||||||
|
- ReportCellSize sequence is supported.
|
||||||
|
- Hi-res display support is currently limited (image gets blurred).
|
||||||
|
|
||||||
|
- **Kitty Graphics Support (TGP)**
|
||||||
|
Set by default, change it with `{kittySupport: true}`.
|
||||||
|
|
||||||
|
Note that the kitty graphics support is still WIP.
|
||||||
|
|
||||||
|
|
||||||
### Storage and Drawing Settings
|
### Storage and Drawing Settings
|
||||||
@@ -127,6 +132,7 @@ The addon exposes two properties to interact with the storage limits at runtime:
|
|||||||
By default the addon will show a placeholder pattern for evicted images that are still part
|
By default the addon will show a placeholder pattern for evicted images that are still part
|
||||||
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
|
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
|
||||||
|
|
||||||
|
|
||||||
### Image Data Retrieval
|
### Image Data Retrieval
|
||||||
|
|
||||||
The addon provides the following API endpoints to retrieve raw image data as canvas:
|
The addon provides the following API endpoints to retrieve raw image data as canvas:
|
||||||
@@ -140,11 +146,12 @@ The addon provides the following API endpoints to retrieve raw image data as can
|
|||||||
The buffer position is the 0-based absolute index (including scrollback at top).
|
The buffer position is the 0-based absolute index (including scrollback at top).
|
||||||
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
|
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
|
||||||
|
|
||||||
|
|
||||||
### Memory Usage
|
### Memory Usage
|
||||||
|
|
||||||
The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps:
|
The addon does most image processing in JavaScript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, let's look at the data flow and processing steps:
|
||||||
- Incomming image data chunk at `term.write` (terminal)
|
- Incoming image data chunk at `term.write` (terminal)
|
||||||
`term.write` might stock up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
|
`term.write` might stack up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
|
||||||
- Sequence Parser (terminal)
|
- Sequence Parser (terminal)
|
||||||
The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB).
|
The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB).
|
||||||
- Sequence Handler - Chunk Decoding (addon)
|
- Sequence Handler - Chunk Decoding (addon)
|
||||||
@@ -170,18 +177,18 @@ const totalActive = storageBytes + decodingBytes;
|
|||||||
```
|
```
|
||||||
|
|
||||||
Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions.
|
Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions.
|
||||||
Further note that the formulas above do not respect the Javascript object's overhead. Compared to the raw buffer needs the book keeping by these objects is rather small (<<5%).
|
Further note that the formulas above do not respect the JavaScript object's overhead. Compared to the raw buffer needs the bookkeeping by these objects is rather small (<<5%).
|
||||||
|
|
||||||
_Why should I care about memory usage at all?_
|
_Why should I care about memory usage at all?_
|
||||||
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the Javascript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
|
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the JavaScript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
|
||||||
|
|
||||||
_How can I adjust the memory usage?_
|
_How can I adjust the memory usage?_
|
||||||
- `pixelLimit`
|
- `pixelLimit`
|
||||||
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page.
|
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage only peaks temporarily, a rather high value should work even with multiple terminals in one page.
|
||||||
- `storageLimit`
|
- `storageLimit`
|
||||||
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for memory peaking for decoding.
|
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for peak memory use during decoding.
|
||||||
- `sixelSizeLimit`
|
- `sixelSizeLimit | iipSizeLimit | kittySizeLimit`
|
||||||
A constructor setting. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken SIXEL sequences at an earlier phase, thus may stop the decoder from entering its memory intensive task for potentially invalid data.
|
Constructor settings. This has only a small impact on peak memory during decoding. It is meant to avoid processing of overly big or broken image sequences at an earlier phase, thus may stop the invoked decoders from entering memory intensive tasks for potentially invalid data.
|
||||||
|
|
||||||
|
|
||||||
### Terminal Interaction
|
### Terminal Interaction
|
||||||
@@ -211,23 +218,5 @@ _How can I adjust the memory usage?_
|
|||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
- Sixel support and image handling in xterm.js is considered beta quality.
|
- Sixel and IIP support and image handling in xterm.js is considered beta quality.
|
||||||
- IIP support is in alpha stage. Please file a bug for any awkwardities.
|
- Kitty support is in alpha stage. Please file a bug for any awkwardities or missing features.
|
||||||
|
|
||||||
|
|
||||||
### Changelog
|
|
||||||
|
|
||||||
- 0.5.0 integrate with xtermjs base repo (at v0.4.3)
|
|
||||||
- 0.4.3 defer canvas creation
|
|
||||||
- 0.4.2 fix image canvas resize
|
|
||||||
- 0.4.1 compat release for xterm.js 5.2.0
|
|
||||||
- 0.4.0 IIP support
|
|
||||||
- 0.3.1 compat release for xterm.js 5.1.0
|
|
||||||
- 0.3.0 important change: worker removed from addon
|
|
||||||
- 0.2.0 compat release for xterm.js 5.0.0
|
|
||||||
- 0.1.3 bugfix: avoid striping
|
|
||||||
- 0.1.2 bugfix: reset clear flag
|
|
||||||
- 0.1.1 bugfixes:
|
|
||||||
- clear sticky image tiles on render
|
|
||||||
- create folder from bootstrap.sh
|
|
||||||
- fix peer dependency in package.json
|
|
||||||
|
|||||||
Binary file not shown.
@@ -25,6 +25,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"sixel": "^0.16.0",
|
"sixel": "^0.16.0",
|
||||||
"xterm-wasm-parts": "^0.3.0"
|
"xterm-wasm-parts": "^0.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { ImageRenderer } from './ImageRenderer';
|
|||||||
import { IIPImageStorage } from './IIPImageStorage';
|
import { IIPImageStorage } from './IIPImageStorage';
|
||||||
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||||
|
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
|
||||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||||
|
|
||||||
// Local const enum mirror - esbuild can't inline const enums from external packages
|
// Local const enum mirror - esbuild can't inline const enums from external packages
|
||||||
@@ -22,6 +23,7 @@ const enum DecoderConst {
|
|||||||
|
|
||||||
// default IIP header values
|
// default IIP header values
|
||||||
const DEFAULT_HEADER: IHeaderFields = {
|
const DEFAULT_HEADER: IHeaderFields = {
|
||||||
|
type: SequenceType.INVALID,
|
||||||
name: 'Unnamed file',
|
name: 'Unnamed file',
|
||||||
size: 0,
|
size: 0,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
@@ -36,7 +38,10 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
private _hp = new HeaderParser();
|
private _hp = new HeaderParser();
|
||||||
private _header: IHeaderFields = DEFAULT_HEADER;
|
private _header: IHeaderFields = DEFAULT_HEADER;
|
||||||
private _dec: Base64Decoder;
|
private _dec: Base64Decoder;
|
||||||
|
private _qoiDec: QoiDecoder;
|
||||||
private _metrics = UNSUPPORTED_TYPE;
|
private _metrics = UNSUPPORTED_TYPE;
|
||||||
|
private _isMultipart = false;
|
||||||
|
private _abortMulti = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _opts: IImageAddonOptions,
|
private readonly _opts: IImageAddonOptions,
|
||||||
@@ -47,13 +52,17 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
||||||
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
||||||
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
|
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
|
||||||
|
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {}
|
public reset(): void {
|
||||||
|
this._hp.reset();
|
||||||
|
this._dec.release();
|
||||||
|
this._qoiDec.release();
|
||||||
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
this._aborted = false;
|
this._aborted = false;
|
||||||
this._header = DEFAULT_HEADER;
|
|
||||||
this._metrics = UNSUPPORTED_TYPE;
|
this._metrics = UNSUPPORTED_TYPE;
|
||||||
this._hp.reset();
|
this._hp.reset();
|
||||||
}
|
}
|
||||||
@@ -73,15 +82,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dataPos > 0) {
|
if (dataPos > 0) {
|
||||||
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
const seqType = this._hp.fields.type;
|
||||||
if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) {
|
if (seqType === SequenceType.FILE) {
|
||||||
|
if (this._isMultipart) {
|
||||||
|
this._isMultipart = false;
|
||||||
|
this._abortMulti = false;
|
||||||
|
this._dec.release();
|
||||||
|
}
|
||||||
|
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||||
|
if (!this._header.inline) {
|
||||||
|
this._aborted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._dec.init();
|
||||||
|
} else if (this._abortMulti) {
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._dec.init();
|
|
||||||
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {
|
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {
|
||||||
this._dec.release();
|
this._dec.release();
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
|
if (this._isMultipart) this._abortMulti = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,22 +111,58 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
public end(success: boolean): boolean | Promise<boolean> {
|
public end(success: boolean): boolean | Promise<boolean> {
|
||||||
if (this._aborted) return true;
|
if (this._aborted) return true;
|
||||||
|
|
||||||
|
if (this._hp.state !== HeaderState.END) {
|
||||||
|
if (this._hp.end()) return true;
|
||||||
|
}
|
||||||
|
const seqType = this._hp.fields.type;
|
||||||
|
|
||||||
|
if (seqType === SequenceType.FILEPART) return true;
|
||||||
|
|
||||||
|
if (seqType === SequenceType.REPORTCELLSIZE) {
|
||||||
|
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
|
||||||
|
let width = CELL_SIZE_DEFAULT.width;
|
||||||
|
let height = 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;
|
||||||
|
}
|
||||||
|
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
|
||||||
|
const report = `\x1b]1337;ReportCellSize=${height.toFixed(3)};${width.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
|
||||||
|
this._coreTerminal.input(report, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seqType === SequenceType.MULTIPARTFILE) {
|
||||||
|
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||||
|
this._isMultipart = true;
|
||||||
|
this._abortMulti = false;
|
||||||
|
this._dec.release();
|
||||||
|
this._dec.init();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seqType === SequenceType.FILEEND) {
|
||||||
|
if (!this._isMultipart) return true;
|
||||||
|
this._isMultipart = false;
|
||||||
|
if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
|
||||||
|
|
||||||
let w = 0;
|
let w = 0;
|
||||||
let h = 0;
|
let h = 0;
|
||||||
|
|
||||||
// early exit condition chain
|
// early exit condition chain
|
||||||
let cond: number | boolean = true;
|
let cond: number | boolean;
|
||||||
if (cond = success) {
|
if (cond = success) {
|
||||||
if (cond = !this._dec.end()) {
|
if (cond = !this._dec.end()) {
|
||||||
if (cond = this._dec.data8.length === this._header.size) {
|
this._metrics = imageType(this._dec.data8);
|
||||||
this._metrics = imageType(this._dec.data8);
|
if (cond = this._metrics.mime !== 'unsupported') {
|
||||||
if (cond = this._metrics.mime !== 'unsupported') {
|
w = this._metrics.width;
|
||||||
w = this._metrics.width;
|
h = this._metrics.height;
|
||||||
h = this._metrics.height;
|
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
[w, h] = this._resize(w, h).map(Math.floor);
|
||||||
[w, h] = this._resize(w, h).map(Math.floor);
|
cond = w && h && w * h < this._opts.pixelLimit;
|
||||||
cond = w && h && w * h < this._opts.pixelLimit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,27 +172,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it
|
let blob: Blob | ImageData;
|
||||||
const blob = new Blob([this._dec.data8 as Uint8Array<ArrayBuffer>], { type: this._metrics.mime });
|
if (this._metrics.mime === 'image/qoi') {
|
||||||
this._dec.release();
|
const data = this._qoiDec.decode(this._dec.data8);
|
||||||
|
blob = new ImageData(
|
||||||
if (!window.createImageBitmap) {
|
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
||||||
const url = URL.createObjectURL(blob);
|
this._qoiDec.width,
|
||||||
const img = new Image();
|
this._qoiDec.height
|
||||||
return new Promise<boolean>(r => {
|
);
|
||||||
img.addEventListener('load', () => {
|
this._qoiDec.release();
|
||||||
URL.revokeObjectURL(url);
|
if (w === this._qoiDec.width && h === this._qoiDec.height) {
|
||||||
const canvas = ImageRenderer.createCanvas(window.document, w, h);
|
// use fast-path if we don't need to rescale
|
||||||
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
|
this._dec.release();
|
||||||
this._storage.addImage(canvas);
|
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
|
||||||
r(true);
|
canvas.getContext('2d')?.putImageData(blob, 0, 0);
|
||||||
});
|
this._storage.addImage(canvas);
|
||||||
img.src = url;
|
return true;
|
||||||
// sanity measure to avoid terminal blocking from dangling promise
|
}
|
||||||
// happens from corrupt data (onload never gets fired)
|
} else {
|
||||||
setTimeout(() => r(true), 1000);
|
blob = new Blob([this._dec.data8], { type: this._metrics.mime });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
this._dec.release();
|
||||||
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
||||||
.then(bm => {
|
.then(bm => {
|
||||||
this._storage.addImage(bm);
|
this._storage.addImage(bm);
|
||||||
@@ -165,8 +222,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
|
|
||||||
private _dim(s: string, total: number, cdim: number): number {
|
private _dim(s: string, total: number, cdim: number): number {
|
||||||
if (s === 'auto') return 0;
|
if (s === 'auto') return 0;
|
||||||
if (s.endsWith('%')) return parseInt(s.slice(0, -1)) * total / 100;
|
if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;
|
||||||
if (s.endsWith('px')) return parseInt(s.slice(0, -2));
|
if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);
|
||||||
return parseInt(s) * cdim;
|
return parseInt(s, 10) * cdim;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser';
|
import { HeaderParser, HeaderState, IHeaderFields, SequenceType } from './IIPHeaderParser';
|
||||||
|
|
||||||
|
|
||||||
const CASES: [string, IHeaderFields][] = [
|
const CASES: [string, IHeaderFields][] = [
|
||||||
['File=size=123456;name=dGVzdA==:', {name: 'test', size: 123456}],
|
['File=size=123456;name=dGVzdA==:', {type: SequenceType.FILE, name: 'test', size: 123456}],
|
||||||
['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}],
|
['File=size=123456;name=dGVzdA:', {type: SequenceType.FILE, name: 'test', size: 123456}],
|
||||||
// utf-8 encoding in name
|
// utf-8 encoding in name
|
||||||
['File=size=123456;name=w7xtbMOkdXTDnw==:', {name: 'ümläutß', size: 123456}],
|
['File=size=123456;name=w7xtbMOkdXTDnw==:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
|
||||||
['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}],
|
['File=size=123456;name=w7xtbMOkdXTDnw:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
|
||||||
// full header spec
|
// full header spec
|
||||||
[
|
[
|
||||||
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||||
{
|
{
|
||||||
|
type: SequenceType.FILE,
|
||||||
inline: 1,
|
inline: 1,
|
||||||
width: '10px',
|
width: '10px',
|
||||||
height: '20%',
|
height: '20%',
|
||||||
@@ -28,6 +29,7 @@ const CASES: [string, IHeaderFields][] = [
|
|||||||
[
|
[
|
||||||
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||||
{
|
{
|
||||||
|
type: SequenceType.FILE,
|
||||||
inline: 1,
|
inline: 1,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
height: '20',
|
height: '20',
|
||||||
@@ -101,7 +103,7 @@ describe('IIPHeaderParser', () => {
|
|||||||
let res = hp.parse(inp, 0, inp.length);
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
assert.strictEqual(res, inp.length);
|
assert.strictEqual(res, inp.length);
|
||||||
assert.strictEqual(hp.state, HeaderState.END);
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
assert.deepEqual(hp.fields, {name: 'test', size: 0});
|
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: 'test', size: 0});
|
||||||
hp.reset();
|
hp.reset();
|
||||||
inp = fromBs(CASES[0][0]);
|
inp = fromBs(CASES[0][0]);
|
||||||
res = hp.parse(inp, 0, inp.length);
|
res = hp.parse(inp, 0, inp.length);
|
||||||
@@ -115,7 +117,7 @@ describe('IIPHeaderParser', () => {
|
|||||||
let res = hp.parse(inp, 0, inp.length);
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
assert.strictEqual(res, inp.length);
|
assert.strictEqual(res, inp.length);
|
||||||
assert.strictEqual(hp.state, HeaderState.END);
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
assert.deepEqual(hp.fields, {name: '', size: 123456});
|
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: '', size: 123456});
|
||||||
hp.reset();
|
hp.reset();
|
||||||
inp = fromBs(CASES[0][0]);
|
inp = fromBs(CASES[0][0]);
|
||||||
res = hp.parse(inp, 0, inp.length);
|
res = hp.parse(inp, 0, inp.length);
|
||||||
@@ -135,4 +137,124 @@ describe('IIPHeaderParser', () => {
|
|||||||
assert.strictEqual(hp.state, HeaderState.END);
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
assert.deepEqual(hp.fields, CASES[0][1]);
|
assert.deepEqual(hp.fields, CASES[0][1]);
|
||||||
});
|
});
|
||||||
|
it('FilePart sequence', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
|
||||||
|
const res = hp.parse(inp, 0, inp.length);
|
||||||
|
assert.strictEqual(res, 9);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
|
||||||
|
});
|
||||||
|
it('FilePart sequence - bytewise', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
|
||||||
|
let res = -2;
|
||||||
|
let pos = 0;
|
||||||
|
while (res === -2 && pos < inp.length) {
|
||||||
|
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||||
|
}
|
||||||
|
assert.strictEqual(res, 1);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
|
||||||
|
});
|
||||||
|
it('MultipartFile sequence', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
|
||||||
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.VALUE);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(
|
||||||
|
hp.fields,
|
||||||
|
{
|
||||||
|
type: SequenceType.MULTIPARTFILE,
|
||||||
|
inline: 1,
|
||||||
|
width: '10px',
|
||||||
|
height: '20%',
|
||||||
|
preserveAspectRatio: 1,
|
||||||
|
size: 123456,
|
||||||
|
name: 'ümläutß'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('MultipartFile sequence - bytewise', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
|
||||||
|
let res = -2;
|
||||||
|
let pos = 0;
|
||||||
|
while (res === -2 && pos < inp.length) {
|
||||||
|
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||||
|
}
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.VALUE);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(
|
||||||
|
hp.fields,
|
||||||
|
{
|
||||||
|
type: SequenceType.MULTIPARTFILE,
|
||||||
|
inline: 1,
|
||||||
|
width: '10px',
|
||||||
|
height: '20%',
|
||||||
|
preserveAspectRatio: 1,
|
||||||
|
size: 123456,
|
||||||
|
name: 'ümläutß'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('FileEnd sequence', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('FileEnd');
|
||||||
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.START);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
|
||||||
|
});
|
||||||
|
it('FileEnd sequence - bytewise', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('FileEnd');
|
||||||
|
let res = -2;
|
||||||
|
let pos = 0;
|
||||||
|
while (res === -2 && pos < inp.length) {
|
||||||
|
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||||
|
}
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.START);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
|
||||||
|
});
|
||||||
|
it('ReportCellSize sequence', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('ReportCellSize');
|
||||||
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.START);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
|
||||||
|
});
|
||||||
|
it('ReportCellSize sequence - bytewise', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('ReportCellSize');
|
||||||
|
let res = -2;
|
||||||
|
let pos = 0;
|
||||||
|
while (res === -2 && pos < inp.length) {
|
||||||
|
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||||
|
}
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.START);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,27 @@
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
declare const Buffer: any;
|
declare const Buffer: any;
|
||||||
|
|
||||||
|
export const enum HeaderState {
|
||||||
|
START = 0,
|
||||||
|
ABORT = 1,
|
||||||
|
KEY = 2,
|
||||||
|
VALUE = 3,
|
||||||
|
END = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum SequenceType {
|
||||||
|
INVALID = 0,
|
||||||
|
FILE = 1,
|
||||||
|
MULTIPARTFILE = 2,
|
||||||
|
FILEPART = 3,
|
||||||
|
FILEEND = 4,
|
||||||
|
REPORTCELLSIZE = 5
|
||||||
|
}
|
||||||
|
|
||||||
export interface IHeaderFields {
|
export interface IHeaderFields {
|
||||||
[key: string]: number | string | Uint32Array | null | undefined;
|
[key: string]: number | string | Uint32Array | null | undefined;
|
||||||
|
// sequence type
|
||||||
|
type: SequenceType;
|
||||||
// base-64 encoded filename. Defaults to "Unnamed file".
|
// base-64 encoded filename. Defaults to "Unnamed file".
|
||||||
name: string;
|
name: string;
|
||||||
// File size in bytes. The file transfer will be canceled if this size is exceeded.
|
// File size in bytes. The file transfer will be canceled if this size is exceeded.
|
||||||
@@ -29,14 +47,6 @@ export interface IHeaderFields {
|
|||||||
inline?: number;
|
inline?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum HeaderState {
|
|
||||||
START = 0,
|
|
||||||
ABORT = 1,
|
|
||||||
KEY = 2,
|
|
||||||
VALUE = 3,
|
|
||||||
END = 4
|
|
||||||
}
|
|
||||||
|
|
||||||
// field value decoders
|
// field value decoders
|
||||||
|
|
||||||
// ASCII bytes to string
|
// ASCII bytes to string
|
||||||
@@ -92,7 +102,19 @@ const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// sequence type markers
|
||||||
|
// File
|
||||||
const FILE_MARKER = [70, 105, 108, 101];
|
const FILE_MARKER = [70, 105, 108, 101];
|
||||||
|
// MultipartFile
|
||||||
|
const MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];
|
||||||
|
// FilePart
|
||||||
|
const FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];
|
||||||
|
// FileEnd
|
||||||
|
const FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];
|
||||||
|
// ReportCellSize
|
||||||
|
const REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];
|
||||||
|
|
||||||
|
// max allowed chars for sequence header
|
||||||
const MAX_FIELDCHARS = 1024;
|
const MAX_FIELDCHARS = 1024;
|
||||||
|
|
||||||
|
|
||||||
@@ -111,12 +133,43 @@ export class HeaderParser {
|
|||||||
this._key = '';
|
this._key = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public end(): number {
|
||||||
|
if (this.state === HeaderState.START) {
|
||||||
|
if (this._position === FILEEND_MARKER.length) {
|
||||||
|
for (let k = 0; k < FILEEND_MARKER.length; ++k) {
|
||||||
|
if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.FILEEND;
|
||||||
|
this.state = HeaderState.END;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (this._position === REPORTCELLSIZE_MARKER.length) {
|
||||||
|
for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {
|
||||||
|
if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.REPORTCELLSIZE;
|
||||||
|
this.state = HeaderState.END;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this._a();
|
||||||
|
}
|
||||||
|
if (this.state === HeaderState.END) return 0;
|
||||||
|
if (this.state === HeaderState.VALUE
|
||||||
|
&& this.fields.type === SequenceType.MULTIPARTFILE
|
||||||
|
) {
|
||||||
|
if (!this._storeValue(this._position)) return this._a();
|
||||||
|
this.state = HeaderState.END;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this._a();
|
||||||
|
}
|
||||||
|
|
||||||
public parse(data: Uint32Array, start: number, end: number): number {
|
public parse(data: Uint32Array, start: number, end: number): number {
|
||||||
let state = this.state;
|
let state = this.state;
|
||||||
let pos = this._position;
|
let pos = this._position;
|
||||||
const buffer = this._buffer;
|
const buffer = this._buffer;
|
||||||
if (state === HeaderState.ABORT || state === HeaderState.END) return -1;
|
if (state === HeaderState.ABORT || state === HeaderState.END) return -1;
|
||||||
if (state === HeaderState.START && pos > 6) return -1;
|
if (state === HeaderState.START && pos > 14) return -1;
|
||||||
for (let i = start; i < end; ++i) {
|
for (let i = start; i < end; ++i) {
|
||||||
const c = data[i];
|
const c = data[i];
|
||||||
switch (c) {
|
switch (c) {
|
||||||
@@ -127,8 +180,29 @@ export class HeaderParser {
|
|||||||
break;
|
break;
|
||||||
case 61: // =
|
case 61: // =
|
||||||
if (state === HeaderState.START) {
|
if (state === HeaderState.START) {
|
||||||
for (let k = 0; k < FILE_MARKER.length; ++k) {
|
if (buffer[0] === 70) {
|
||||||
if (buffer[k] !== FILE_MARKER[k]) return this._a();
|
// 'File' or 'FilePart'
|
||||||
|
let k = 0;
|
||||||
|
for (; k < FILE_MARKER.length; ++k) {
|
||||||
|
if (buffer[k] !== FILE_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.FILE;
|
||||||
|
if (pos === FILEPART_MARKER.length) {
|
||||||
|
for (; k < FILEPART_MARKER.length; ++k) {
|
||||||
|
if (buffer[k] !== FILEPART_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.FILEPART;
|
||||||
|
this.state = HeaderState.END;
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
} else if (buffer[0] === 77) {
|
||||||
|
// 'MultipartFile'
|
||||||
|
for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {
|
||||||
|
if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.MULTIPARTFILE;
|
||||||
|
} else {
|
||||||
|
return this._a();
|
||||||
}
|
}
|
||||||
state = HeaderState.KEY;
|
state = HeaderState.KEY;
|
||||||
pos = 0;
|
pos = 0;
|
||||||
@@ -158,6 +232,7 @@ export class HeaderParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _a(): number {
|
private _a(): number {
|
||||||
|
this.fields.type = SequenceType.INVALID;
|
||||||
this.state = HeaderState.ABORT;
|
this.state = HeaderState.ABORT;
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { IAddImageOpts } from './Types';
|
||||||
import { ImageStorage } from './ImageStorage';
|
import { ImageStorage } from './ImageStorage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +13,7 @@ import { ImageStorage } from './ImageStorage';
|
|||||||
* - Always uses scrolling mode (cursor advances with image)
|
* - Always uses scrolling mode (cursor advances with image)
|
||||||
*/
|
*/
|
||||||
export class IIPImageStorage {
|
export class IIPImageStorage {
|
||||||
|
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _storage: ImageStorage
|
private readonly _storage: ImageStorage
|
||||||
) {}
|
) {}
|
||||||
@@ -21,6 +23,6 @@ export class IIPImageStorage {
|
|||||||
* Always uses scrolling mode — cursor advances past the image.
|
* Always uses scrolling mode — cursor advances past the image.
|
||||||
*/
|
*/
|
||||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||||
this._storage.addImage(img, true);
|
this._storage.addImage(img, this._addImageOpts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const TEST_IMAGES: [string, IMetrics][] = [
|
|||||||
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
|
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
|
||||||
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
|
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
|
||||||
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
|
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
|
||||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }]
|
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
|
||||||
|
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
|
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
|
||||||
|
|
||||||
export interface IMetrics {
|
export interface IMetrics {
|
||||||
mime: ImageType;
|
mime: ImageType;
|
||||||
@@ -45,6 +45,14 @@ export function imageType(d: Uint8Array): IMetrics {
|
|||||||
height: d[9] << 8 | d[8]
|
height: d[9] << 8 | d[8]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// 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]
|
||||||
|
};
|
||||||
|
}
|
||||||
return UNSUPPORTED_TYPE;
|
return UNSUPPORTED_TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
|
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
|
||||||
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
|
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
|
||||||
|
import { Emitter, type IEvent } from 'common/Event';
|
||||||
import { IIPHandler } from './IIPHandler';
|
import { IIPHandler } from './IIPHandler';
|
||||||
import { ImageRenderer } from './ImageRenderer';
|
import { ImageRenderer } from './ImageRenderer';
|
||||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||||
@@ -15,20 +16,61 @@ import { SixelImageStorage } from './SixelImageStorage';
|
|||||||
import { IIPImageStorage } from './IIPImageStorage';
|
import { IIPImageStorage } from './IIPImageStorage';
|
||||||
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document VT features provided by this addon.
|
||||||
|
*
|
||||||
|
* @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
|
||||||
|
*
|
||||||
|
* Sixel support is provided by the addon @xterm/addon-image with these limitations:
|
||||||
|
* - immediate coloring (no shared palette, allows high color settings of `img2sixel`)
|
||||||
|
* - max. palette size of 4096 colors
|
||||||
|
* - max. pixel width of 16K
|
||||||
|
* - max. 25 MB per sixel sequence
|
||||||
|
* - VT340 cursor positioning (begin of last sixel data row)
|
||||||
|
*
|
||||||
|
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands."
|
||||||
|
*
|
||||||
|
* Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with
|
||||||
|
* the following limitations:
|
||||||
|
* - sequence:
|
||||||
|
* - format: `OSC 1337 ; File=inline=1 ; size=<unencoded size> ; ... : <base64 payload> BEL`
|
||||||
|
* - size param must be set and payload may not exceed CEIL(size * 4 / 3)
|
||||||
|
* - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,
|
||||||
|
* no separator bytes allowed)
|
||||||
|
* - supported params: size, name, width, height, preserveAspectRatio
|
||||||
|
* - image formats: PNG, JPEG and GIF
|
||||||
|
* - no animation support (renders first image of a GIF)
|
||||||
|
* - no multipart support
|
||||||
|
* - VT340 cursor positioning (begin of last sixel data row)
|
||||||
|
*
|
||||||
|
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)
|
||||||
|
* and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol."
|
||||||
|
*
|
||||||
|
* Kitty graphics support is provided by the addon @xterm/addon-image.
|
||||||
|
* Note that while basic image output already works, this is still work in progress.
|
||||||
|
*/
|
||||||
|
|
||||||
// default values of addon ctor options
|
// default values of addon ctor options
|
||||||
const DEFAULT_OPTIONS: IImageAddonOptions = {
|
const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||||
enableSizeReports: true,
|
enableSizeReports: true,
|
||||||
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
|
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
|
||||||
sixelSupport: true,
|
sixelSupport: true,
|
||||||
sixelScrolling: true,
|
sixelScrolling: true,
|
||||||
sixelPaletteLimit: 256,
|
sixelPaletteLimit: 4096,
|
||||||
sixelSizeLimit: 25000000,
|
sixelSizeLimit: 33554432,
|
||||||
storageLimit: 128,
|
storageLimit: 128,
|
||||||
showPlaceholder: true,
|
showPlaceholder: true,
|
||||||
iipSupport: true,
|
iipSupport: true,
|
||||||
iipSizeLimit: 20000000,
|
iipSizeLimit: 33554432,
|
||||||
kittySupport: true,
|
kittySupport: true,
|
||||||
kittySizeLimit: 20000000
|
kittySizeLimit: 33554432
|
||||||
};
|
};
|
||||||
|
|
||||||
// max palette size supported by the sixel lib (compile time setting)
|
// max palette size supported by the sixel lib (compile time setting)
|
||||||
@@ -62,6 +104,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
private _disposables: IDisposable[] = [];
|
private _disposables: IDisposable[] = [];
|
||||||
private _terminal: ITerminalExt | undefined;
|
private _terminal: ITerminalExt | undefined;
|
||||||
private _handlers: Map<String, IResetHandler> = new Map();
|
private _handlers: Map<String, IResetHandler> = new Map();
|
||||||
|
private readonly _onImageAdded = new Emitter<void>();
|
||||||
|
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
|
||||||
|
|
||||||
constructor(opts?: Partial<IImageAddonOptions>) {
|
constructor(opts?: Partial<IImageAddonOptions>) {
|
||||||
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
|
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
|
||||||
@@ -74,6 +118,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
}
|
}
|
||||||
this._disposables.length = 0;
|
this._disposables.length = 0;
|
||||||
this._handlers.clear();
|
this._handlers.clear();
|
||||||
|
this._onImageAdded.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _disposeLater(...args: IDisposable[]): void {
|
private _disposeLater(...args: IDisposable[]): void {
|
||||||
@@ -88,14 +133,10 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
// internal data structures
|
// internal data structures
|
||||||
this._renderer = new ImageRenderer(terminal);
|
this._renderer = new ImageRenderer(terminal);
|
||||||
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
|
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
|
||||||
|
this._storage.onImageAdded = () => this._onImageAdded.fire();
|
||||||
|
|
||||||
// enable size reports
|
// enable size reports
|
||||||
if (this._opts.enableSizeReports) {
|
if (this._opts.enableSizeReports) {
|
||||||
// const windowOptions = terminal.getOption('windowOptions');
|
|
||||||
// windowOptions.getWinSizePixels = true;
|
|
||||||
// windowOptions.getCellSizePixels = true;
|
|
||||||
// windowOptions.getWinSizeChars = true;
|
|
||||||
// terminal.setOption('windowOptions', windowOptions);
|
|
||||||
const windowOps = terminal.options.windowOptions ?? {};
|
const windowOps = terminal.options.windowOptions ?? {};
|
||||||
windowOps.getWinSizePixels = true;
|
windowOps.getWinSizePixels = true;
|
||||||
windowOps.getCellSizePixels = true;
|
windowOps.getCellSizePixels = true;
|
||||||
@@ -161,7 +202,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
this._disposeLater(
|
this._disposeLater(
|
||||||
kittyStorage,
|
kittyStorage,
|
||||||
kittyHandler,
|
kittyHandler,
|
||||||
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
|
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +255,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _report(s: string): void {
|
private _report(s: string): void {
|
||||||
this._terminal?._core.coreService.triggerDataEvent(s);
|
this._terminal?._core.input(s, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _decset(params: (number | number[])[]): boolean {
|
private _decset(params: (number | number[])[]): boolean {
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { IDisposable } from '@xterm/xterm';
|
|||||||
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||||
|
|
||||||
const PLACEHOLDER_LENGTH = 4096;
|
const enum Constants {
|
||||||
const PLACEHOLDER_HEIGHT = 24;
|
PLACEHOLDER_LENGTH = 4096,
|
||||||
|
PLACEHOLDER_HEIGHT = 24
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageRenderer - terminal frontend extension:
|
* ImageRenderer - terminal frontend extension:
|
||||||
@@ -110,7 +112,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
public showPlaceholder(value: boolean): void {
|
public showPlaceholder(value: boolean): void {
|
||||||
if (value) {
|
if (value) {
|
||||||
if (!this._placeholder && this.cellSize.height !== -1) {
|
if (!this._placeholder && this.cellSize.height !== -1) {
|
||||||
this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
|
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this._placeholderBitmap?.close();
|
this._placeholderBitmap?.close();
|
||||||
@@ -144,7 +146,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
public clearLines(start: number, end: number, layer?: ImageLayer): void {
|
public clearLines(start: number, end: number, layer?: ImageLayer): void {
|
||||||
const y = start * (this.dimensions?.css.cell.height || 0);
|
const y = start * (this.dimensions?.css.cell.height || 0);
|
||||||
const w = this.dimensions?.css.canvas.width || 0;
|
const w = this.dimensions?.css.canvas.width || 0;
|
||||||
const h = (++end - start) * (this.dimensions?.css.cell.height || 0);
|
const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);
|
||||||
if (!layer || layer === 'top') {
|
if (!layer || layer === 'top') {
|
||||||
this._layers.get('top')?.clearRect(0, y, w, h);
|
this._layers.get('top')?.clearRect(0, y, w, h);
|
||||||
}
|
}
|
||||||
@@ -249,7 +251,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this._placeholder) {
|
if (!this._placeholder) {
|
||||||
this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
|
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
|
||||||
} else if (height >= this._placeholder!.height) {
|
} else if (height >= this._placeholder!.height) {
|
||||||
this._createPlaceHolder(height + 1);
|
this._createPlaceHolder(height + 1);
|
||||||
}
|
}
|
||||||
@@ -358,7 +360,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
canvas.style.zIndex = '0';
|
canvas.style.zIndex = '0';
|
||||||
screenElement.appendChild(canvas);
|
screenElement.appendChild(canvas);
|
||||||
}
|
}
|
||||||
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
canvas.remove();
|
canvas.remove();
|
||||||
return;
|
return;
|
||||||
@@ -379,7 +381,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
return this._layers.has(layer);
|
return this._layers.has(layer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
|
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
|
||||||
this._placeholderBitmap?.close();
|
this._placeholderBitmap?.close();
|
||||||
this._placeholderBitmap = undefined;
|
this._placeholderBitmap = undefined;
|
||||||
|
|
||||||
@@ -403,7 +405,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
ctx.putImageData(imgData, 0, 0);
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
|
||||||
// create placeholder line, width aligned to blueprint width
|
// create placeholder line, width aligned to blueprint width
|
||||||
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
|
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;
|
||||||
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
|
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
|
||||||
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
|
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
|
||||||
if (!ctx2) {
|
if (!ctx2) {
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
|
|
||||||
import { IDisposable } from '@xterm/xterm';
|
import { IDisposable } from '@xterm/xterm';
|
||||||
import { ImageRenderer } from './ImageRenderer';
|
import { ImageRenderer } from './ImageRenderer';
|
||||||
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types';
|
import {
|
||||||
|
ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,
|
||||||
|
IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,
|
||||||
|
UnderlineStyle, IAddImageOpts
|
||||||
|
} from './Types';
|
||||||
|
|
||||||
|
|
||||||
// fallback default cell size
|
// fallback default cell size
|
||||||
@@ -124,6 +128,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
private _pixelLimit: number = 2500000;
|
private _pixelLimit: number = 2500000;
|
||||||
|
|
||||||
private _viewportMetrics: { cols: number, rows: number };
|
private _viewportMetrics: { cols: number, rows: number };
|
||||||
|
public onImageAdded: (() => void) | undefined;
|
||||||
public onImageDeleted: ((storageId: number) => void) | undefined;
|
public onImageDeleted: ((storageId: number) => void) | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -234,13 +239,15 @@ export class ImageStorage implements IDisposable {
|
|||||||
/**
|
/**
|
||||||
* Method to add an image to the storage.
|
* Method to add an image to the storage.
|
||||||
* @param img - The image to add (canvas or bitmap).
|
* @param img - The image to add (canvas or bitmap).
|
||||||
* @param scrolling - When true, cursor advances with the image (lineFeed per row).
|
* @param opts - Options for addImage:
|
||||||
* When false, image is placed at (0,0) and cursor is restored (DECSET 80 / sixel origin mode).
|
* - scrolling: When true, cursor advances with the image.
|
||||||
* @param layer - Which canvas layer to render on ('top' or 'bottom').
|
* When false, image is placed at ORIGIN and cursor does not move.
|
||||||
* @param zIndex - Z-index for image layering within the same layer.
|
* - layer: Which canvas layer to render on ('top' or 'bottom').
|
||||||
|
* - zIndex: Z-index for image layering within the same layer.
|
||||||
|
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
|
||||||
* @returns The internal image ID assigned to the stored image.
|
* @returns The internal image ID assigned to the stored image.
|
||||||
*/
|
*/
|
||||||
public addImage(img: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer = 'top', zIndex: number = 0): number {
|
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
|
||||||
// never allow storage to exceed memory limit
|
// never allow storage to exceed memory limit
|
||||||
this._evictOldest(img.width * img.height);
|
this._evictOldest(img.width * img.height);
|
||||||
|
|
||||||
@@ -262,7 +269,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
let offset = originX;
|
let offset = originX;
|
||||||
let tileCount = 0;
|
let tileCount = 0;
|
||||||
|
|
||||||
if (!scrolling) {
|
if (!opts.scrolling) {
|
||||||
buffer.x = 0;
|
buffer.x = 0;
|
||||||
buffer.y = 0;
|
buffer.y = 0;
|
||||||
offset = 0;
|
offset = 0;
|
||||||
@@ -276,7 +283,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
|
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
|
||||||
tileCount++;
|
tileCount++;
|
||||||
}
|
}
|
||||||
if (scrolling) {
|
if (opts.scrolling) {
|
||||||
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
|
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
|
||||||
} else {
|
} else {
|
||||||
if (++buffer.y >= termRows) break;
|
if (++buffer.y >= termRows) break;
|
||||||
@@ -286,8 +293,12 @@ export class ImageStorage implements IDisposable {
|
|||||||
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
||||||
|
|
||||||
// cursor positioning modes
|
// cursor positioning modes
|
||||||
if (scrolling) {
|
if (opts.scrolling) {
|
||||||
buffer.x = offset;
|
if (opts.cursorPos === 'iip') {
|
||||||
|
buffer.x = Math.min(offset + cols, termCols);
|
||||||
|
} else {
|
||||||
|
buffer.x = offset;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
buffer.x = originX;
|
buffer.x = originX;
|
||||||
buffer.y = originY;
|
buffer.y = originY;
|
||||||
@@ -330,12 +341,13 @@ export class ImageStorage implements IDisposable {
|
|||||||
marker: endMarker || undefined,
|
marker: endMarker || undefined,
|
||||||
tileCount,
|
tileCount,
|
||||||
bufferType: this._terminal.buffer.active.type,
|
bufferType: this._terminal.buffer.active.type,
|
||||||
layer,
|
layer: opts.layer,
|
||||||
zIndex
|
zIndex: opts.zIndex
|
||||||
};
|
};
|
||||||
|
|
||||||
// finally add the image
|
// finally add the image
|
||||||
this._images.set(imageId, imgSpec);
|
this._images.set(imageId, imgSpec);
|
||||||
|
this.onImageAdded?.();
|
||||||
return imageId;
|
return imageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,7 +465,6 @@ export class ImageStorage implements IDisposable {
|
|||||||
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
e = nextE;
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
col--;
|
col--;
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
|||||||
const height = this._dec.height;
|
const height = this._dec.height;
|
||||||
|
|
||||||
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
||||||
if (!width || ! height) {
|
if (!width || !height) {
|
||||||
if (height) {
|
if (height) {
|
||||||
this._storage.advanceCursor(height);
|
this._storage.advanceCursor(height);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||||
import { IImageAddonOptions, ITerminalExt } from './Types';
|
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';
|
||||||
import { ImageRenderer } from './ImageRenderer';
|
import { ImageRenderer } from './ImageRenderer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +15,7 @@ import { ImageRenderer } from './ImageRenderer';
|
|||||||
* - advanceCursor for empty sixels carrying only height
|
* - advanceCursor for empty sixels carrying only height
|
||||||
*/
|
*/
|
||||||
export class SixelImageStorage {
|
export class SixelImageStorage {
|
||||||
|
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _storage: ImageStorage,
|
private readonly _storage: ImageStorage,
|
||||||
private readonly _opts: IImageAddonOptions,
|
private readonly _opts: IImageAddonOptions,
|
||||||
@@ -27,7 +28,8 @@ export class SixelImageStorage {
|
|||||||
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
|
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
|
||||||
*/
|
*/
|
||||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||||
this._storage.addImage(img, this._opts.sixelScrolling);
|
this._addImageOpts.scrolling = this._opts.sixelScrolling;
|
||||||
|
this._storage.addImage(img, this._addImageOpts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
|
|||||||
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
|
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
|
||||||
import type { AttributeData } from 'common/buffer/AttributeData';
|
import type { AttributeData } from 'common/buffer/AttributeData';
|
||||||
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||||
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
|
import type { IInputHandler } from 'common/Types';
|
||||||
|
import type { IBufferLine, IExtendedAttrs } from 'common/buffer/Types';
|
||||||
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||||
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||||
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||||
@@ -100,6 +101,14 @@ export interface ICellSize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ImageLayer = 'top' | 'bottom';
|
export type ImageLayer = 'top' | 'bottom';
|
||||||
|
export type CursorPos = 'vt340' | 'iip';
|
||||||
|
|
||||||
|
export interface IAddImageOpts {
|
||||||
|
scrolling: boolean;
|
||||||
|
layer: ImageLayer;
|
||||||
|
zIndex: number;
|
||||||
|
cursorPos: CursorPos;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IImageSpec {
|
export interface IImageSpec {
|
||||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||||
|
|||||||
@@ -16,24 +16,23 @@ import {
|
|||||||
IKittyCommand,
|
IKittyCommand,
|
||||||
IPendingTransmission,
|
IPendingTransmission,
|
||||||
IKittyImageData,
|
IKittyImageData,
|
||||||
BYTES_PER_PIXEL_RGB,
|
KittyPixelConstants,
|
||||||
BYTES_PER_PIXEL_RGBA,
|
|
||||||
ALPHA_OPAQUE,
|
|
||||||
parseKittyCommand
|
parseKittyCommand
|
||||||
} from './KittyGraphicsTypes';
|
} from './KittyGraphicsTypes';
|
||||||
|
|
||||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
const enum Constants {
|
||||||
const DECODER_KEEP_DATA = 4194304;
|
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||||
const DECODER_INITIAL_DATA = 4194304; // 4MB
|
DECODER_KEEP_DATA = 4194304,
|
||||||
|
DECODER_INITIAL_DATA = 4194304, // 4MB
|
||||||
|
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||||
|
DECODER_OK = 0,
|
||||||
|
// Maximum control data size
|
||||||
|
MAX_CONTROL_DATA_SIZE = 512,
|
||||||
|
// Semicolon codepoint
|
||||||
|
SEMICOLON = 0x3B
|
||||||
|
}
|
||||||
|
|
||||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
|
||||||
const DECODER_OK: DecodeStatus.OK = 0;
|
|
||||||
|
|
||||||
// Maximum control data size
|
|
||||||
const MAX_CONTROL_DATA_SIZE = 512;
|
|
||||||
|
|
||||||
// Semicolon codepoint
|
|
||||||
const SEMICOLON = 0x3B;
|
|
||||||
|
|
||||||
// Kitty graphics protocol handler with streaming base64 decoding.
|
// Kitty graphics protocol handler with streaming base64 decoding.
|
||||||
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
|
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
|
||||||
@@ -50,7 +49,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
private _inControlData = true;
|
private _inControlData = true;
|
||||||
|
|
||||||
// Buffer for control data.
|
// Buffer for control data.
|
||||||
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
|
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
|
||||||
private _controlLength = 0;
|
private _controlLength = 0;
|
||||||
|
|
||||||
// Pre-calculated encoded size limit
|
// Pre-calculated encoded size limit
|
||||||
@@ -77,7 +76,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
// Convert decoded size limit -> max encoded bytes.
|
// Convert decoded size limit -> max encoded bytes.
|
||||||
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
||||||
// ensure we preallocate more than configured limit while using 4mb initial size.
|
// ensure we preallocate more than configured limit while using 4mb initial size.
|
||||||
this._initialEncodedBytes = Math.min(DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
@@ -129,7 +128,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
// Scan for semicolon
|
// Scan for semicolon
|
||||||
let controlEnd = end;
|
let controlEnd = end;
|
||||||
for (let i = start; i < end; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
if (data[i] === SEMICOLON) {
|
if (data[i] === Constants.SEMICOLON) {
|
||||||
this._inControlData = false;
|
this._inControlData = false;
|
||||||
controlEnd = i;
|
controlEnd = i;
|
||||||
break;
|
break;
|
||||||
@@ -138,7 +137,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
|
|
||||||
// Copy control data
|
// Copy control data
|
||||||
const copyLength = controlEnd - start;
|
const copyLength = controlEnd - start;
|
||||||
if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) {
|
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -201,7 +200,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
this._activeDecoder = pending.decoder;
|
this._activeDecoder = pending.decoder;
|
||||||
}
|
}
|
||||||
if (!this._activeDecoder) {
|
if (!this._activeDecoder) {
|
||||||
this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||||
this._activeDecoder.init();
|
this._activeDecoder.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,14 +376,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
if (cmd.id === undefined) {
|
if (cmd.id === undefined) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const image = this._kittyStorage.getImage(cmd.id);
|
const id = cmd.id;
|
||||||
|
const image = this._kittyStorage.getImage(id);
|
||||||
if (!image) {
|
if (!image) {
|
||||||
this._sendResponse(cmd.id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
|
this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const result = this._displayImage(image, cmd);
|
const result = this._displayImage(image, cmd);
|
||||||
return result.then(success => {
|
return result.then(success => {
|
||||||
this._sendResponse(cmd.id!, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
|
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -483,7 +483,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
const expectedBytes = width * height * bytesPerPixel;
|
const expectedBytes = width * height * bytesPerPixel;
|
||||||
|
|
||||||
if (bytes.length < expectedBytes) {
|
if (bytes.length < expectedBytes) {
|
||||||
@@ -511,6 +511,10 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
break;
|
break;
|
||||||
case 'i':
|
case 'i':
|
||||||
case 'I':
|
case 'I':
|
||||||
|
// TODO: When placement id tracking is implemented (see TODO in
|
||||||
|
// KittyImageStorage), d=i with p=<pid> should delete only that
|
||||||
|
// specific placement, while d=i without p should delete all
|
||||||
|
// placements for the image.
|
||||||
if (cmd.id !== undefined) {
|
if (cmd.id !== undefined) {
|
||||||
const pending = this._pendingTransmissions.get(cmd.id);
|
const pending = this._pendingTransmissions.get(cmd.id);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
@@ -529,8 +533,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
|
|
||||||
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
|
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
|
||||||
const isOk = message === 'OK';
|
const isOk = message === 'OK';
|
||||||
if (isOk && quiet === 1) return;
|
if (isOk && quiet >= 1) return;
|
||||||
if (!isOk && quiet === 2) return;
|
if (!isOk && quiet >= 2) return;
|
||||||
|
|
||||||
const pPart = placementId ? `,p=${placementId}` : '';
|
const pPart = placementId ? `,p=${placementId}` : '';
|
||||||
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
|
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
|
||||||
@@ -546,135 +550,137 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||||
let bitmap = await this._createBitmap(image);
|
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
|
||||||
|
|
||||||
const cropX = Math.max(0, cmd.x ?? 0);
|
try {
|
||||||
const cropY = Math.max(0, cmd.y ?? 0);
|
const cropX = Math.max(0, cmd.x ?? 0);
|
||||||
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
|
const cropY = Math.max(0, cmd.y ?? 0);
|
||||||
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
|
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
|
||||||
|
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
|
||||||
|
|
||||||
const maxCropW = Math.max(0, bitmap.width - cropX);
|
const maxCropW = Math.max(0, bitmap.width - cropX);
|
||||||
const maxCropH = Math.max(0, bitmap.height - cropY);
|
const maxCropH = Math.max(0, bitmap.height - cropY);
|
||||||
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
|
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
|
||||||
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
|
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
|
||||||
|
|
||||||
if (finalCropW === 0 || finalCropH === 0) {
|
if (finalCropW === 0 || finalCropH === 0) {
|
||||||
bitmap.close();
|
throw new Error('invalid source rectangle');
|
||||||
throw new Error('invalid source rectangle');
|
}
|
||||||
}
|
|
||||||
|
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
|
||||||
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
|
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
|
||||||
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
|
bitmap.close();
|
||||||
bitmap.close();
|
bitmap = cropped;
|
||||||
bitmap = cropped;
|
}
|
||||||
}
|
|
||||||
|
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||||
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||||
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
|
||||||
|
// Per spec: c/r default to image's natural cell dimensions.
|
||||||
// Per spec: c/r default to image's natural cell dimensions.
|
// If only one of c/r is specified, compute the other from image aspect ratio.
|
||||||
// If only one of c/r is specified, compute the other from image aspect ratio.
|
let imgCols: number;
|
||||||
let imgCols: number;
|
let imgRows: number;
|
||||||
let imgRows: number;
|
if (cmd.columns !== undefined && cmd.rows !== undefined) {
|
||||||
if (cmd.columns !== undefined && cmd.rows !== undefined) {
|
imgCols = cmd.columns;
|
||||||
imgCols = cmd.columns;
|
imgRows = cmd.rows;
|
||||||
imgRows = cmd.rows;
|
} else if (cmd.columns !== undefined) {
|
||||||
} else if (cmd.columns !== undefined) {
|
imgCols = cmd.columns;
|
||||||
imgCols = cmd.columns;
|
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
|
||||||
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
|
} else if (cmd.rows !== undefined) {
|
||||||
} else if (cmd.rows !== undefined) {
|
imgRows = cmd.rows;
|
||||||
imgRows = cmd.rows;
|
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
|
||||||
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
|
} else {
|
||||||
} else {
|
imgCols = Math.ceil(bitmap.width / cw);
|
||||||
imgCols = Math.ceil(bitmap.width / cw);
|
imgRows = Math.ceil(bitmap.height / ch);
|
||||||
imgRows = Math.ceil(bitmap.height / ch);
|
}
|
||||||
}
|
|
||||||
|
let w = bitmap.width;
|
||||||
let w = bitmap.width;
|
let h = bitmap.height;
|
||||||
let h = bitmap.height;
|
|
||||||
|
// Scale bitmap to fit placement rectangle when c/r are specified
|
||||||
// Scale bitmap to fit placement rectangle when c/r are specified
|
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
||||||
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
w = Math.round(imgCols * cw);
|
||||||
w = Math.round(imgCols * cw);
|
h = Math.round(imgRows * ch);
|
||||||
h = Math.round(imgRows * ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (w * h > this._opts.pixelLimit) {
|
|
||||||
bitmap.close();
|
|
||||||
throw new Error('image exceeds pixel limit');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save cursor position before addImage modifies it
|
|
||||||
const buffer = this._coreTerminal._core.buffer;
|
|
||||||
const savedX = buffer.x;
|
|
||||||
const savedY = buffer.y;
|
|
||||||
const savedYbase = buffer.ybase;
|
|
||||||
|
|
||||||
// Determine layer based on z-index: negative = behind text, 0+ = on top.
|
|
||||||
// When z<0 we always use the bottom layer even without allowTransparency —
|
|
||||||
// the image will simply be hidden behind the opaque text background, which
|
|
||||||
// is the correct behavior (client asked for "behind text").
|
|
||||||
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
|
|
||||||
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
|
|
||||||
|
|
||||||
let finalBitmap = bitmap;
|
|
||||||
if (w !== bitmap.width || h !== bitmap.height) {
|
|
||||||
finalBitmap = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
|
||||||
bitmap.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
|
|
||||||
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
|
|
||||||
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
|
|
||||||
if (xOffset !== 0 || yOffset !== 0) {
|
|
||||||
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
|
|
||||||
// total placement area remains c*cw × r*ch pixels and the offset image
|
|
||||||
// is clipped to fit. When c/r are unset, the padded canvas determines
|
|
||||||
// the natural cell dimensions.
|
|
||||||
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : finalBitmap.width + xOffset;
|
|
||||||
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : finalBitmap.height + yOffset;
|
|
||||||
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
|
|
||||||
const offsetCtx = offsetCanvas.getContext('2d');
|
|
||||||
if (!offsetCtx) {
|
|
||||||
finalBitmap.close();
|
|
||||||
throw new Error('Failed to create offset canvas context');
|
|
||||||
}
|
}
|
||||||
offsetCtx.drawImage(finalBitmap, xOffset, yOffset);
|
|
||||||
|
|
||||||
const offsetBitmap = await createImageBitmap(offsetCanvas);
|
|
||||||
offsetCanvas.width = offsetCanvas.height = 0;
|
|
||||||
finalBitmap.close();
|
|
||||||
finalBitmap = offsetBitmap;
|
|
||||||
w = finalBitmap.width;
|
|
||||||
h = finalBitmap.height;
|
|
||||||
if (w * h > this._opts.pixelLimit) {
|
if (w * h > this._opts.pixelLimit) {
|
||||||
finalBitmap.close();
|
|
||||||
throw new Error('image exceeds pixel limit');
|
throw new Error('image exceeds pixel limit');
|
||||||
}
|
}
|
||||||
if (cmd.columns === undefined) {
|
|
||||||
imgCols = Math.ceil(finalBitmap.width / cw);
|
|
||||||
}
|
|
||||||
if (cmd.rows === undefined) {
|
|
||||||
imgRows = Math.ceil(finalBitmap.height / ch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const zIndex = cmd.zIndex ?? 0;
|
// Save cursor position before addImage modifies it
|
||||||
this._kittyStorage.addImage(image.id, finalBitmap, true, layer, zIndex);
|
const buffer = this._coreTerminal._core.buffer;
|
||||||
|
const savedX = buffer.x;
|
||||||
|
const savedY = buffer.y;
|
||||||
|
const savedYbase = buffer.ybase;
|
||||||
|
|
||||||
// Kitty cursor movement
|
// Determine layer based on z-index: negative = behind text, 0+ = on top.
|
||||||
// Per spec: cursor placed at first column after last image column,
|
// When z<0 we always use the bottom layer even without allowTransparency —
|
||||||
// on the last row of the image. C=1 means don't move cursor.
|
// the image will simply be hidden behind the opaque text background, which
|
||||||
if (cmd.cursorMovement === 1) {
|
// is the correct behavior (client asked for "behind text").
|
||||||
// C=1: restore cursor to position before image was placed
|
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
|
||||||
const scrolled = buffer.ybase - savedYbase;
|
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
|
||||||
buffer.x = savedX;
|
|
||||||
// Can't restore cursor to scrollback?
|
if (w !== bitmap.width || h !== bitmap.height) {
|
||||||
buffer.y = Math.max(savedY - scrolled, 0);
|
const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||||
} else {
|
bitmap.close();
|
||||||
// Default (C=0): advance cursor horizontally past the image
|
bitmap = scaled;
|
||||||
// addImage already positioned cursor on the last row via lineFeeds
|
}
|
||||||
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
|
|
||||||
|
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
|
||||||
|
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
|
||||||
|
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
|
||||||
|
if (xOffset !== 0 || yOffset !== 0) {
|
||||||
|
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
|
||||||
|
// total placement area remains c*cw × r*ch pixels and the offset image
|
||||||
|
// is clipped to fit. When c/r are unset, the padded canvas determines
|
||||||
|
// the natural cell dimensions.
|
||||||
|
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
|
||||||
|
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
|
||||||
|
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
|
||||||
|
const offsetCtx = offsetCanvas.getContext('2d');
|
||||||
|
if (!offsetCtx) {
|
||||||
|
throw new Error('Failed to create offset canvas context');
|
||||||
|
}
|
||||||
|
offsetCtx.drawImage(bitmap, xOffset, yOffset);
|
||||||
|
|
||||||
|
const offsetBitmap = await createImageBitmap(offsetCanvas);
|
||||||
|
offsetCanvas.width = offsetCanvas.height = 0;
|
||||||
|
bitmap.close();
|
||||||
|
bitmap = offsetBitmap;
|
||||||
|
w = bitmap.width;
|
||||||
|
h = bitmap.height;
|
||||||
|
if (w * h > this._opts.pixelLimit) {
|
||||||
|
throw new Error('image exceeds pixel limit');
|
||||||
|
}
|
||||||
|
if (cmd.columns === undefined) {
|
||||||
|
imgCols = Math.ceil(bitmap.width / cw);
|
||||||
|
}
|
||||||
|
if (cmd.rows === undefined) {
|
||||||
|
imgRows = Math.ceil(bitmap.height / ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zIndex = cmd.zIndex ?? 0;
|
||||||
|
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
|
||||||
|
bitmap = undefined; // ownership transferred to storage
|
||||||
|
|
||||||
|
// Kitty cursor movement
|
||||||
|
// Per spec: cursor placed at first column after last image column,
|
||||||
|
// on the last row of the image. C=1 means don't move cursor.
|
||||||
|
if (cmd.cursorMovement === 1) {
|
||||||
|
// C=1: restore cursor to position before image was placed
|
||||||
|
const scrolled = buffer.ybase - savedYbase;
|
||||||
|
buffer.x = savedX;
|
||||||
|
// Can't restore cursor to scrollback?
|
||||||
|
buffer.y = Math.max(savedY - scrolled, 0);
|
||||||
|
} else {
|
||||||
|
// Default (C=0): advance cursor horizontally past the image
|
||||||
|
// addImage already positioned cursor on the last row via lineFeeds
|
||||||
|
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
bitmap?.close();
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,7 +722,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
throw new Error('Width and height required for raw pixel data');
|
throw new Error('Width and height required for raw pixel data');
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
const expectedBytes = width * height * bytesPerPixel;
|
const expectedBytes = width * height * bytesPerPixel;
|
||||||
|
|
||||||
if (bytes.length < expectedBytes) {
|
if (bytes.length < expectedBytes) {
|
||||||
@@ -727,13 +733,13 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
|
|
||||||
if (image.format === KittyFormat.RGBA) {
|
if (image.format === KittyFormat.RGBA) {
|
||||||
// RGBA: use bytes directly — no copy needed
|
// RGBA: use bytes directly — no copy needed
|
||||||
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * BYTES_PER_PIXEL_RGBA), width, height));
|
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));
|
||||||
}
|
}
|
||||||
|
|
||||||
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
|
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
|
||||||
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
|
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
|
||||||
// Assumes little-endian (all modern browsers/Node.js).
|
// Assumes little-endian (all modern browsers/Node.js).
|
||||||
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
|
const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
|
||||||
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
||||||
const dst32 = new Uint32Array(data.buffer);
|
const dst32 = new Uint32Array(data.buffer);
|
||||||
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
|
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
|
||||||
@@ -745,22 +751,22 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
|||||||
const b1 = src32[srcOffset++];
|
const b1 = src32[srcOffset++];
|
||||||
const b2 = src32[srcOffset++];
|
const b2 = src32[srcOffset++];
|
||||||
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
|
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
|
||||||
dst32[dstOffset++] = (b0 & 0x00FFFFFF) | 0xFF000000;
|
dst32[dstOffset++] = 0xFF000000 | b0;
|
||||||
dst32[dstOffset++] = ((b0 >>> 24) | (b1 << 8)) & 0x00FFFFFF | 0xFF000000;
|
dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
|
||||||
dst32[dstOffset++] = ((b1 >>> 16) | (b2 << 16)) & 0x00FFFFFF | 0xFF000000;
|
dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
|
||||||
dst32[dstOffset++] = (b2 >>> 8) | 0xFF000000;
|
dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle remaining 1–3 pixels
|
// Handle remaining 1–3 pixels
|
||||||
let srcByte = alignedPixels * BYTES_PER_PIXEL_RGB;
|
let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA;
|
let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
|
||||||
for (let i = alignedPixels; i < pixelCount; i++) {
|
for (let i = alignedPixels; i < pixelCount; i++) {
|
||||||
data[dstByte] = bytes[srcByte];
|
data[dstByte] = bytes[srcByte];
|
||||||
data[dstByte + 1] = bytes[srcByte + 1];
|
data[dstByte + 1] = bytes[srcByte + 1];
|
||||||
data[dstByte + 2] = bytes[srcByte + 2];
|
data[dstByte + 2] = bytes[srcByte + 2];
|
||||||
data[dstByte + 3] = ALPHA_OPAQUE;
|
data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
|
||||||
srcByte += BYTES_PER_PIXEL_RGB;
|
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
dstByte += BYTES_PER_PIXEL_RGBA;
|
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createImageBitmap(new ImageData(data, width, height));
|
return createImageBitmap(new ImageData(data, width, height));
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ export const enum KittyKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pixel format constants
|
// Pixel format constants
|
||||||
export const BYTES_PER_PIXEL_RGB = 3;
|
export const enum KittyPixelConstants {
|
||||||
export const BYTES_PER_PIXEL_RGBA = 4;
|
BYTES_PER_PIXEL_RGB = 3,
|
||||||
export const ALPHA_OPAQUE = 255;
|
BYTES_PER_PIXEL_RGBA = 4,
|
||||||
|
ALPHA_OPAQUE = 255
|
||||||
|
}
|
||||||
|
|
||||||
// Parsed Kitty graphics command.
|
// Parsed Kitty graphics command.
|
||||||
export interface IKittyCommand {
|
export interface IKittyCommand {
|
||||||
@@ -166,7 +168,7 @@ export function parseKittyCommand(data: string): IKittyCommand {
|
|||||||
cmd.deleteSelector = value;
|
cmd.deleteSelector = value;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const numValue = parseInt(value);
|
const numValue = parseInt(value, 10);
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case KittyKey.FORMAT: cmd.format = numValue; break;
|
case KittyKey.FORMAT: cmd.format = numValue; break;
|
||||||
case KittyKey.ID: cmd.id = numValue; break;
|
case KittyKey.ID: cmd.id = numValue; break;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { IDisposable } from '@xterm/xterm';
|
import { IDisposable } from '@xterm/xterm';
|
||||||
import { ImageStorage } from '../ImageStorage';
|
import { ImageStorage } from '../ImageStorage';
|
||||||
import { ImageLayer } from '../Types';
|
import { ImageLayer, IAddImageOpts } from '../Types';
|
||||||
import { IKittyImageData } from './KittyGraphicsTypes';
|
import { IKittyImageData } from './KittyGraphicsTypes';
|
||||||
|
|
||||||
// Kitty-specific image storage controller.
|
// Kitty-specific image storage controller.
|
||||||
@@ -20,6 +20,15 @@ export class KittyImageStorage implements IDisposable {
|
|||||||
|
|
||||||
private _nextImageId = 1;
|
private _nextImageId = 1;
|
||||||
private readonly _images: Map<number, IKittyImageData> = new Map();
|
private readonly _images: Map<number, IKittyImageData> = new Map();
|
||||||
|
// TODO: Support multiple placements per image. The kitty spec identifies
|
||||||
|
// placements by an (image id, placement id) pair — same i + different p
|
||||||
|
// values should coexist, and same i + same p should replace the prior
|
||||||
|
// placement. Currently we track only one storage entry per kitty image id,
|
||||||
|
// so multiple placements of the same image overwrite each other. Fixing
|
||||||
|
// this requires changing these maps to Map<number, Map<number, number>>
|
||||||
|
// (kittyId → placementId → storageId) and updating addImage/deleteById
|
||||||
|
// accordingly. The underlying shared ImageStorage would also need to
|
||||||
|
// support multiple entries per logical image.
|
||||||
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
|
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
|
||||||
private readonly _storageIdToKittyId: Map<number, number> = new Map();
|
private readonly _storageIdToKittyId: Map<number, number> = new Map();
|
||||||
|
|
||||||
@@ -33,6 +42,7 @@ export class KittyImageStorage implements IDisposable {
|
|||||||
this._images.delete(kittyId);
|
this._images.delete(kittyId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _storage: ImageStorage
|
private readonly _storage: ImageStorage
|
||||||
@@ -89,7 +99,10 @@ export class KittyImageStorage implements IDisposable {
|
|||||||
if (oldStorageId !== undefined) {
|
if (oldStorageId !== undefined) {
|
||||||
this._storageIdToKittyId.delete(oldStorageId);
|
this._storageIdToKittyId.delete(oldStorageId);
|
||||||
}
|
}
|
||||||
const storageId = this._storage.addImage(image, scrolling, layer, zIndex);
|
this._addImageOpts.scrolling = scrolling;
|
||||||
|
this._addImageOpts.layer = layer;
|
||||||
|
this._addImageOpts.zIndex = zIndex;
|
||||||
|
const storageId = this._storage.addImage(image, this._addImageOpts);
|
||||||
this._kittyIdToStorageId.set(kittyId, storageId);
|
this._kittyIdToStorageId.set(kittyId, storageId);
|
||||||
this._storageIdToKittyId.set(storageId, kittyId);
|
this._storageIdToKittyId.set(storageId, kittyId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { readFileSync } from 'fs';
|
|||||||
import { FINALIZER, introducer, sixelEncode } from 'sixel';
|
import { FINALIZER, introducer, sixelEncode } from 'sixel';
|
||||||
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
|
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
|
||||||
import { deepStrictEqual, ok, strictEqual } from 'assert';
|
import { deepStrictEqual, ok, strictEqual } from 'assert';
|
||||||
|
import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin ctor options.
|
* Plugin ctor options.
|
||||||
@@ -74,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [
|
|||||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]],
|
[readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]],
|
||||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
|
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
|
||||||
];
|
];
|
||||||
|
const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64');
|
||||||
|
const qoiEnc = new QoiEncoder(1024*1024);
|
||||||
|
const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80);
|
||||||
|
const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64');
|
||||||
|
|
||||||
let ctx: ITestContext;
|
let ctx: ITestContext;
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeAll(async ({ browser }) => {
|
||||||
@@ -85,11 +90,6 @@ test.afterAll(async () => await ctx.page.close());
|
|||||||
test.describe('ImageAddon', () => {
|
test.describe('ImageAddon', () => {
|
||||||
|
|
||||||
test.beforeEach(async ({}, testInfo) => {
|
test.beforeEach(async ({}, testInfo) => {
|
||||||
// DEBT: This test never worked on webkit
|
|
||||||
if (ctx.browser.browserType().name() === 'webkit') {
|
|
||||||
testInfo.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.term.reset()
|
window.term.reset()
|
||||||
window.imageAddon?.dispose();
|
window.imageAddon?.dispose();
|
||||||
@@ -132,13 +132,13 @@ test.describe('ImageAddon', () => {
|
|||||||
sixelSupport: true,
|
sixelSupport: true,
|
||||||
sixelScrolling: true,
|
sixelScrolling: true,
|
||||||
sixelPaletteLimit: 512, // set to 512 to get example image working
|
sixelPaletteLimit: 512, // set to 512 to get example image working
|
||||||
sixelSizeLimit: 25000000,
|
sixelSizeLimit: 33554432,
|
||||||
storageLimit: 128,
|
storageLimit: 128,
|
||||||
showPlaceholder: true,
|
showPlaceholder: true,
|
||||||
iipSupport: true,
|
iipSupport: true,
|
||||||
iipSizeLimit: 20000000,
|
iipSizeLimit: 33554432,
|
||||||
kittySupport: true,
|
kittySupport: true,
|
||||||
kittySizeLimit: 20000000
|
kittySizeLimit: 33554432
|
||||||
};
|
};
|
||||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||||
});
|
});
|
||||||
@@ -196,6 +196,18 @@ test.describe('ImageAddon', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('image lifecycle & eviction', () => {
|
test.describe('image lifecycle & eviction', () => {
|
||||||
|
test('onImageAdded fires for each image', async () => {
|
||||||
|
await ctx.page.evaluate(`
|
||||||
|
window._imageAddedCount = 0;
|
||||||
|
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
|
||||||
|
`);
|
||||||
|
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||||
|
await pollFor(ctx.page, 'window._imageAddedCount', 1);
|
||||||
|
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||||
|
await pollFor(ctx.page, 'window._imageAddedCount', 2);
|
||||||
|
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||||
|
await pollFor(ctx.page, 'window._imageAddedCount', 3);
|
||||||
|
});
|
||||||
test('delete image once scrolled off', async () => {
|
test('delete image once scrolled off', async () => {
|
||||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||||
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
|
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
|
||||||
@@ -278,6 +290,67 @@ test.describe('ImageAddon', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('IIP support', () => {
|
||||||
|
test('size params is optional/informative', async () => {
|
||||||
|
// too small size param
|
||||||
|
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||||
|
// no size aparm at all
|
||||||
|
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(2), [640, 80]);
|
||||||
|
});
|
||||||
|
test.skip('FilePart support - bytewise', async () => {
|
||||||
|
// opener
|
||||||
|
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||||
|
// byte-wise
|
||||||
|
for (let i = 0; i < PALETTE_PNG_BASE64.length; ++i) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64[i]}\x07`);
|
||||||
|
}
|
||||||
|
// finalizer
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||||
|
});
|
||||||
|
test('FilePart support - chunks', async () => {
|
||||||
|
// opener
|
||||||
|
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||||
|
// chunks
|
||||||
|
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||||
|
}
|
||||||
|
// finalizer
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||||
|
});
|
||||||
|
test('FilePart support - edgecases', async () => {
|
||||||
|
// multiple opener do not harm
|
||||||
|
for (let i = 0; i < 10; ++i) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||||
|
}
|
||||||
|
// multiple finalizer do not harm
|
||||||
|
for (let i = 0; i < 5; ++i) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||||
|
}
|
||||||
|
// chunks w'o opener are ignored
|
||||||
|
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||||
|
}
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||||
|
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 0);
|
||||||
|
});
|
||||||
|
test('FilePart support - other IIP sequence cancels chunked image', async () => {
|
||||||
|
// opener
|
||||||
|
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||||
|
// chunks
|
||||||
|
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||||
|
}
|
||||||
|
// finalizer skipped
|
||||||
|
// write spinfox.png
|
||||||
|
await ctx.proxy.write(TESTDATA_IIP[1][0]);
|
||||||
|
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('IIP support - testimages', () => {
|
test.describe('IIP support - testimages', () => {
|
||||||
test('palette.png', async () => {
|
test('palette.png', async () => {
|
||||||
await ctx.proxy.write(TESTDATA_IIP[0][0]);
|
await ctx.proxy.write(TESTDATA_IIP[0][0]);
|
||||||
@@ -300,6 +373,68 @@ test.describe('ImageAddon', () => {
|
|||||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
|
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('IIP - QOI support', () => {
|
||||||
|
test('palette should yield same bytes from PNG and QOI', async () => {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||||
|
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(2), [640, 80]);
|
||||||
|
const pngScrape = await getImageAtBufferCell(0, 0);
|
||||||
|
const qoiScrape = await getImageAtBufferCell(0, 11);
|
||||||
|
deepStrictEqual(qoiScrape, pngScrape);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('IIP - resizing', () => {
|
||||||
|
/**
|
||||||
|
* The correct resize behavior is wonky and needs to be tested against iTerm2,
|
||||||
|
* especially for missing params to derive correct default behavior.
|
||||||
|
* We document the current behavior here w'o claiming to be fully in line with iTerm2.
|
||||||
|
* ref: https://iterm2.com/documentation-images.html
|
||||||
|
* imgcat: https://iterm2.com/utilities/imgcat
|
||||||
|
*
|
||||||
|
* NOTE: QOI has a slightly different parse path, thus we test resizing explicitly
|
||||||
|
*/
|
||||||
|
const images = [
|
||||||
|
['palette.png', 525, PALETTE_PNG_BASE64],
|
||||||
|
['palette.qoi', qoiData.length, PALETTE_QOI_BASE64]
|
||||||
|
];
|
||||||
|
for (const [name, size, payload] of images) {
|
||||||
|
test(name + ': N --> width=20 height=5 preserveAspectRatio=0', async () => {
|
||||||
|
// cell based resize
|
||||||
|
const header = 'width=20;height=5;preserveAspectRatio=0';
|
||||||
|
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||||
|
const dim = await getDimensions();
|
||||||
|
deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
|
||||||
|
});
|
||||||
|
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
|
||||||
|
// pixel based resize
|
||||||
|
const header = 'width=320px;height=160px;preserveAspectRatio=0';
|
||||||
|
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [320, 160]);
|
||||||
|
});
|
||||||
|
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
|
||||||
|
// % of viewport resize
|
||||||
|
const header = 'width=50%;height=30%;preserveAspectRatio=0';
|
||||||
|
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||||
|
const dim = await getDimensions();
|
||||||
|
deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
|
||||||
|
});
|
||||||
|
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
|
||||||
|
// width provided in percent
|
||||||
|
const header = 'width=50%';
|
||||||
|
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||||
|
const dim = await getDimensions();
|
||||||
|
const width = Math.floor(dim.width * 0.5);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]);
|
||||||
|
// height provided in pixel
|
||||||
|
const header2 = 'height=200px';
|
||||||
|
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,3 +468,7 @@ async function getOrigSize(id: number): Promise<[number, number]> {
|
|||||||
window.imageAddon._storage._images.get(${id}).orig.height
|
window.imageAddon._storage._images.get(${id}).orig.height
|
||||||
]`);
|
]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
|
||||||
|
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,11 +109,6 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
// TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
|
// TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
|
||||||
|
|
||||||
test.beforeEach(async ({}, testInfo) => {
|
test.beforeEach(async ({}, testInfo) => {
|
||||||
// DEBT: This test never worked on webkit
|
|
||||||
if (ctx.browser.browserType().name() === 'webkit') {
|
|
||||||
testInfo.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.term.reset()
|
window.term.reset()
|
||||||
window.imageAddon?.dispose();
|
window.imageAddon?.dispose();
|
||||||
@@ -544,7 +539,6 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
|
|
||||||
test.describe('Query support (a=q)', () => {
|
test.describe('Query support (a=q)', () => {
|
||||||
test('responds with OK for capability query without payload', async () => {
|
test('responds with OK for capability query without payload', async () => {
|
||||||
let response = '';
|
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
(window as any).kittyResponse = '';
|
(window as any).kittyResponse = '';
|
||||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||||
@@ -553,12 +547,11 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
await ctx.proxy.write('\x1b_Gi=31,a=q;\x1b\\');
|
await ctx.proxy.write('\x1b_Gi=31,a=q;\x1b\\');
|
||||||
await timeout(100);
|
await timeout(100);
|
||||||
|
|
||||||
response = await ctx.page.evaluate('window.kittyResponse');
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||||
strictEqual(response, '\x1b_Gi=31;OK\x1b\\');
|
strictEqual(response, '\x1b_Gi=31;OK\x1b\\');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('responds with OK for valid PNG query', async () => {
|
test('responds with OK for valid PNG query', async () => {
|
||||||
let response = '';
|
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
(window as any).kittyResponse = '';
|
(window as any).kittyResponse = '';
|
||||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||||
@@ -567,7 +560,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
await ctx.proxy.write(`\x1b_Gi=42,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
await ctx.proxy.write(`\x1b_Gi=42,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
await timeout(100);
|
await timeout(100);
|
||||||
|
|
||||||
response = await ctx.page.evaluate('window.kittyResponse');
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||||
strictEqual(response, '\x1b_Gi=42;OK\x1b\\');
|
strictEqual(response, '\x1b_Gi=42;OK\x1b\\');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -583,7 +576,6 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('responds with error for invalid base64', async () => {
|
test('responds with error for invalid base64', async () => {
|
||||||
let response = '';
|
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
(window as any).kittyResponse = '';
|
(window as any).kittyResponse = '';
|
||||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||||
@@ -592,12 +584,11 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
await ctx.proxy.write('\x1b_Gi=60,a=q,f=100;!!!invalid!!!\x1b\\');
|
await ctx.proxy.write('\x1b_Gi=60,a=q,f=100;!!!invalid!!!\x1b\\');
|
||||||
await timeout(100);
|
await timeout(100);
|
||||||
|
|
||||||
response = await ctx.page.evaluate('window.kittyResponse');
|
const response: string = await ctx.page.evaluate('window.kittyResponse');
|
||||||
strictEqual(response.startsWith('\x1b_Gi=60;EINVAL:'), true);
|
strictEqual(response.startsWith('\x1b_Gi=60;EINVAL:'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('responds with error for RGB data without dimensions', async () => {
|
test('responds with error for RGB data without dimensions', async () => {
|
||||||
let response = '';
|
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
(window as any).kittyResponse = '';
|
(window as any).kittyResponse = '';
|
||||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||||
@@ -606,7 +597,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
await ctx.proxy.write('\x1b_Gi=70,a=q,f=24;AAAA\x1b\\');
|
await ctx.proxy.write('\x1b_Gi=70,a=q,f=24;AAAA\x1b\\');
|
||||||
await timeout(100);
|
await timeout(100);
|
||||||
|
|
||||||
response = await ctx.page.evaluate('window.kittyResponse');
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||||
strictEqual(response, '\x1b_Gi=70;EINVAL:width and height required for raw pixel data\x1b\\');
|
strictEqual(response, '\x1b_Gi=70;EINVAL:width and height required for raw pixel data\x1b\\');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -622,6 +613,18 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('suppresses OK response when q=2', async () => {
|
||||||
|
await ctx.page.evaluate(() => {
|
||||||
|
(window as any).kittyGotResponse = false;
|
||||||
|
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.proxy.write(`\x1b_Gi=81,a=q,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
|
||||||
|
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('suppresses error response when q=2', async () => {
|
test('suppresses error response when q=2', async () => {
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
(window as any).kittyGotResponse = false;
|
(window as any).kittyGotResponse = false;
|
||||||
@@ -635,7 +638,6 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('responds with EINVAL when both i and I keys are specified', async () => {
|
test('responds with EINVAL when both i and I keys are specified', async () => {
|
||||||
let response = '';
|
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
(window as any).kittyResponse = '';
|
(window as any).kittyResponse = '';
|
||||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||||
@@ -645,12 +647,11 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
await ctx.proxy.write(`\x1b_Gi=100,I=200,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
await ctx.proxy.write(`\x1b_Gi=100,I=200,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
await timeout(100);
|
await timeout(100);
|
||||||
|
|
||||||
response = await ctx.page.evaluate('window.kittyResponse');
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||||
strictEqual(response, '\x1b_Gi=100;EINVAL:cannot specify both i and I keys\x1b\\');
|
strictEqual(response, '\x1b_Gi=100;EINVAL:cannot specify both i and I keys\x1b\\');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('responds with EINVAL for i+I conflict even without payload', async () => {
|
test('responds with EINVAL for i+I conflict even without payload', async () => {
|
||||||
let response = '';
|
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
(window as any).kittyResponse = '';
|
(window as any).kittyResponse = '';
|
||||||
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
(window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
|
||||||
@@ -660,7 +661,7 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
await ctx.proxy.write('\x1b_Gi=101,I=201,a=d\x1b\\');
|
await ctx.proxy.write('\x1b_Gi=101,I=201,a=d\x1b\\');
|
||||||
await timeout(100);
|
await timeout(100);
|
||||||
|
|
||||||
response = await ctx.page.evaluate('window.kittyResponse');
|
const response = await ctx.page.evaluate('window.kittyResponse');
|
||||||
strictEqual(response, '\x1b_Gi=101;EINVAL:cannot specify both i and I keys\x1b\\');
|
strictEqual(response, '\x1b_Gi=101;EINVAL:cannot specify both i and I keys\x1b\\');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -802,6 +803,30 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
|
|
||||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a=t OK suppressed by q=2', async () => {
|
||||||
|
await ctx.page.evaluate(() => {
|
||||||
|
(window as any).kittyGotResponse = false;
|
||||||
|
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.proxy.write(`\x1b_Gi=181,a=t,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
|
||||||
|
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a=T OK suppressed by q=2', async () => {
|
||||||
|
await ctx.page.evaluate(() => {
|
||||||
|
(window as any).kittyGotResponse = false;
|
||||||
|
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.proxy.write(`\x1b_Gi=191,a=T,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
|
||||||
|
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Transmission medium rejection', () => {
|
test.describe('Transmission medium rejection', () => {
|
||||||
@@ -1151,6 +1176,22 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
strictEqual(await getImageStorageLength(), 1);
|
strictEqual(await getImageStorageLength(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('OK response suppressed by q=2', async () => {
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=221;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
|
||||||
|
await ctx.page.evaluate(() => {
|
||||||
|
(window as any).kittyGotResponse = false;
|
||||||
|
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=p,i=221,q=2\x1b\\`);
|
||||||
|
await timeout(100);
|
||||||
|
|
||||||
|
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||||
|
strictEqual(await getImageStorageLength(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('ENOENT error suppressed by q=2', async () => {
|
test('ENOENT error suppressed by q=2', async () => {
|
||||||
await ctx.page.evaluate(() => {
|
await ctx.page.evaluate(() => {
|
||||||
(window as any).kittyGotResponse = false;
|
(window as any).kittyGotResponse = false;
|
||||||
@@ -2159,6 +2200,19 @@ test.describe('Kitty Graphics Protocol', () => {
|
|||||||
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.has(${oldStorageId})`), false);
|
strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.has(${oldStorageId})`), false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('onImageAdded callback', () => {
|
||||||
|
test('onImageAdded fires for each kitty image', async () => {
|
||||||
|
await ctx.page.evaluate(`
|
||||||
|
window._imageAddedCount = 0;
|
||||||
|
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
|
||||||
|
`);
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||||
|
await pollFor(ctx.page, 'window._imageAddedCount', 1);
|
||||||
|
await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
|
||||||
|
await pollFor(ctx.page, 'window._imageAddedCount', 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+6
-1
@@ -3,7 +3,7 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Terminal, ITerminalAddon } from '@xterm/xterm';
|
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
|
||||||
|
|
||||||
declare module '@xterm/addon-image' {
|
declare module '@xterm/addon-image' {
|
||||||
export interface IImageAddonOptions {
|
export interface IImageAddonOptions {
|
||||||
@@ -116,6 +116,11 @@ declare module '@xterm/addon-image' {
|
|||||||
*/
|
*/
|
||||||
public showPlaceholder: boolean;
|
public showPlaceholder: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired whenever a new image is added to storage.
|
||||||
|
*/
|
||||||
|
public readonly onImageAdded: IEvent<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get original image canvas at buffer position.
|
* Get original image canvas at buffer position.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"package": "webpack",
|
"package": "webpack",
|
||||||
"pretest": "npm run build",
|
"pretest": "npm run build",
|
||||||
"test": "nyc mocha out/**/*.test.js",
|
"test": "nyc mocha out/**/*.test.js",
|
||||||
"prepublish": "npm run package"
|
"prepublishOnly": "npm run package"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"font",
|
"font",
|
||||||
@@ -31,15 +31,11 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lru-cache": "^6.0.0",
|
"lru-cache": "^11.3.6",
|
||||||
"opentype.js": "^0.8.0"
|
"opentype.js": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lru-cache": "^5.1.0",
|
"@types/opentype.js": "^1.3.9",
|
||||||
"@types/opentype.js": "^0.7.0",
|
"font-finder": "^1.1.0"
|
||||||
"axios": "^1.6.0",
|
|
||||||
"font-finder": "^1.1.0",
|
|
||||||
"mkdirp": "0.5.5",
|
|
||||||
"yauzl": "^2.10.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as opentype from 'opentype.js';
|
import * as opentype from 'opentype.js';
|
||||||
import LRUCache = require('lru-cache');
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types';
|
import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types';
|
||||||
import mergeTrees from './merge';
|
import mergeTrees from './merge';
|
||||||
@@ -23,8 +23,8 @@ class FontImpl implements IFont {
|
|||||||
|
|
||||||
if (options.cacheSize > 0) {
|
if (options.cacheSize > 0) {
|
||||||
this._cache = new LRUCache({
|
this._cache = new LRUCache({
|
||||||
max: options.cacheSize,
|
maxSize: options.cacheSize,
|
||||||
length: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
|
sizeCalculation: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export default function mergeRange(ranges: [number, number][], newRangeStart: nu
|
|||||||
// current range
|
// current range
|
||||||
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
|
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
|
||||||
ranges.splice(i, 1);
|
ranges.splice(i, 1);
|
||||||
inRange = false;
|
|
||||||
return ranges;
|
return ranges;
|
||||||
}
|
}
|
||||||
// Case 7: New range extends from previous range past the
|
// Case 7: New range extends from previous range past the
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const CACHE_SIZE = 100000;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable ligature support for the provided Terminal instance. To function
|
* Enable ligature support for the provided Terminal instance. To function
|
||||||
* properly, this must be called after `open()` is called on the therminal. If
|
* properly, this must be called after `open()` is called on the terminal. If
|
||||||
* the font currently in use supports ligatures, the terminal will automatically
|
* the font currently in use supports ligatures, the terminal will automatically
|
||||||
* start to render them.
|
* start to render them.
|
||||||
* @param term Terminal instance from xterm.js
|
* @param term Terminal instance from xterm.js
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
|
|||||||
|
|
||||||
|
|
||||||
let ctx: ITestContext;
|
let ctx: ITestContext;
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
ctx = await createTestContext(browser);
|
|
||||||
ctx.page.setViewportSize({ width: 1024, height: 768 });
|
|
||||||
await openTerminal(ctx);
|
|
||||||
});
|
|
||||||
test.afterAll(async () => await ctx.page.close());
|
|
||||||
|
|
||||||
|
|
||||||
test.describe('ProgressAddon', () => {
|
test.describe('ProgressAddon', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
ctx = await createTestContext(browser);
|
||||||
|
await openTerminal(ctx);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await ctx?.page?.close();
|
||||||
|
});
|
||||||
|
|
||||||
test.beforeEach(async function(): Promise<any> {
|
test.beforeEach(async function(): Promise<any> {
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.progressStack = [];
|
window.progressStack = [];
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { DecorationManager } from './DecorationManager';
|
||||||
|
import { SearchEngine } from './SearchEngine';
|
||||||
|
import { SearchLineCache } from './SearchLineCache';
|
||||||
|
import { Terminal } from 'browser/public/Terminal';
|
||||||
|
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
||||||
|
import type { IDecorationOptions } from '@xterm/xterm';
|
||||||
|
import { DisposableStore } from 'common/Lifecycle';
|
||||||
|
|
||||||
|
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||||
|
return new Promise(r => terminal.write(data, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DecorationManager', () => {
|
||||||
|
let store: DisposableStore;
|
||||||
|
let terminal: Terminal;
|
||||||
|
let decorationManager: DecorationManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new DisposableStore();
|
||||||
|
terminal = store.add(new Terminal({ cols: 10, rows: 5 }));
|
||||||
|
decorationManager = store.add(new DecorationManager(terminal));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split highlight decorations for a wrapped match', async () => {
|
||||||
|
await writeP(terminal, '0123456789abcde');
|
||||||
|
const searchEngine = new SearchEngine(terminal, store.add(new SearchLineCache(terminal)));
|
||||||
|
const match = searchEngine.find('9abc', 0, 0);
|
||||||
|
assert.ok(match);
|
||||||
|
|
||||||
|
const decorationOptions: IDecorationOptions[] = [];
|
||||||
|
const registerDecoration = terminal.registerDecoration.bind(terminal);
|
||||||
|
terminal.registerDecoration = (options: IDecorationOptions) => {
|
||||||
|
decorationOptions.push(options);
|
||||||
|
return registerDecoration(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ISearchDecorationOptions = {
|
||||||
|
matchOverviewRuler: '#ff0000',
|
||||||
|
activeMatchColorOverviewRuler: '#00ff00'
|
||||||
|
};
|
||||||
|
decorationManager.createHighlightDecorations([match], options);
|
||||||
|
|
||||||
|
assert.strictEqual(decorationOptions.length, 2);
|
||||||
|
assert.strictEqual(decorationOptions[0].x, 9);
|
||||||
|
assert.strictEqual(decorationOptions[0].width, 1);
|
||||||
|
assert.strictEqual(decorationOptions[1].x, 0);
|
||||||
|
assert.strictEqual(decorationOptions[1].width, 3);
|
||||||
|
|
||||||
|
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
|
||||||
|
assert.strictEqual(withOverviewRuler.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only add one overview ruler marker per buffer line', async () => {
|
||||||
|
await writeP(terminal, 'abcdefghij');
|
||||||
|
const decorationOptions: IDecorationOptions[] = [];
|
||||||
|
const registerDecoration = terminal.registerDecoration.bind(terminal);
|
||||||
|
terminal.registerDecoration = (options: IDecorationOptions) => {
|
||||||
|
decorationOptions.push(options);
|
||||||
|
return registerDecoration(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ISearchDecorationOptions = {
|
||||||
|
matchOverviewRuler: '#ff0000',
|
||||||
|
activeMatchColorOverviewRuler: '#00ff00'
|
||||||
|
};
|
||||||
|
decorationManager.createHighlightDecorations([
|
||||||
|
{ term: 'a', col: 0, row: 0, size: 1 },
|
||||||
|
{ term: 'f', col: 5, row: 0, size: 1 }
|
||||||
|
], options);
|
||||||
|
|
||||||
|
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
|
||||||
|
assert.strictEqual(withOverviewRuler.length, 1);
|
||||||
|
assert.strictEqual(withOverviewRuler[0].x, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -142,12 +142,14 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
}
|
}
|
||||||
prevResult = result;
|
prevResult = result;
|
||||||
results.push(prevResult);
|
results.push(prevResult);
|
||||||
result = this._engine.find(
|
const cols = this._terminal.cols;
|
||||||
term,
|
let nextCol = prevResult.col + prevResult.size;
|
||||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
|
let nextRow = prevResult.row;
|
||||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
|
if (nextCol >= cols) {
|
||||||
searchOptions
|
nextRow += Math.floor(nextCol / cols);
|
||||||
);
|
nextCol = nextCol % cols;
|
||||||
|
}
|
||||||
|
result = this._engine.find(term, nextRow, nextCol, searchOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._resultTracker.updateResults(results, this._highlightLimit);
|
this._resultTracker.updateResults(results, this._highlightLimit);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class SearchEngine {
|
|||||||
this._terminal.clearSelection();
|
this._terminal.clearSelection();
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (startCol > this._terminal.cols) {
|
if (startCol >= this._terminal.cols) {
|
||||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ export class SearchEngine {
|
|||||||
this._terminal.clearSelection();
|
this._terminal.clearSelection();
|
||||||
|
|
||||||
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
||||||
let startCol = this._terminal.cols;
|
const startCol = this._terminal.cols;
|
||||||
const isReverseSearch = true;
|
const isReverseSearch = true;
|
||||||
|
|
||||||
this._lineCache.initLinesCache();
|
this._lineCache.initLinesCache();
|
||||||
@@ -186,14 +186,14 @@ export class SearchEngine {
|
|||||||
let result: ISearchResult | undefined;
|
let result: ISearchResult | undefined;
|
||||||
if (prevSelectedPos) {
|
if (prevSelectedPos) {
|
||||||
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
||||||
searchPosition.startCol = startCol = prevSelectedPos.start.x;
|
searchPosition.startCol = prevSelectedPos.start.x;
|
||||||
if (cachedSearchTerm !== term) {
|
if (cachedSearchTerm !== term) {
|
||||||
// Try to expand selection to right first.
|
// Try to expand selection to right first.
|
||||||
result = this._findInLine(term, searchPosition, searchOptions, false);
|
result = this._findInLine(term, searchPosition, searchOptions, false);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// If selection was not able to be expanded to the right, then try reverse search
|
// If selection was not able to be expanded to the right, then try reverse search
|
||||||
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
||||||
searchPosition.startCol = startCol = prevSelectedPos.end.x;
|
searchPosition.startCol = prevSelectedPos.end.x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,10 +414,24 @@ test.describe('Search Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Regression 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', () => {
|
test.describe('#2444 wrapped line content not being found', () => {
|
||||||
let fixture: string;
|
let fixture: string;
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
fixture = (await new Promise<Buffer>(r => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => r(data)))).toString();
|
fixture = (await new Promise<Buffer>((res, rej) => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => err ? rej(err) : res(data)))).toString();
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
fixture = fixture.replace(/\n/g, '\n\r');
|
fixture = fixture.replace(/\n/g, '\n\r');
|
||||||
}
|
}
|
||||||
@@ -501,7 +515,7 @@ test.describe('Search Tests', () => {
|
|||||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||||
`);
|
`);
|
||||||
// Move cursor forward 1 time to create a null character, as opposed to regular whitespace
|
// Move cursor forward 1 time to create a null character, as opposed to regular whitespace
|
||||||
await ctx.proxy.write('\\x1b[CHi Hi');
|
await ctx.proxy.write('\x1b[CHi Hi');
|
||||||
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true);
|
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true);
|
||||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||||
{ resultCount: 2, resultIndex: 1 }
|
{ resultCount: 2, resultIndex: 1 }
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"outDir": "../out-benchmark",
|
"outDir": "../out-benchmark",
|
||||||
"types": ["../../../node_modules/@types/node"],
|
"types": ["../../../node_modules/@types/node"],
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "nodenext",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"target": "es2021",
|
"target": "es2021",
|
||||||
"module": "commonjs",
|
"module": "nodenext",
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": ["../../../src/common/*"],
|
"common/*": ["../../../src/common/*"],
|
||||||
"browser/*": ["../../../src/browser/*"],
|
"browser/*": ["../../../src/browser/*"],
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
"start": "node ../../demo/start",
|
"start": "node ../../demo/start",
|
||||||
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json",
|
"benchmark": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json",
|
||||||
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
"benchmark-baseline": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
||||||
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
|
"benchmark-eval": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
|
|
||||||
import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
|
import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||||
import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';
|
import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';
|
||||||
import { IAttributeData, IColor } from 'common/Types';
|
import { IColor } from 'common/Types';
|
||||||
|
import { IAttributeData } from 'common/buffer/Types';
|
||||||
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
|
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
|
||||||
import { UnderlineStyle } from 'common/buffer/Constants';
|
import { UnderlineStyle } from 'common/buffer/Constants';
|
||||||
|
|
||||||
@@ -172,7 +173,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();
|
private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();
|
||||||
private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();
|
private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();
|
||||||
protected _rowEnd(row: number, isLastRow: boolean): void {
|
protected _rowEnd(row: number, isLastRow: boolean): void {
|
||||||
// if there is colorful empty cell at line end, whe must pad it back, or the the color block
|
// if there is colorful empty cell at line end, we must pad it back, or the color block
|
||||||
// will missing
|
// will missing
|
||||||
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
|
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
|
||||||
// use clear right to set background.
|
// use clear right to set background.
|
||||||
@@ -220,7 +221,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
// you can't use control sequence to move cursor to (x === row)
|
// you can't use control sequence to move cursor to (x === row)
|
||||||
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
|
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
|
||||||
// change background of the first wrapped cell also affects BCE
|
// change background of the first wrapped cell also affects BCE
|
||||||
// so we mark it as invalid to simply the process to determine line separator
|
// so we mark it as invalid to simplify the process to determine line separator
|
||||||
equalBg(thisRowLastChar, nextRowFirstChar)
|
equalBg(thisRowLastChar, nextRowFirstChar)
|
||||||
) {
|
) {
|
||||||
isValid = true;
|
isValid = true;
|
||||||
@@ -232,7 +233,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
isNextRowFirstCharDoubleWidth &&
|
isNextRowFirstCharDoubleWidth &&
|
||||||
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
|
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
|
||||||
// change background of the first wrapped cell also affects BCE
|
// change background of the first wrapped cell also affects BCE
|
||||||
// so we mark it as invalid to simply the process to determine line separator
|
// so we mark it as invalid to simplify the process to determine line separator
|
||||||
equalBg(thisRowLastChar, nextRowFirstChar) &&
|
equalBg(thisRowLastChar, nextRowFirstChar) &&
|
||||||
equalBg(thisRowLastSecondChar, nextRowFirstChar)
|
equalBg(thisRowLastSecondChar, nextRowFirstChar)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -49,7 +49,14 @@ test.describe('SerializeAddon', () => {
|
|||||||
for (let i = 0; i < buffer.length; i++) {
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
// Do this intentionally to get content of underlining source
|
// Do this intentionally to get content of underlining source
|
||||||
const bufferLine = buffer.getLine(i)._line;
|
const bufferLine = buffer.getLine(i)._line;
|
||||||
lines.push(JSON.stringify(bufferLine));
|
lines.push(JSON.stringify(bufferLine, (key, value) => {
|
||||||
|
// BufferLine caches are internal/transient and can legitimately differ
|
||||||
|
// across equivalent terminal states.
|
||||||
|
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
x: buffer.cursorX,
|
x: buffer.cursorX,
|
||||||
@@ -185,7 +192,7 @@ test.describe('SerializeAddon', () => {
|
|||||||
const cols = 10;
|
const cols = 10;
|
||||||
const line = '+'.repeat(cols);
|
const line = '+'.repeat(cols);
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags a the end, serialize will use \x1b[0m to clear instead of the sepcific disable sequence
|
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags at the end, serialize will use \x1b[0m to clear instead of the specific disable sequence
|
||||||
sgr(INVERSE) + line,
|
sgr(INVERSE) + line,
|
||||||
sgr(BOLD) + line,
|
sgr(BOLD) + line,
|
||||||
sgr(UNDERLINED) + line,
|
sgr(UNDERLINED) + line,
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"outDir": "../out-benchmark",
|
"outDir": "../out-benchmark",
|
||||||
"types": ["../../../node_modules/@types/node"],
|
"types": ["../../../node_modules/@types/node"],
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "nodenext",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"target": "es2015",
|
"target": "es2015",
|
||||||
"module": "commonjs",
|
"module": "nodenext",
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": ["../../../src/common/*"],
|
"common/*": ["../../../src/common/*"],
|
||||||
"browser/*": ["../../../src/browser/*"],
|
"browser/*": ["../../../src/browser/*"],
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
"start": "node ../../demo/start",
|
"start": "node ../../demo/start",
|
||||||
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
|
"benchmark": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
|
||||||
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
"benchmark-baseline": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
||||||
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
|
"benchmark-eval": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||||
* @license MIT
|
* @license MIT
|
||||||
*
|
*
|
||||||
* UnicodeVersionProvider for V15 with grapeme cluster handleing.
|
* UnicodeVersionProvider for V15 with grapheme cluster handling.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm';
|
import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm';
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ test.describe('WebFontsAddon', () => {
|
|||||||
test.describe('font loading at runtime', () => {
|
test.describe('font loading at runtime', () => {
|
||||||
test('loadFonts (JS)', async () => {
|
test('loadFonts (JS)', async () => {
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
|
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
|
||||||
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
|
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
|
||||||
loadFonts([ff1, ff2]);
|
loadFonts([ff1, ff2]);
|
||||||
`);
|
`);
|
||||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
|
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
|
||||||
});
|
});
|
||||||
test('loadFonts (CSS, unquoted)', async () => {
|
test('loadFonts (CSS, unquoted)', async () => {
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
|
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
|
||||||
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/bpdots.regular.otf) format('opentype')}", 1);
|
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
|
||||||
loadFonts(['Kongtext', 'BPdots']);
|
loadFonts(['Kongtext', 'BPdots']);
|
||||||
`);
|
`);
|
||||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
|
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
|
||||||
@@ -50,8 +50,8 @@ test.describe('WebFontsAddon', () => {
|
|||||||
test('loadFonts (CSS, quoted)', async ({ browser }) => {
|
test('loadFonts (CSS, quoted)', async ({ browser }) => {
|
||||||
// NOTE: firefox preserves family quotes from CSS rules in fontface, all other browsers unquote them
|
// NOTE: firefox preserves family quotes from CSS rules in fontface, all other browsers unquote them
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
|
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
|
||||||
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/bpdots.regular.otf) format('opentype')}", 1);
|
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
|
||||||
loadFonts(['Kongtext', 'BPdots']);
|
loadFonts(['Kongtext', 'BPdots']);
|
||||||
`);
|
`);
|
||||||
if (browser.browserType().name() === 'firefox') {
|
if (browser.browserType().name() === 'firefox') {
|
||||||
@@ -63,8 +63,8 @@ test.describe('WebFontsAddon', () => {
|
|||||||
test('FontFace hashing', async () => {
|
test('FontFace hashing', async () => {
|
||||||
// multiple calls of `loadFonts` with the same objects shall not bloat document.fonts
|
// multiple calls of `loadFonts` with the same objects shall not bloat document.fonts
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
|
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
|
||||||
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
|
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
|
||||||
loadFonts([ff1, ff2]);
|
loadFonts([ff1, ff2]);
|
||||||
loadFonts([ff1, ff2]);
|
loadFonts([ff1, ff2]);
|
||||||
loadFonts([ff1, ff2]).then(() => loadFonts([ff1, ff2]));
|
loadFonts([ff1, ff2]).then(() => loadFonts([ff1, ff2]));
|
||||||
@@ -76,9 +76,24 @@ test.describe('WebFontsAddon', () => {
|
|||||||
// to make this test work, we exclude the default measurement char W (x57) by restricting unicode-range
|
// to make this test work, we exclude the default measurement char W (x57) by restricting unicode-range
|
||||||
// now the browser will postpone font loading until codepoint is hit --> wrong glyph metrics on first usage
|
// now the browser will postpone font loading until codepoint is hit --> wrong glyph metrics on first usage
|
||||||
const data = await ctx.page.evaluate(`
|
const data = await ctx.page.evaluate(`
|
||||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
|
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
|
||||||
`);
|
`);
|
||||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
let fonts = await getDocumentFonts();
|
||||||
|
// safari may have loaded the font, firefox & chrome never load it
|
||||||
|
if (browser.browserType().name() === 'webkit') {
|
||||||
|
try {
|
||||||
|
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'loading' }]);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'loaded' }]);
|
||||||
|
} catch (e) {
|
||||||
|
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// broken case: webfont in ctor without addon usage
|
// broken case: webfont in ctor without addon usage
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
@@ -86,11 +101,20 @@ test.describe('WebFontsAddon', () => {
|
|||||||
window.helperTerm.open(term.element);
|
window.helperTerm.open(term.element);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// safari loads the font, firefox & chrome dont
|
fonts = await getDocumentFonts();
|
||||||
|
// safari might load the font, firefox & chrome dont
|
||||||
if (browser.browserType().name() === 'webkit') {
|
if (browser.browserType().name() === 'webkit') {
|
||||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
try {
|
||||||
|
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'loading' }]);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'loaded' }]);
|
||||||
|
} catch (e) {
|
||||||
|
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
deepStrictEqual(fonts, [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// good case: addon fixes layout for webfont in ctor
|
// good case: addon fixes layout for webfont in ctor
|
||||||
@@ -103,7 +127,10 @@ test.describe('WebFontsAddon', () => {
|
|||||||
window.helperTerm.open(term.element);
|
window.helperTerm.open(term.element);
|
||||||
`);
|
`);
|
||||||
await timeout(100);
|
await timeout(100);
|
||||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
if (browser.browserType().name() !== 'webkit') {
|
||||||
|
// safari sometimes reports 'unloadeed'?
|
||||||
|
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||||
|
}
|
||||||
|
|
||||||
// cleanup this messy test case
|
// cleanup this messy test case
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"lib": [
|
"lib": [
|
||||||
"es2021",
|
"es2021",
|
||||||
],
|
],
|
||||||
// "downlevelIteration": true,
|
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ function isUrl(urlString: string): boolean {
|
|||||||
|
|
||||||
export class LinkComputer {
|
export class LinkComputer {
|
||||||
public static computeLink(y: number, regex: RegExp, terminal: Terminal, activate: (event: MouseEvent, uri: string) => void): ILink[] {
|
public static computeLink(y: number, regex: RegExp, terminal: Terminal, activate: (event: MouseEvent, uri: string) => void): ILink[] {
|
||||||
const rex = new RegExp(regex.source, (regex.flags || '') + 'g');
|
const flags = regex.flags.includes('g') ? regex.flags : `${regex.flags}g`;
|
||||||
|
const rex = new RegExp(regex.source, flags);
|
||||||
|
|
||||||
const [lines, startLineIndex] = LinkComputer._getWindowedLineStrings(y - 1, terminal);
|
const [lines, startLineIndex] = LinkComputer._getWindowedLineStrings(y - 1, terminal);
|
||||||
const line = lines.join('');
|
const line = lines.join('');
|
||||||
@@ -113,8 +114,8 @@ export class LinkComputer {
|
|||||||
let line: IBufferLine | undefined;
|
let line: IBufferLine | undefined;
|
||||||
let topIdx = lineIndex;
|
let topIdx = lineIndex;
|
||||||
let bottomIdx = lineIndex;
|
let bottomIdx = lineIndex;
|
||||||
let length = 0;
|
let length;
|
||||||
let content = '';
|
let content;
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
if ((line = terminal.buffer.active.getLine(lineIndex))) {
|
if ((line = terminal.buffer.active.getLine(lineIndex))) {
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
|
|||||||
import type { WebLinksAddon as IWebLinksApi } from '@xterm/addon-web-links';
|
import type { WebLinksAddon as IWebLinksApi } from '@xterm/addon-web-links';
|
||||||
import { ILinkProviderOptions, WebLinkProvider } from './WebLinkProvider';
|
import { ILinkProviderOptions, WebLinkProvider } from './WebLinkProvider';
|
||||||
|
|
||||||
// consider everthing starting with http:// or https://
|
// consider everything starting with http:// or https://
|
||||||
// up to first whitespace, `"` or `'` as url
|
// up to first whitespace, `"` or `'` as url
|
||||||
// NOTE: The repeated end clause is needed to not match a dangling `:`
|
// NOTE: The repeated end clause is needed to not match a dangling `:`
|
||||||
// resembling the old (...)*([^:"\'\\s]) final path clause
|
// resembling the old (...)*([^:"\'\\s]) final path clause
|
||||||
// additionally exclude early + final:
|
// additionally exclude early + final:
|
||||||
// - unsafe from rfc3986: !*'()
|
// - unsafe from rfc3986: !*'()
|
||||||
// - unsafe chars from rfc1738: {}|\^~[]` (minus [] as we need them for ipv6 adresses, also allow ~)
|
// - unsafe chars from rfc1738: {}|\^~[]` (minus [] for ipv6 addresses, also allow ~)
|
||||||
// also exclude as finals:
|
// also exclude as finals:
|
||||||
// - final interpunction like ,.!?
|
// - final interpunction like ,.!?
|
||||||
// - any sort of brackets <>()[]{} (not spec conform, but often used to enclose urls)
|
// - any sort of brackets <>()[]{} (not spec conform, but often used to enclose urls)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"lib": [
|
"lib": [
|
||||||
"es2021",
|
"es2021",
|
||||||
],
|
],
|
||||||
// "downlevelIteration": true,
|
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-
|
|||||||
|
|
||||||
### Handling Context Loss
|
### Handling Context Loss
|
||||||
|
|
||||||
The browser may drop WebGL contexts for various reasons like OOM or after the system has been suspended. There is an API exposed that fires the `webglcontextlost` event fired on the canvas so embedders can handle it however they wish. An easy, but suboptimal way, to handle this is by disposing of WebglAddon when the event fires:
|
The browser may drop WebGL contexts for various reasons like OOM or after the system has been suspended. There is an API exposed that fires the `webglcontextlost` event on the canvas so embedders can handle it however they wish. An easy, but suboptimal way, to handle this is by disposing of WebglAddon when the event fires:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const terminal = new Terminal();
|
const terminal = new Terminal();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
|||||||
import { ReadonlyColorSet } from 'browser/Types';
|
import { ReadonlyColorSet } from 'browser/Types';
|
||||||
import { Attributes, BgFlags, ExtFlags, FgFlags, NULL_CELL_CODE, UnderlineStyle } from 'common/buffer/Constants';
|
import { Attributes, BgFlags, ExtFlags, FgFlags, NULL_CELL_CODE, UnderlineStyle } from 'common/buffer/Constants';
|
||||||
import { IDecorationService, IOptionsService } from 'common/services/Services';
|
import { IDecorationService, IOptionsService } from 'common/services/Services';
|
||||||
import { ICellData } from 'common/Types';
|
import { ICellData } from 'common/buffer/Types';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { rgba } from 'common/Color';
|
import { rgba } from 'common/Color';
|
||||||
import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils';
|
import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils';
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { configEquals } from './CharAtlasUtils';
|
||||||
|
import { ICharAtlasConfig } from './Types';
|
||||||
|
import { NULL_COLOR } from 'common/Color';
|
||||||
|
import { IColor } from 'common/Types';
|
||||||
|
|
||||||
|
function createTestConfig(overrides: Partial<ICharAtlasConfig> = {}): ICharAtlasConfig {
|
||||||
|
const color: IColor = { css: '#ffffff', rgba: 0xffffffff };
|
||||||
|
const contrastCache = {
|
||||||
|
clear: () => {},
|
||||||
|
setCss: () => {},
|
||||||
|
getCss: () => undefined,
|
||||||
|
setColor: () => {},
|
||||||
|
getColor: () => undefined
|
||||||
|
};
|
||||||
|
const colors = {
|
||||||
|
foreground: color,
|
||||||
|
background: color,
|
||||||
|
cursor: NULL_COLOR,
|
||||||
|
cursorAccent: NULL_COLOR,
|
||||||
|
selectionForeground: undefined,
|
||||||
|
selectionBackgroundTransparent: NULL_COLOR,
|
||||||
|
selectionBackgroundOpaque: NULL_COLOR,
|
||||||
|
selectionInactiveBackgroundTransparent: NULL_COLOR,
|
||||||
|
selectionInactiveBackgroundOpaque: NULL_COLOR,
|
||||||
|
overviewRulerBorder: NULL_COLOR,
|
||||||
|
scrollbarSliderBackground: NULL_COLOR,
|
||||||
|
scrollbarSliderHoverBackground: NULL_COLOR,
|
||||||
|
scrollbarSliderActiveBackground: NULL_COLOR,
|
||||||
|
ansi: new Array(256).fill(color),
|
||||||
|
contrastCache,
|
||||||
|
halfContrastCache: contrastCache
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
customGlyphs: true,
|
||||||
|
devicePixelRatio: 1,
|
||||||
|
deviceMaxTextureSize: 4096,
|
||||||
|
letterSpacing: 0,
|
||||||
|
lineHeight: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontWeightBold: 'bold',
|
||||||
|
deviceCellWidth: 10,
|
||||||
|
deviceCellHeight: 20,
|
||||||
|
deviceCharWidth: 8,
|
||||||
|
deviceCharHeight: 16,
|
||||||
|
allowTransparency: false,
|
||||||
|
drawBoldTextInBrightColors: true,
|
||||||
|
minimumContrastRatio: 1,
|
||||||
|
colors,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CharAtlasUtils', () => {
|
||||||
|
describe('configEquals', () => {
|
||||||
|
it('should return true for identical configs', () => {
|
||||||
|
const a = createTestConfig();
|
||||||
|
const b = createTestConfig();
|
||||||
|
assert.ok(configEquals(a, b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when deviceMaxTextureSize differs', () => {
|
||||||
|
const a = createTestConfig();
|
||||||
|
const b = createTestConfig({ deviceMaxTextureSize: 8192 });
|
||||||
|
assert.ok(!configEquals(a, b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when deviceCellWidth differs', () => {
|
||||||
|
const a = createTestConfig();
|
||||||
|
const b = createTestConfig({ deviceCellWidth: 11 });
|
||||||
|
assert.ok(!configEquals(a, b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when deviceCellHeight differs', () => {
|
||||||
|
const a = createTestConfig();
|
||||||
|
const b = createTestConfig({ deviceCellHeight: 21 });
|
||||||
|
assert.ok(!configEquals(a, b));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,7 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return a.devicePixelRatio === b.devicePixelRatio &&
|
return a.devicePixelRatio === b.devicePixelRatio &&
|
||||||
|
a.deviceMaxTextureSize === b.deviceMaxTextureSize &&
|
||||||
a.customGlyphs === b.customGlyphs &&
|
a.customGlyphs === b.customGlyphs &&
|
||||||
a.lineHeight === b.lineHeight &&
|
a.lineHeight === b.lineHeight &&
|
||||||
a.letterSpacing === b.letterSpacing &&
|
a.letterSpacing === b.letterSpacing &&
|
||||||
@@ -67,6 +68,8 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
|||||||
a.fontWeight === b.fontWeight &&
|
a.fontWeight === b.fontWeight &&
|
||||||
a.fontWeightBold === b.fontWeightBold &&
|
a.fontWeightBold === b.fontWeightBold &&
|
||||||
a.allowTransparency === b.allowTransparency &&
|
a.allowTransparency === b.allowTransparency &&
|
||||||
|
a.deviceCellWidth === b.deviceCellWidth &&
|
||||||
|
a.deviceCellHeight === b.deviceCellHeight &&
|
||||||
a.deviceCharWidth === b.deviceCharWidth &&
|
a.deviceCharWidth === b.deviceCharWidth &&
|
||||||
a.deviceCharHeight === b.deviceCharHeight &&
|
a.deviceCharHeight === b.deviceCharHeight &&
|
||||||
a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors &&
|
a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors &&
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
|
|||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject, type IRasterizedGlyph, type ITextureAtlas } from './Types';
|
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject, type IRasterizedGlyph, type ITextureAtlas } from './Types';
|
||||||
import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils';
|
import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils';
|
||||||
import type { IOptionsService } from 'common/services/Services';
|
import type { ILogService, IOptionsService } from 'common/services/Services';
|
||||||
import { allowRescaling, throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
import { allowRescaling, throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||||
|
|
||||||
interface IVertices {
|
interface IVertices {
|
||||||
@@ -78,9 +78,11 @@ void main() {
|
|||||||
}`);
|
}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDICES_PER_CELL = 11;
|
const enum Constants {
|
||||||
const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT;
|
INDICES_PER_CELL = 11,
|
||||||
const CELL_POSITION_INDICES = 2;
|
BYTES_PER_CELL = INDICES_PER_CELL * 4/* Float32Array.BYTES_PER_ELEMENT */,
|
||||||
|
CELL_POSITION_INDICES = 2
|
||||||
|
}
|
||||||
|
|
||||||
// Work variables to avoid garbage collection
|
// Work variables to avoid garbage collection
|
||||||
let $i = 0;
|
let $i = 0;
|
||||||
@@ -98,6 +100,8 @@ export class GlyphRenderer extends Disposable {
|
|||||||
private readonly _attributesBuffer: WebGLBuffer;
|
private readonly _attributesBuffer: WebGLBuffer;
|
||||||
|
|
||||||
private _atlas: ITextureAtlas | undefined;
|
private _atlas: ITextureAtlas | undefined;
|
||||||
|
private _lastAtlasPagesVersion: number = -1;
|
||||||
|
private _pageOverflowWarned: boolean = false;
|
||||||
private _activeBuffer: number = 0;
|
private _activeBuffer: number = 0;
|
||||||
private readonly _vertices: IVertices = {
|
private readonly _vertices: IVertices = {
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -112,7 +116,8 @@ export class GlyphRenderer extends Disposable {
|
|||||||
private readonly _terminal: Terminal,
|
private readonly _terminal: Terminal,
|
||||||
private readonly _gl: IWebGL2RenderingContext,
|
private readonly _gl: IWebGL2RenderingContext,
|
||||||
private _dimensions: IRenderDimensions,
|
private _dimensions: IRenderDimensions,
|
||||||
private readonly _optionsService: IOptionsService
|
private readonly _optionsService: IOptionsService,
|
||||||
|
private readonly _logService: ILogService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@@ -125,7 +130,7 @@ export class GlyphRenderer extends Disposable {
|
|||||||
TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null);
|
TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages)));
|
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages), this._logService));
|
||||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||||
|
|
||||||
// Uniform locations
|
// Uniform locations
|
||||||
@@ -134,8 +139,9 @@ export class GlyphRenderer extends Disposable {
|
|||||||
this._textureLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_texture'));
|
this._textureLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_texture'));
|
||||||
|
|
||||||
// Create and set the vertex array object
|
// Create and set the vertex array object
|
||||||
this._vertexArrayObject = gl.createVertexArray();
|
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||||
gl.bindVertexArray(this._vertexArrayObject);
|
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||||
|
gl.bindVertexArray(vertexArrayObject);
|
||||||
|
|
||||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||||
@@ -160,22 +166,22 @@ export class GlyphRenderer extends Disposable {
|
|||||||
this._register(toDisposable(() => gl.deleteBuffer(this._attributesBuffer)));
|
this._register(toDisposable(() => gl.deleteBuffer(this._attributesBuffer)));
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
|
||||||
gl.enableVertexAttribArray(VertexAttribLocations.OFFSET);
|
gl.enableVertexAttribArray(VertexAttribLocations.OFFSET);
|
||||||
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, BYTES_PER_CELL, 0);
|
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 0);
|
||||||
gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1);
|
gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1);
|
||||||
gl.enableVertexAttribArray(VertexAttribLocations.SIZE);
|
gl.enableVertexAttribArray(VertexAttribLocations.SIZE);
|
||||||
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
|
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
|
||||||
gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1);
|
gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1);
|
||||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXPAGE);
|
gl.enableVertexAttribArray(VertexAttribLocations.TEXPAGE);
|
||||||
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
|
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, Constants.BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
|
||||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXPAGE, 1);
|
gl.vertexAttribDivisor(VertexAttribLocations.TEXPAGE, 1);
|
||||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXCOORD);
|
gl.enableVertexAttribArray(VertexAttribLocations.TEXCOORD);
|
||||||
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
|
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
|
||||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXCOORD, 1);
|
gl.vertexAttribDivisor(VertexAttribLocations.TEXCOORD, 1);
|
||||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXSIZE);
|
gl.enableVertexAttribArray(VertexAttribLocations.TEXSIZE);
|
||||||
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
|
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
|
||||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXSIZE, 1);
|
gl.vertexAttribDivisor(VertexAttribLocations.TEXSIZE, 1);
|
||||||
gl.enableVertexAttribArray(VertexAttribLocations.CELL_POSITION);
|
gl.enableVertexAttribArray(VertexAttribLocations.CELL_POSITION);
|
||||||
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
|
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
|
||||||
gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1);
|
gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1);
|
||||||
|
|
||||||
// Setup static uniforms
|
// Setup static uniforms
|
||||||
@@ -209,25 +215,38 @@ export class GlyphRenderer extends Disposable {
|
|||||||
this.handleResize();
|
this.handleResize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the full model must be rebuilt before rendering this frame.
|
||||||
|
* This is tracked per renderer (not on the shared atlas) so that every
|
||||||
|
* terminal sharing the atlas rebuilds its model after a page merge, not just
|
||||||
|
* the first one to render (vscode#322756).
|
||||||
|
*/
|
||||||
public beginFrame(): boolean {
|
public beginFrame(): boolean {
|
||||||
return this._atlas ? this._atlas.beginFrame() : true;
|
if (!this._atlas) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this._atlas.pagesVersion !== this._lastAtlasPagesVersion) {
|
||||||
|
this._lastAtlasPagesVersion = this._atlas.pagesVersion;
|
||||||
|
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 {
|
public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
|
||||||
// Since this function is called for every cell (`rows*cols`), it must be very optimized. It
|
// Since this function is called for every cell (`rows*cols`), it must be very optimized. It
|
||||||
// should not instantiate any variables unless a new glyph is drawn to the cache where the
|
// should not instantiate any variables unless a new glyph is drawn to the cache where the
|
||||||
// slight slowdown is acceptable for the developer ergonomics provided as it's a once of for
|
// slight slowdown is acceptable for the developer ergonomics provided as it's a one-off for
|
||||||
// each glyph.
|
// each glyph.
|
||||||
this._updateCell(this._vertices.attributes, x, y, code, bg, fg, ext, chars, width, lastBg);
|
this._updateCell(this._vertices.attributes, x, y, code, bg, fg, ext, chars, width, lastBg);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
|
private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
|
||||||
$i = (y * this._terminal.cols + x) * INDICES_PER_CELL;
|
$i = (y * this._terminal.cols + x) * Constants.INDICES_PER_CELL;
|
||||||
|
|
||||||
// Exit early if this is a null character, allow space character to continue as it may have
|
// Exit early if this is a null character, allow space character to continue as it may have
|
||||||
// underline/strikethrough styles
|
// underline/strikethrough styles
|
||||||
if (code === NULL_CELL_CODE || code === undefined/* This is used for the right side of wide chars */) {
|
if (code === NULL_CELL_CODE || code === undefined/* This is used for the right side of wide chars */) {
|
||||||
array.fill(0, $i, $i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES);
|
array.fill(0, $i, $i + Constants.INDICES_PER_CELL - 1 - Constants.CELL_POSITION_INDICES);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +307,7 @@ export class GlyphRenderer extends Disposable {
|
|||||||
|
|
||||||
public clear(): void {
|
public clear(): void {
|
||||||
const terminal = this._terminal;
|
const terminal = this._terminal;
|
||||||
const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL;
|
const newCount = terminal.cols * terminal.rows * Constants.INDICES_PER_CELL;
|
||||||
|
|
||||||
// Clear vertices
|
// Clear vertices
|
||||||
if (this._vertices.count !== newCount) {
|
if (this._vertices.count !== newCount) {
|
||||||
@@ -310,7 +329,7 @@ export class GlyphRenderer extends Disposable {
|
|||||||
for (let x = 0; x < terminal.cols; x++) {
|
for (let x = 0; x < terminal.cols; x++) {
|
||||||
this._vertices.attributes[i + 9] = x / terminal.cols;
|
this._vertices.attributes[i + 9] = x / terminal.cols;
|
||||||
this._vertices.attributes[i + 10] = y / terminal.rows;
|
this._vertices.attributes[i + 10] = y / terminal.rows;
|
||||||
i += INDICES_PER_CELL;
|
i += Constants.INDICES_PER_CELL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,8 +365,8 @@ export class GlyphRenderer extends Disposable {
|
|||||||
// - So we don't send vertices for all the line-ending whitespace to the GPU
|
// - So we don't send vertices for all the line-ending whitespace to the GPU
|
||||||
let bufferLength = 0;
|
let bufferLength = 0;
|
||||||
for (let y = 0; y < renderModel.lineLengths.length; y++) {
|
for (let y = 0; y < renderModel.lineLengths.length; y++) {
|
||||||
const si = y * this._terminal.cols * INDICES_PER_CELL;
|
const si = y * this._terminal.cols * Constants.INDICES_PER_CELL;
|
||||||
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL);
|
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * Constants.INDICES_PER_CELL);
|
||||||
activeBuffer.set(sub, bufferLength);
|
activeBuffer.set(sub, bufferLength);
|
||||||
bufferLength += sub.length;
|
bufferLength += sub.length;
|
||||||
}
|
}
|
||||||
@@ -356,19 +375,35 @@ export class GlyphRenderer extends Disposable {
|
|||||||
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
|
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW);
|
||||||
|
|
||||||
// Bind the atlas page texture if they have changed
|
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
|
||||||
for (let i = 0; i < this._atlas.pages.length; i++) {
|
// monotonic, so a page object swap at the same index (which happens after a page merge)
|
||||||
|
// is detected by the same comparison. The page count is clamped to the fixed texture
|
||||||
|
// array size so an atlas page overflow degrades (untextured glyphs) instead of throwing
|
||||||
|
// mid-frame; the atlas is responsible for never exceeding maxAtlasPages.
|
||||||
|
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) {
|
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
|
||||||
this._bindAtlasPageTexture(gl, this._atlas, i);
|
this._bindAtlasPageTexture(gl, this._atlas, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the viewport
|
// Draw the viewport
|
||||||
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / INDICES_PER_CELL);
|
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / Constants.INDICES_PER_CELL);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setAtlas(atlas: ITextureAtlas): void {
|
public setAtlas(atlas: ITextureAtlas): void {
|
||||||
this._atlas = atlas;
|
this._atlas = atlas;
|
||||||
|
// Force a model rebuild on the next frame as glyph coordinates from a
|
||||||
|
// previous atlas are meaningless in the new one.
|
||||||
|
this._lastAtlasPagesVersion = -1;
|
||||||
|
this.invalidateAtlasTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
public invalidateAtlasTextures(): void {
|
||||||
for (const glTexture of this._atlasTextures) {
|
for (const glTexture of this._atlasTextures) {
|
||||||
glTexture.version = -1;
|
glTexture.version = -1;
|
||||||
}
|
}
|
||||||
@@ -379,8 +414,9 @@ export class GlyphRenderer extends Disposable {
|
|||||||
gl.bindTexture(gl.TEXTURE_2D, this._atlasTextures[i].texture);
|
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_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_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.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;
|
this._atlasTextures[i].version = atlas.pages[i].version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import { Attributes, FgFlags } from 'common/buffer/Constants';
|
|||||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||||
import { IColor } from 'common/Types';
|
import { IColor } from 'common/Types';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel';
|
import { RenderModelConstants } from './RenderModel';
|
||||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
|
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
|
||||||
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
|
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
|
||||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||||
|
import type { ILogService } from 'common/services/Services';
|
||||||
|
|
||||||
const enum VertexAttribLocations {
|
const enum VertexAttribLocations {
|
||||||
POSITION = 0,
|
POSITION = 0,
|
||||||
@@ -89,21 +90,23 @@ export class RectangleRenderer extends Disposable {
|
|||||||
private _terminal: Terminal,
|
private _terminal: Terminal,
|
||||||
private _gl: IWebGL2RenderingContext,
|
private _gl: IWebGL2RenderingContext,
|
||||||
private _dimensions: IRenderDimensions,
|
private _dimensions: IRenderDimensions,
|
||||||
private readonly _themeService: IThemeService
|
private readonly _themeService: IThemeService,
|
||||||
|
private readonly _logService: ILogService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
const gl = this._gl;
|
const gl = this._gl;
|
||||||
|
|
||||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource));
|
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource, this._logService));
|
||||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||||
|
|
||||||
// Uniform locations
|
// Uniform locations
|
||||||
this._projectionLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_projection'));
|
this._projectionLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_projection'));
|
||||||
|
|
||||||
// Create and set the vertex array object
|
// Create and set the vertex array object
|
||||||
this._vertexArrayObject = gl.createVertexArray();
|
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||||
gl.bindVertexArray(this._vertexArrayObject);
|
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||||
|
gl.bindVertexArray(vertexArrayObject);
|
||||||
|
|
||||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||||
@@ -217,9 +220,9 @@ export class RectangleRenderer extends Disposable {
|
|||||||
currentFg = 0;
|
currentFg = 0;
|
||||||
currentInverse = false;
|
currentInverse = false;
|
||||||
for (x = 0; x < terminal.cols; x++) {
|
for (x = 0; x < terminal.cols; x++) {
|
||||||
modelIndex = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
modelIndex = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||||
bg = model.cells[modelIndex + RENDER_MODEL_BG_OFFSET];
|
bg = model.cells[modelIndex + RenderModelConstants.BG_OFFSET];
|
||||||
fg = model.cells[modelIndex + RENDER_MODEL_FG_OFFSET];
|
fg = model.cells[modelIndex + RenderModelConstants.FG_OFFSET];
|
||||||
inverse = !!(fg & FgFlags.INVERSE);
|
inverse = !!(fg & FgFlags.INVERSE);
|
||||||
if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) {
|
if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) {
|
||||||
// A rectangle needs to be drawn if going from non-default to another color
|
// A rectangle needs to be drawn if going from non-default to another color
|
||||||
@@ -336,8 +339,9 @@ export class RectangleRenderer extends Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vertices.attributes.length < offset + 4) {
|
if (vertices.attributes.length < offset + INDICES_PER_RECTANGLE) {
|
||||||
vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE);
|
// +1 for the viewport-clear rectangle at offset 0.
|
||||||
|
vertices.attributes = expandFloat32Array(vertices.attributes, (this._terminal.rows * this._terminal.cols + 1) * INDICES_PER_RECTANGLE);
|
||||||
}
|
}
|
||||||
$x1 = startX * this._dimensions.device.cell.width;
|
$x1 = startX * this._dimensions.device.cell.width;
|
||||||
$y1 = y * this._dimensions.device.cell.height;
|
$y1 = y * this._dimensions.device.cell.height;
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { ICursorRenderModel, IRenderModel } from './Types';
|
|||||||
import { ISelectionRenderModel } from 'browser/renderer/shared/Types';
|
import { ISelectionRenderModel } from 'browser/renderer/shared/Types';
|
||||||
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
|
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
|
||||||
|
|
||||||
export const RENDER_MODEL_INDICIES_PER_CELL = 4;
|
export const enum RenderModelConstants {
|
||||||
export const RENDER_MODEL_BG_OFFSET = 1;
|
INDICIES_PER_CELL = 4,
|
||||||
export const RENDER_MODEL_FG_OFFSET = 2;
|
BG_OFFSET = 1,
|
||||||
export const RENDER_MODEL_EXT_OFFSET = 3;
|
FG_OFFSET = 2,
|
||||||
|
EXT_OFFSET = 3
|
||||||
|
}
|
||||||
|
|
||||||
export const COMBINED_CHAR_BIT_MASK = 0x80000000;
|
export const COMBINED_CHAR_BIT_MASK = 0x80000000;
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ export class RenderModel implements IRenderModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public resize(cols: number, rows: number): void {
|
public resize(cols: number, rows: number): void {
|
||||||
const indexCount = cols * rows * RENDER_MODEL_INDICIES_PER_CELL;
|
const indexCount = cols * rows * RenderModelConstants.INDICIES_PER_CELL;
|
||||||
if (indexCount !== this.cells.length) {
|
if (indexCount !== this.cells.length) {
|
||||||
this.cells = new Uint32Array(indexCount);
|
this.cells = new Uint32Array(indexCount);
|
||||||
this.lineLengths = new Uint32Array(rows);
|
this.lineLengths = new Uint32Array(rows);
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
private _overflowSizePage: AtlasPage | undefined;
|
private _overflowSizePage: AtlasPage | undefined;
|
||||||
|
|
||||||
private _tmpCanvas: HTMLCanvasElement;
|
private _tmpCanvas: HTMLCanvasElement;
|
||||||
// A temporary context that glyphs are drawn to before being transfered to the atlas.
|
// A temporary context that glyphs are drawn to before being transferred to the atlas.
|
||||||
private _tmpCtx: CanvasRenderingContext2D;
|
private _tmpCtx: CanvasRenderingContext2D;
|
||||||
|
|
||||||
private _workBoundingBox: IBoundingBox = { top: 0, left: 0, bottom: 0, right: 0 };
|
private _workBoundingBox: IBoundingBox = { top: 0, left: 0, bottom: 0, right: 0 };
|
||||||
@@ -109,6 +109,7 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
page.canvas.remove();
|
page.canvas.remove();
|
||||||
}
|
}
|
||||||
this._onAddTextureAtlasCanvas.dispose();
|
this._onAddTextureAtlasCanvas.dispose();
|
||||||
|
this._onRemoveTextureAtlasCanvas.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
public warmUp(): void {
|
public warmUp(): void {
|
||||||
@@ -131,10 +132,16 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _requestClearModel = false;
|
/**
|
||||||
public beginFrame(): boolean {
|
* Incremented whenever the structure of the pages array changes in a way that
|
||||||
return this._requestClearModel;
|
* invalidates texture page indexes/coordinates cached outside the atlas (page
|
||||||
}
|
* merges, overflow page creation). Renderers compare this against their own
|
||||||
|
* last-seen value to decide when to rebuild their models; the atlas is shared
|
||||||
|
* across terminals, so a consume-once flag would only repair the first
|
||||||
|
* renderer to draw (see the shared-atlas garble in vscode#322756).
|
||||||
|
*/
|
||||||
|
private _pagesVersion = 0;
|
||||||
|
public get pagesVersion(): number { return this._pagesVersion; }
|
||||||
|
|
||||||
public clearTexture(): void {
|
public clearTexture(): void {
|
||||||
if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
|
if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) {
|
||||||
@@ -149,12 +156,12 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _createNewPage(): AtlasPage {
|
private _createNewPage(): AtlasPage {
|
||||||
// Try merge the set of the 4 most used pages of the largest size. This is is deferred to a
|
// Try merge the set of the 4 most used pages of the largest size. This is deferred to a
|
||||||
// microtask to ensure it does not interrupt textures that will be rendered in the current
|
// microtask to ensure it does not interrupt textures that will be rendered in the current
|
||||||
// animation frame which would result in blank rendered areas. This is actually not that
|
// animation frame which would result in blank rendered areas. This is actually not that
|
||||||
// expensive relative to drawing the glyphs, so there is no need to wait for an idle callback.
|
// expensive relative to drawing the glyphs, so there is no need to wait for an idle callback.
|
||||||
if (TextureAtlas.maxAtlasPages && this._pages.length >= Math.max(4, TextureAtlas.maxAtlasPages)) {
|
if (TextureAtlas.maxAtlasPages && this._pages.length >= Math.max(4, TextureAtlas.maxAtlasPages)) {
|
||||||
// Find the set of the largest 4 images, below the maximum size, with the highest
|
// Find the set of the largest images, below the maximum size, with the highest
|
||||||
// percentages used
|
// percentages used
|
||||||
const pagesBySize = this._pages.filter(e => {
|
const pagesBySize = this._pages.filter(e => {
|
||||||
return e.canvas.width * 2 <= (TextureAtlas.maxTextureSize || Constants.FORCED_MAX_TEXTURE_SIZE);
|
return e.canvas.width * 2 <= (TextureAtlas.maxTextureSize || Constants.FORCED_MAX_TEXTURE_SIZE);
|
||||||
@@ -164,48 +171,55 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
}
|
}
|
||||||
return b.percentageUsed - a.percentageUsed;
|
return b.percentageUsed - a.percentageUsed;
|
||||||
});
|
});
|
||||||
let sameSizeI = -1;
|
|
||||||
let size = 0;
|
// Find the largest group (up to 4) of same-sized pages, preferring larger page sizes. A
|
||||||
for (let i = 0; i < pagesBySize.length; i++) {
|
// group of 2 or 3 still reduces (or at worst maintains) the page count net of the new page
|
||||||
if (pagesBySize[i].canvas.width !== size) {
|
// pushed below; growing past the cap is never an option as GlyphRenderer's texture and
|
||||||
sameSizeI = i;
|
// shader sampler arrays are sized to exactly maxAtlasPages (exceeding them crashes render).
|
||||||
size = pagesBySize[i].canvas.width;
|
let bestGroupStart = -1;
|
||||||
} else if (i - sameSizeI === 3) {
|
let bestGroupSize = 0;
|
||||||
break;
|
let groupStart = 0;
|
||||||
|
for (let i = 1; i <= pagesBySize.length; i++) {
|
||||||
|
if (i === pagesBySize.length || pagesBySize[i].canvas.width !== pagesBySize[groupStart].canvas.width) {
|
||||||
|
const groupSize = Math.min(4, i - groupStart);
|
||||||
|
if (groupSize > bestGroupSize) {
|
||||||
|
bestGroupSize = groupSize;
|
||||||
|
bestGroupStart = groupStart;
|
||||||
|
if (groupSize === 4) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groupStart = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gather details of the merge
|
if (bestGroupSize >= 2) {
|
||||||
const mergingPages = pagesBySize.slice(sameSizeI, sameSizeI + 4);
|
// Gather details of the merge
|
||||||
|
const mergingPages = pagesBySize.slice(bestGroupStart, bestGroupStart + bestGroupSize);
|
||||||
|
const sortedMergingPagesIndexes = mergingPages.map(e => this._pages.indexOf(e)).sort((a, b) => a - b);
|
||||||
|
const mergedPageIndex = this.pages.length - mergingPages.length;
|
||||||
|
|
||||||
// Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot
|
// Merge into the new page
|
||||||
// effectively reduce page count and merging would cause issues.
|
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
|
||||||
if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) {
|
mergedPage.version = ++AtlasPage.nextVersion;
|
||||||
const newPage = new AtlasPage(this._document, this._textureSize);
|
|
||||||
this._pages.push(newPage);
|
// Delete the pages, shifting glyph texture pages as needed
|
||||||
this._activePages.push(newPage);
|
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
|
||||||
this._onAddTextureAtlasCanvas.fire(newPage.canvas);
|
this._deletePage(sortedMergingPagesIndexes[i]);
|
||||||
return newPage;
|
}
|
||||||
|
|
||||||
|
// Add the new merged page to the end
|
||||||
|
this.pages.push(mergedPage);
|
||||||
|
|
||||||
|
// Invalidate models so all texture pages are refreshed.
|
||||||
|
this._pagesVersion++;
|
||||||
|
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
|
||||||
|
} else {
|
||||||
|
// No two same-sized pages below the maximum size exist, so merging cannot reduce the
|
||||||
|
// page count. Evict all pages rather than exceed the renderers' fixed texture/sampler
|
||||||
|
// capacity; glyphs re-rasterize on demand and models rebuild via pagesVersion.
|
||||||
|
this._evictAllPages();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a > b ? 1 : -1);
|
|
||||||
const mergedPageIndex = this.pages.length - mergingPages.length;
|
|
||||||
|
|
||||||
// Merge into the new page
|
|
||||||
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
|
|
||||||
mergedPage.version++;
|
|
||||||
|
|
||||||
// Delete the pages, shifting glyph texture pages as needed
|
|
||||||
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
|
|
||||||
this._deletePage(sortedMergingPagesIndexes[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
this._onAddTextureAtlasCanvas.fire(mergedPage.canvas);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All new atlas pages are created small as they are highly dynamic
|
// All new atlas pages are created small as they are highly dynamic
|
||||||
@@ -251,10 +265,31 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
for (const g of adjustingPage.glyphs) {
|
for (const g of adjustingPage.glyphs) {
|
||||||
g.texturePage--;
|
g.texturePage--;
|
||||||
}
|
}
|
||||||
adjustingPage.version++;
|
adjustingPage.version = ++AtlasPage.nextVersion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes every page and clears the glyph caches. This is the last resort when the page cap is
|
||||||
|
* reached but no same-sized pages exist to merge: the page count must never exceed
|
||||||
|
* maxAtlasPages because renderers size their texture and shader sampler arrays to exactly
|
||||||
|
* that. Glyphs re-rasterize on demand and renderers rebuild their models via
|
||||||
|
* {@link pagesVersion}.
|
||||||
|
*/
|
||||||
|
private _evictAllPages(): void {
|
||||||
|
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._pagesVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph {
|
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);
|
return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext, restrictToCellHeight, domContainer);
|
||||||
}
|
}
|
||||||
@@ -527,7 +562,7 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
let customGlyph = false;
|
let customGlyph = false;
|
||||||
if (this._config.customGlyphs !== false) {
|
if (this._config.customGlyphs !== false) {
|
||||||
const variantOffset = this._workAttributeData.getUnderlineVariantOffset();
|
const variantOffset = this._workAttributeData.getUnderlineVariantOffset();
|
||||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset);
|
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, this._logService, backgroundColor.css, variantOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether to clear pixels based on a threshold difference between the glyph color and the
|
// Whether to clear pixels based on a threshold difference between the glyph color and the
|
||||||
@@ -815,8 +850,8 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
|
this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize);
|
||||||
this.pages.push(this._overflowSizePage);
|
this.pages.push(this._overflowSizePage);
|
||||||
|
|
||||||
// Request the model to be cleared to refresh all texture pages.
|
// Invalidate models so all texture pages are refreshed.
|
||||||
this._requestClearModel = true;
|
this._pagesVersion++;
|
||||||
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
|
this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas);
|
||||||
}
|
}
|
||||||
activePage = this._overflowSizePage;
|
activePage = this._overflowSizePage;
|
||||||
@@ -937,7 +972,7 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
rasterizedGlyph.size.y
|
rasterizedGlyph.size.y
|
||||||
);
|
);
|
||||||
activePage.addGlyph(rasterizedGlyph);
|
activePage.addGlyph(rasterizedGlyph);
|
||||||
activePage.version++;
|
activePage.version = ++AtlasPage.nextVersion;
|
||||||
|
|
||||||
return rasterizedGlyph;
|
return rasterizedGlyph;
|
||||||
}
|
}
|
||||||
@@ -1047,9 +1082,13 @@ class AtlasPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to check whether the canvas of the atlas page has changed.
|
* Monotonically increasing across all atlas pages globally. Used to detect when the texture
|
||||||
|
* unit at a given index needs to be re-uploaded — both for content changes within the same
|
||||||
|
* page and for a page object swap at the same index (which happens after a page merge,
|
||||||
|
* where a per-page counter could coincide with the previously-bound page's value).
|
||||||
*/
|
*/
|
||||||
public version = 0;
|
public static nextVersion: number = 0;
|
||||||
|
public version = ++AtlasPage.nextVersion;
|
||||||
|
|
||||||
// Texture atlas current positioning data. The texture packing strategy used is to fill from
|
// Texture atlas current positioning data. The texture packing strategy used is to fill from
|
||||||
// left-to-right and top-to-bottom. When the glyph being written is less than half of the current
|
// left-to-right and top-to-bottom. When the glyph being written is less than half of the current
|
||||||
@@ -1073,17 +1112,33 @@ class AtlasPage {
|
|||||||
size: number,
|
size: number,
|
||||||
sourcePages?: AtlasPage[]
|
sourcePages?: AtlasPage[]
|
||||||
) {
|
) {
|
||||||
if (sourcePages) {
|
|
||||||
for (const p of sourcePages) {
|
|
||||||
this._glyphs.push(...p.glyphs);
|
|
||||||
this._usedPixels += p._usedPixels;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.canvas = createCanvas(document, size, size);
|
this.canvas = createCanvas(document, size, size);
|
||||||
// The canvas needs alpha because we use clearColor to convert the background color to alpha.
|
// 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
|
// It might also contain some characters with transparent backgrounds if allowTransparency is
|
||||||
// set.
|
// set.
|
||||||
this.ctx = throwIfFalsy(this.canvas.getContext('2d', { alpha: true }));
|
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 {
|
||||||
|
// 2- and 3-page merges, used when no 4-page same-size set exists at the page cap
|
||||||
|
for (let i = 0; i < sourcePages.length; ++i) {
|
||||||
|
this._glyphs = this._glyphs.concat(sourcePages[i].glyphs);
|
||||||
|
this._usedPixels += sourcePages[i]._usedPixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear(): void {
|
public clear(): void {
|
||||||
@@ -1092,7 +1147,7 @@ class AtlasPage {
|
|||||||
this.currentRow.y = 0;
|
this.currentRow.y = 0;
|
||||||
this.currentRow.height = 0;
|
this.currentRow.height = 0;
|
||||||
this.fixedRows.length = 0;
|
this.fixedRows.length = 0;
|
||||||
this.version++;
|
this.version = ++AtlasPage.nextVersion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,7 +1173,7 @@ function clearColor(imageData: ImageData, bg: IColor, fg: IColor, enableThreshol
|
|||||||
// were covered (fg=#8ae234, bg=#c4a000).
|
// were covered (fg=#8ae234, bg=#c4a000).
|
||||||
const threshold = Math.floor((Math.abs(r - fgR) + Math.abs(g - fgG) + Math.abs(b - fgB)) / 12);
|
const threshold = Math.floor((Math.abs(r - fgR) + Math.abs(g - fgG) + Math.abs(b - fgB)) / 12);
|
||||||
|
|
||||||
// Set alpha channel of relevent pixels to 0
|
// Set alpha channel of relevant pixels to 0
|
||||||
let isEmpty = true;
|
let isEmpty = true;
|
||||||
for (let offset = 0; offset < imageData.data.length; offset += 4) {
|
for (let offset = 0; offset < imageData.data.length; offset += 4) {
|
||||||
// Check exact match
|
// Check exact match
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ICursorRenderModel {
|
|||||||
export interface IWebGL2RenderingContext extends WebGLRenderingContext {
|
export interface IWebGL2RenderingContext extends WebGLRenderingContext {
|
||||||
vertexAttribDivisor(index: number, divisor: number): void;
|
vertexAttribDivisor(index: number, divisor: number): void;
|
||||||
createVertexArray(): IWebGLVertexArrayObject;
|
createVertexArray(): IWebGLVertexArrayObject;
|
||||||
|
deleteVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||||
bindVertexArray(vao: IWebGLVertexArrayObject): void;
|
bindVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||||
drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void;
|
drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void;
|
||||||
}
|
}
|
||||||
@@ -67,10 +68,13 @@ export interface ITextureAtlas extends IDisposable {
|
|||||||
warmUp(): void;
|
warmUp(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call when a frame is being drawn, this will return true if the atlas was cleared to make room
|
* Incremented whenever the pages array changes structurally (page merge,
|
||||||
* for a new set of glyphs.
|
* overflow page creation), invalidating any texture page indexes/coordinates
|
||||||
|
* cached by renderers. Tracked as a version rather than a consumable flag
|
||||||
|
* because the atlas may be shared by multiple renderers, each of which must
|
||||||
|
* notice the change independently.
|
||||||
*/
|
*/
|
||||||
beginFrame(): boolean;
|
readonly pagesVersion: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all glyphs from the texture atlas.
|
* Clear all glyphs from the texture atlas.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRender
|
|||||||
import { ITerminal } from 'browser/Types';
|
import { ITerminal } from 'browser/Types';
|
||||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||||
import { getSafariVersion, isSafari } from 'common/Platform';
|
import { getSafariVersion, isSafari } from 'common/Platform';
|
||||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||||
import { IWebGL2RenderingContext } from './Types';
|
import { IWebGL2RenderingContext } from './Types';
|
||||||
import { WebglRenderer } from './WebglRenderer';
|
import { WebglRenderer } from './WebglRenderer';
|
||||||
import { Emitter, EventUtils } from 'common/Event';
|
import { Emitter, EventUtils } from 'common/Event';
|
||||||
@@ -40,7 +40,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
|||||||
};
|
};
|
||||||
const gl = document.createElement('canvas').getContext('webgl2', contextAttributes) as IWebGL2RenderingContext;
|
const gl = document.createElement('canvas').getContext('webgl2', contextAttributes) as IWebGL2RenderingContext;
|
||||||
if (!gl) {
|
if (!gl) {
|
||||||
throw new Error('Webgl2 is only supported on Safari 16 and above');
|
throw new Error('WebGL2 is only supported on Safari 16 and above');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
super();
|
super();
|
||||||
@@ -65,6 +65,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
|||||||
const charSizeService: ICharSizeService = unsafeCore._charSizeService;
|
const charSizeService: ICharSizeService = unsafeCore._charSizeService;
|
||||||
const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService;
|
const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService;
|
||||||
const decorationService: IDecorationService = unsafeCore._decorationService;
|
const decorationService: IDecorationService = unsafeCore._decorationService;
|
||||||
|
const logService: ILogService = unsafeCore._logService;
|
||||||
const themeService: IThemeService = unsafeCore._themeService;
|
const themeService: IThemeService = unsafeCore._themeService;
|
||||||
|
|
||||||
this._renderer = this._register(new WebglRenderer(
|
this._renderer = this._register(new WebglRenderer(
|
||||||
@@ -74,6 +75,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
|||||||
coreBrowserService,
|
coreBrowserService,
|
||||||
coreService,
|
coreService,
|
||||||
decorationService,
|
decorationService,
|
||||||
|
logService,
|
||||||
optionsService,
|
optionsService,
|
||||||
themeService,
|
themeService,
|
||||||
this._customGlyphs,
|
this._customGlyphs,
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ import { CursorBlinkStateManager } from './CursorBlinkStateManager';
|
|||||||
import { observeDevicePixelDimensions } from './DevicePixelObserver';
|
import { observeDevicePixelDimensions } from './DevicePixelObserver';
|
||||||
import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types';
|
import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types';
|
||||||
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
||||||
import { CharData, IBufferLine, ICellData } from 'common/Types';
|
import { CharData, IBufferLine, ICellData } from 'common/buffer/Types';
|
||||||
import { AttributeData } from 'common/buffer/AttributeData';
|
import { AttributeData } from 'common/buffer/AttributeData';
|
||||||
import { CellData } from 'common/buffer/CellData';
|
import { CellData } from 'common/buffer/CellData';
|
||||||
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
|
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||||
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
||||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { GlyphRenderer } from './GlyphRenderer';
|
import { GlyphRenderer } from './GlyphRenderer';
|
||||||
import { RectangleRenderer } from './RectangleRenderer';
|
import { RectangleRenderer } from './RectangleRenderer';
|
||||||
import { COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_EXT_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL, RenderModel } from './RenderModel';
|
import { COMBINED_CHAR_BIT_MASK, RenderModel, RenderModelConstants } from './RenderModel';
|
||||||
import { IWebGL2RenderingContext, type ITextureAtlas } from './Types';
|
import { IWebGL2RenderingContext, type ITextureAtlas } from './Types';
|
||||||
import { LinkRenderLayer } from './renderLayer/LinkRenderLayer';
|
import { LinkRenderLayer } from './renderLayer/LinkRenderLayer';
|
||||||
import { IRenderLayer } from './renderLayer/Types';
|
import { IRenderLayer } from './renderLayer/Types';
|
||||||
@@ -28,9 +28,13 @@ import { addDisposableListener } from 'browser/Dom';
|
|||||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||||
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
|
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
|
||||||
|
|
||||||
|
const enum Constants {
|
||||||
|
MERGE_RETRY_LIMIT = 32
|
||||||
|
}
|
||||||
|
|
||||||
export class WebglRenderer extends Disposable implements IRenderer {
|
export class WebglRenderer extends Disposable implements IRenderer {
|
||||||
private _renderLayers: IRenderLayer[];
|
private _renderLayers: IRenderLayer[];
|
||||||
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = new MutableDisposable();
|
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this._register(new MutableDisposable());
|
||||||
private _textBlinkStateManager: TextBlinkStateManager;
|
private _textBlinkStateManager: TextBlinkStateManager;
|
||||||
private _charAtlasDisposable = this._register(new MutableDisposable());
|
private _charAtlasDisposable = this._register(new MutableDisposable());
|
||||||
private _charAtlas: ITextureAtlas | undefined;
|
private _charAtlas: ITextureAtlas | undefined;
|
||||||
@@ -73,6 +77,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
private readonly _coreBrowserService: ICoreBrowserService,
|
private readonly _coreBrowserService: ICoreBrowserService,
|
||||||
private readonly _coreService: ICoreService,
|
private readonly _coreService: ICoreService,
|
||||||
private readonly _decorationService: IDecorationService,
|
private readonly _decorationService: IDecorationService,
|
||||||
|
private readonly _logService: ILogService,
|
||||||
private readonly _optionsService: IOptionsService,
|
private readonly _optionsService: IOptionsService,
|
||||||
private readonly _themeService: IThemeService,
|
private readonly _themeService: IThemeService,
|
||||||
private readonly _customGlyphs: boolean = true,
|
private readonly _customGlyphs: boolean = true,
|
||||||
@@ -118,19 +123,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
|
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
|
||||||
|
|
||||||
this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => {
|
this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => {
|
||||||
console.log('webglcontextlost event received');
|
this._logService.debug('webglcontextlost event received');
|
||||||
// Prevent the default behavior in order to enable WebGL context restoration.
|
// Prevent the default behavior in order to enable WebGL context restoration.
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Wait a few seconds to see if the 'webglcontextrestored' event is fired.
|
// Wait a few seconds to see if the 'webglcontextrestored' event is fired.
|
||||||
// If not, dispatch the onContextLoss notification to observers.
|
// If not, dispatch the onContextLoss notification to observers.
|
||||||
this._contextRestorationTimeout = setTimeout(() => {
|
this._contextRestorationTimeout = setTimeout(() => {
|
||||||
this._contextRestorationTimeout = undefined;
|
this._contextRestorationTimeout = undefined;
|
||||||
console.warn('webgl context not restored; firing onContextLoss');
|
this._logService.warn('webgl context not restored; firing onContextLoss');
|
||||||
this._onContextLoss.fire(e);
|
this._onContextLoss.fire(e);
|
||||||
}, 3000 /* ms */);
|
}, 3000 /* ms */);
|
||||||
}));
|
}));
|
||||||
this._register(addDisposableListener(this._canvas, 'webglcontextrestored', (e) => {
|
this._register(addDisposableListener(this._canvas, 'webglcontextrestored', (e) => {
|
||||||
console.warn('webglcontextrestored event received');
|
this._logService.warn('webglcontextrestored event received');
|
||||||
clearTimeout(this._contextRestorationTimeout);
|
clearTimeout(this._contextRestorationTimeout);
|
||||||
this._contextRestorationTimeout = undefined;
|
this._contextRestorationTimeout = undefined;
|
||||||
// The texture atlas and glyph renderer must be fully reinitialized
|
// The texture atlas and glyph renderer must be fully reinitialized
|
||||||
@@ -154,6 +159,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
this._isAttached = this._core.screenElement!.isConnected;
|
this._isAttached = this._core.screenElement!.isConnected;
|
||||||
|
|
||||||
this._register(toDisposable(() => {
|
this._register(toDisposable(() => {
|
||||||
|
clearTimeout(this._contextRestorationTimeout);
|
||||||
|
this._contextRestorationTimeout = undefined;
|
||||||
for (const l of this._renderLayers) {
|
for (const l of this._renderLayers) {
|
||||||
l.dispose();
|
l.dispose();
|
||||||
}
|
}
|
||||||
@@ -270,8 +277,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
* Initializes members dependent on WebGL context state.
|
* Initializes members dependent on WebGL context state.
|
||||||
*/
|
*/
|
||||||
private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] {
|
private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] {
|
||||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService);
|
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService, this._logService);
|
||||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService);
|
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService, this._logService);
|
||||||
|
|
||||||
// Update dimensions and acquire char atlas
|
// Update dimensions and acquire char atlas
|
||||||
this.handleCharSizeChanged();
|
this.handleCharSizeChanged();
|
||||||
@@ -375,6 +382,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
this._updateModel(start, end);
|
this._updateModel(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A mid-update atlas page merge invalidates vertex data and may not bump the host
|
||||||
|
// page's version, so re-run the update and force a full texture rebind.
|
||||||
|
let merged = false;
|
||||||
|
let mergeRetries = 0;
|
||||||
|
while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
|
||||||
|
merged = true;
|
||||||
|
this._clearModel(true);
|
||||||
|
this._updateModel(0, this._terminal.rows - 1);
|
||||||
|
}
|
||||||
|
if (merged) {
|
||||||
|
this._glyphRenderer.value.invalidateAtlasTextures();
|
||||||
|
}
|
||||||
|
|
||||||
// Render
|
// Render
|
||||||
this._rectangleRenderer.value.renderBackgrounds();
|
this._rectangleRenderer.value.renderBackgrounds();
|
||||||
this._glyphRenderer.value.render(this._model);
|
this._glyphRenderer.value.render(this._model);
|
||||||
@@ -407,8 +427,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
let line: IBufferLine;
|
let line: IBufferLine;
|
||||||
let joinedRanges: [number, number][];
|
let joinedRanges: [number, number][];
|
||||||
let isJoined: boolean;
|
let isJoined: boolean;
|
||||||
let skipJoinedCheckUntilX: number = 0;
|
let skipJoinedCheckUntilX: number;
|
||||||
let isValidJoinRange: boolean = true;
|
let isValidJoinRange: boolean;
|
||||||
let lastCharX: number;
|
let lastCharX: number;
|
||||||
let range: [number, number];
|
let range: [number, number];
|
||||||
let isCursorRow: boolean;
|
let isCursorRow: boolean;
|
||||||
@@ -436,7 +456,18 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
|
|
||||||
for (y = start; y <= end; y++) {
|
for (y = start; y <= end; y++) {
|
||||||
row = y + terminal.buffer.ydisp;
|
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;
|
let rowHasBlinkingCells = false;
|
||||||
this._model.lineLengths[y] = 0;
|
this._model.lineLengths[y] = 0;
|
||||||
isCursorRow = cursorY === row;
|
isCursorRow = cursorY === row;
|
||||||
@@ -493,7 +524,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
|
|
||||||
chars = cell.getChars();
|
chars = cell.getChars();
|
||||||
code = cell.getCode();
|
code = cell.getCode();
|
||||||
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
i = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||||
|
|
||||||
if (!rowHasBlinkingCells && cell.isBlink()) {
|
if (!rowHasBlinkingCells && cell.isBlink()) {
|
||||||
rowHasBlinkingCells = true;
|
rowHasBlinkingCells = true;
|
||||||
@@ -538,9 +569,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
|
|
||||||
// Nothing has changed, no updates needed
|
// Nothing has changed, no updates needed
|
||||||
if (this._model.cells[i] === code &&
|
if (this._model.cells[i] === code &&
|
||||||
this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._cellColorResolver.result.bg &&
|
this._model.cells[i + RenderModelConstants.BG_OFFSET] === this._cellColorResolver.result.bg &&
|
||||||
this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._cellColorResolver.result.fg &&
|
this._model.cells[i + RenderModelConstants.FG_OFFSET] === this._cellColorResolver.result.fg &&
|
||||||
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] === this._cellColorResolver.result.ext) {
|
this._model.cells[i + RenderModelConstants.EXT_OFFSET] === this._cellColorResolver.result.ext) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,9 +584,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
|
|
||||||
// Cache the results in the model
|
// Cache the results in the model
|
||||||
this._model.cells[i] = code;
|
this._model.cells[i] = code;
|
||||||
this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
|
this._model.cells[i + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||||
this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
|
this._model.cells[i + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||||
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
|
this._model.cells[i + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||||
|
|
||||||
width = cell.getWidth();
|
width = cell.getWidth();
|
||||||
this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg);
|
this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg);
|
||||||
@@ -566,14 +597,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
|
|
||||||
// Null out non-first cells
|
// Null out non-first cells
|
||||||
for (x++; x <= lastCharX; x++) {
|
for (x++; x <= lastCharX; x++) {
|
||||||
j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
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
|
// Don't re-resolve the cell color since multi-colored ligature backgrounds are not
|
||||||
// supported
|
// supported
|
||||||
this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
|
this._nullModelCell(x, y, j, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext);
|
||||||
this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
|
|
||||||
this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
|
|
||||||
}
|
}
|
||||||
x--; // Go back to the previous update cell for next iteration
|
x--; // Go back to the previous update cell for next iteration
|
||||||
}
|
}
|
||||||
@@ -587,6 +614,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
this._updateTextBlinkState();
|
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 {
|
private _resetBlinkingRowState(): void {
|
||||||
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
|
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
|
||||||
this._rowHasBlinkingCellsCount = 0;
|
this._rowHasBlinkingCellsCount = 0;
|
||||||
@@ -644,10 +679,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
|||||||
this.dimensions.device.canvas.height = this._terminal.rows * this.dimensions.device.cell.height;
|
this.dimensions.device.canvas.height = this._terminal.rows * this.dimensions.device.cell.height;
|
||||||
this.dimensions.device.canvas.width = this._terminal.cols * this.dimensions.device.cell.width;
|
this.dimensions.device.canvas.width = this._terminal.cols * this.dimensions.device.cell.width;
|
||||||
|
|
||||||
// The the size of the canvas on the page. It's important that this rounds to nearest integer
|
// The size of the canvas on the page. It's important that this rounds to nearest integer
|
||||||
// and not ceils as browsers often have floating point precision issues where
|
// and not ceils as browsers often have floating point precision issues where
|
||||||
// `window.devicePixelRatio` ends up being something like `1.100000023841858` for example, when
|
// `window.devicePixelRatio` ends up being something like `1.100000023841858` for example, when
|
||||||
// it's actually 1.1. Ceiling may causes blurriness as the backing canvas image is 1 pixel too
|
// it's actually 1.1. Ceiling may cause blurriness as the backing canvas image is 1 pixel too
|
||||||
// large for the canvas element size.
|
// large for the canvas element size.
|
||||||
this.dimensions.css.canvas.height = Math.round(this.dimensions.device.canvas.height / this._devicePixelRatio);
|
this.dimensions.css.canvas.height = Math.round(this.dimensions.device.canvas.height / this._devicePixelRatio);
|
||||||
this.dimensions.css.canvas.width = Math.round(this.dimensions.device.canvas.width / this._devicePixelRatio);
|
this.dimensions.css.canvas.width = Math.round(this.dimensions.device.canvas.width / this._devicePixelRatio);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||||
|
import type { ILogService } from 'common/services/Services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A matrix that when multiplies will translate 0-1 coordinates (left to right,
|
* A matrix that when multiplies will translate 0-1 coordinates (left to right,
|
||||||
@@ -16,21 +17,21 @@ export const PROJECTION_MATRIX = new Float32Array([
|
|||||||
-1, 1, 0, 1
|
-1, 1, 0, 1
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | undefined {
|
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string, logService: ILogService): WebGLProgram | undefined {
|
||||||
const program = throwIfFalsy(gl.createProgram());
|
const program = throwIfFalsy(gl.createProgram());
|
||||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource)));
|
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource, logService)));
|
||||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)));
|
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource, logService)));
|
||||||
gl.linkProgram(program);
|
gl.linkProgram(program);
|
||||||
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
|
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
|
||||||
if (success) {
|
if (success) {
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(gl.getProgramInfoLog(program));
|
logService.error(gl.getProgramInfoLog(program));
|
||||||
gl.deleteProgram(program);
|
gl.deleteProgram(program);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined {
|
export function createShader(gl: WebGLRenderingContext, type: number, source: string, logService: ILogService): WebGLShader | undefined {
|
||||||
const shader = throwIfFalsy(gl.createShader(type));
|
const shader = throwIfFalsy(gl.createShader(type));
|
||||||
gl.shaderSource(shader, source);
|
gl.shaderSource(shader, source);
|
||||||
gl.compileShader(shader);
|
gl.compileShader(shader);
|
||||||
@@ -39,7 +40,7 @@ export function createShader(gl: WebGLRenderingContext, type: number, source: st
|
|||||||
return shader;
|
return shader;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(gl.getShaderInfoLog(shader));
|
logService.error(gl.getShaderInfoLog(shader));
|
||||||
gl.deleteShader(shader);
|
gl.deleteShader(shader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphCharacterDefinition, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition } from './Types';
|
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphCharacterDefinition, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition } from './Types';
|
||||||
|
|
||||||
/* eslint-disable max-len */
|
/* oxlint-disable @stylistic/max-len -- generated glyph path data */
|
||||||
|
|
||||||
const enum Shapes {
|
const enum Shapes {
|
||||||
/** │ */ TOP_TO_BOTTOM = 'M.5,0 L.5,1',
|
/** │ */ TOP_TO_BOTTOM = 'M.5,0 L.5,1',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||||
|
import type { ILogService } from 'common/services/Services';
|
||||||
import { customGlyphDefinitions } from './CustomGlyphDefinitions';
|
import { customGlyphDefinitions } from './CustomGlyphDefinitions';
|
||||||
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
|
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export function tryDrawCustomGlyph(
|
|||||||
deviceCharHeight: number,
|
deviceCharHeight: number,
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
devicePixelRatio: number,
|
devicePixelRatio: number,
|
||||||
|
logService: ILogService,
|
||||||
backgroundColor?: string,
|
backgroundColor?: string,
|
||||||
variantOffset: number = 0
|
variantOffset: number = 0
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -30,7 +32,7 @@ export function tryDrawCustomGlyph(
|
|||||||
// Normalize to array for uniform handling
|
// Normalize to array for uniform handling
|
||||||
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
|
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset);
|
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, logService, backgroundColor, variantOffset);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,7 @@ function drawDefinitionPart(
|
|||||||
deviceCharHeight: number,
|
deviceCharHeight: number,
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
devicePixelRatio: number,
|
devicePixelRatio: number,
|
||||||
|
logService: ILogService,
|
||||||
backgroundColor?: string,
|
backgroundColor?: string,
|
||||||
variantOffset: number = 0
|
variantOffset: number = 0
|
||||||
): void {
|
): void {
|
||||||
@@ -79,7 +82,7 @@ function drawDefinitionPart(
|
|||||||
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset);
|
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset);
|
||||||
break;
|
break;
|
||||||
case CustomGlyphDefinitionType.PATH_FUNCTION:
|
case CustomGlyphDefinitionType.PATH_FUNCTION:
|
||||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, logService, part.strokeWidth);
|
||||||
break;
|
break;
|
||||||
case CustomGlyphDefinitionType.PATH:
|
case CustomGlyphDefinitionType.PATH:
|
||||||
drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||||
@@ -88,7 +91,7 @@ function drawDefinitionPart(
|
|||||||
drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor);
|
drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor);
|
||||||
break;
|
break;
|
||||||
case CustomGlyphDefinitionType.VECTOR_SHAPE:
|
case CustomGlyphDefinitionType.VECTOR_SHAPE:
|
||||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio);
|
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio, logService);
|
||||||
break;
|
break;
|
||||||
case CustomGlyphDefinitionType.BRAILLE:
|
case CustomGlyphDefinitionType.BRAILLE:
|
||||||
drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
|
drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
|
||||||
@@ -218,8 +221,8 @@ function drawPathDefinitionCharacter(
|
|||||||
const rx = parseFloat(args[0]) * deviceCellWidth;
|
const rx = parseFloat(args[0]) * deviceCellWidth;
|
||||||
const ry = parseFloat(args[1]) * deviceCellHeight;
|
const ry = parseFloat(args[1]) * deviceCellHeight;
|
||||||
const xAxisRotation = parseFloat(args[2]) * Math.PI / 180;
|
const xAxisRotation = parseFloat(args[2]) * Math.PI / 180;
|
||||||
const largeArcFlag = parseInt(args[3]);
|
const largeArcFlag = parseInt(args[3], 10);
|
||||||
const sweepFlag = parseInt(args[4]);
|
const sweepFlag = parseInt(args[4], 10);
|
||||||
const x = xOffset + parseFloat(args[5]) * deviceCellWidth;
|
const x = xOffset + parseFloat(args[5]) * deviceCellWidth;
|
||||||
const y = yOffset + parseFloat(args[6]) * deviceCellHeight;
|
const y = yOffset + parseFloat(args[6]) * deviceCellHeight;
|
||||||
drawSvgArc(ctx, currentX, currentY, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y);
|
drawSvgArc(ctx, currentX, currentY, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y);
|
||||||
@@ -510,6 +513,7 @@ function drawPathFunctionCharacter(
|
|||||||
deviceCellWidth: number,
|
deviceCellWidth: number,
|
||||||
deviceCellHeight: number,
|
deviceCellHeight: number,
|
||||||
devicePixelRatio: number,
|
devicePixelRatio: number,
|
||||||
|
logService: ILogService,
|
||||||
strokeWidth?: number
|
strokeWidth?: number
|
||||||
): void {
|
): void {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
@@ -536,7 +540,7 @@ function drawPathFunctionCharacter(
|
|||||||
}
|
}
|
||||||
const f = svgToCanvasInstructionMap[type];
|
const f = svgToCanvasInstructionMap[type];
|
||||||
if (!f) {
|
if (!f) {
|
||||||
console.error(`Could not find drawing instructions for "${type}"`);
|
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const args: string[] = instruction.substring(1).split(',');
|
const args: string[] = instruction.substring(1).split(',');
|
||||||
@@ -598,7 +602,8 @@ function drawVectorShape(
|
|||||||
deviceCellWidth: number,
|
deviceCellWidth: number,
|
||||||
deviceCellHeight: number,
|
deviceCellHeight: number,
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
devicePixelRatio: number
|
devicePixelRatio: number,
|
||||||
|
logService: ILogService
|
||||||
): void {
|
): void {
|
||||||
// Clip the cell to make sure drawing doesn't occur beyond bounds
|
// Clip the cell to make sure drawing doesn't occur beyond bounds
|
||||||
const clipRegion = new Path2D();
|
const clipRegion = new Path2D();
|
||||||
@@ -619,7 +624,7 @@ function drawVectorShape(
|
|||||||
}
|
}
|
||||||
const f = svgToCanvasInstructionMap[type];
|
const f = svgToCanvasInstructionMap[type];
|
||||||
if (!f) {
|
if (!f) {
|
||||||
console.error(`Could not find drawing instructions for "${type}"`);
|
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const args: string[] = instruction.substring(1).split(',');
|
const args: string[] = instruction.substring(1).split(',');
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"downlevelIteration": true,
|
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"types": [
|
"types": [
|
||||||
"../../../node_modules/@types/mocha"
|
"../../../node_modules/@types/mocha"
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Reproduces the single-terminal WebGL crash/garble when the texture atlas
|
||||||
|
* page count overflows TextureAtlas.maxAtlasPages (vscode#322756 class).
|
||||||
|
*
|
||||||
|
* GlyphRenderer sizes its GL texture array and fragment-shader sampler array
|
||||||
|
* to exactly maxAtlasPages at construction time, but TextureAtlas._createNewPage
|
||||||
|
* pushes past that cap whenever a 4-page same-size merge is impossible (e.g. a
|
||||||
|
* heterogeneous mix like 1024+512+512+512). The next GlyphRenderer.render then
|
||||||
|
* throws "Cannot read properties of undefined (reading 'version')" and cells
|
||||||
|
* reference sampler indexes the shader does not have, freezing/garbling the
|
||||||
|
* terminal. Long heavy-CLI sessions hit this in production; the test makes it
|
||||||
|
* fast by building the renderer with a small cap (the same code path as the
|
||||||
|
* production 8/16/32 values).
|
||||||
|
*
|
||||||
|
* Chromium only (additive scope; Firefox/WebKit WebGL broken in CI, xtermjs#5854).
|
||||||
|
*/
|
||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||||
|
import {
|
||||||
|
captureRowSignatures,
|
||||||
|
expectSignatureMatches,
|
||||||
|
loadWebglStrict,
|
||||||
|
resetMaxAtlasPages,
|
||||||
|
setMaxAtlasPages,
|
||||||
|
writeAndWaitForRender
|
||||||
|
} from '../../../test/playwright/RendererTestUtils';
|
||||||
|
import { generateUniqueGlyphFlood } from '../../../test/playwright/SyntheticTui';
|
||||||
|
|
||||||
|
const HEADER = ' OVERFLOW_REF_0123456789';
|
||||||
|
const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
|
||||||
|
test.describe('atlas page overflow (#322756 class)', () => {
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||||
|
test.describe.configure({ timeout: 60000 });
|
||||||
|
|
||||||
|
test('renderer survives the atlas exceeding maxAtlasPages-sized textures', async ({ browser }) => {
|
||||||
|
const ctx: ITestContext = await createTestContext(browser);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const onError = (e: Error): void => { errors.push(e.message); };
|
||||||
|
ctx.page.on('pageerror', onError);
|
||||||
|
try {
|
||||||
|
// Bootstrap: create a throwaway WebGL terminal so the TextureAtlas class
|
||||||
|
// statics exist, then lower the page cap. openTerminal() disposes the old
|
||||||
|
// terminal (and its atlas), so the next WebGL renderer is CONSTRUCTED
|
||||||
|
// with the small cap: its GL texture array and fragment-shader sampler
|
||||||
|
// array have exactly 4 entries, faithfully mirroring production where
|
||||||
|
// they always equal maxAtlasPages.
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
await setMaxAtlasPages(ctx, 4);
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
|
||||||
|
const atlasTexturesLength = await ctx.page.evaluate(() =>
|
||||||
|
(window as any).term._core._renderService._renderer.value._glyphRenderer.value._atlasTextures.length);
|
||||||
|
expect(atlasTexturesLength, 'renderer must be built with the lowered page cap').toBe(4);
|
||||||
|
|
||||||
|
// Pinned header in the alt buffer; body churns distinct CJK glyphs below
|
||||||
|
// (cursor-positioned, no trailing linefeed on the bottom row -> no scroll).
|
||||||
|
await writeAndWaitForRender(ctx, `\x1b[?1049h\x1b[H${HEADER}`);
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS);
|
||||||
|
await ctx.page.locator('#terminal-container .xterm-screen').screenshot({ path: 'out-esbuild-test/playwright/overflow-before.png' });
|
||||||
|
|
||||||
|
const glyphsPerChunk = 23 * 40 - 1;
|
||||||
|
for (let c = 0; c < 16; c++) {
|
||||||
|
// Plain write + short wait instead of waiting for a render event: once
|
||||||
|
// the overflow crash fires, render events stop arriving and a render
|
||||||
|
// wait would hang the test instead of failing it.
|
||||||
|
await ctx.proxy.write('\x1b[2;1H' + generateUniqueGlyphFlood(glyphsPerChunk, 80, c * glyphsPerChunk));
|
||||||
|
await ctx.page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
await ctx.page.waitForTimeout(300);
|
||||||
|
await ctx.page.locator('#terminal-container .xterm-screen').screenshot({ path: 'out-esbuild-test/playwright/overflow-after.png' });
|
||||||
|
|
||||||
|
const state = await ctx.page.evaluate(() => {
|
||||||
|
const renderer = (window as any).term._core._renderService._renderer.value;
|
||||||
|
return {
|
||||||
|
pages: renderer._charAtlas.pages.length,
|
||||||
|
atlasTextures: renderer._glyphRenderer.value._atlasTextures.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.error('[overflow] pages=', state.pages, 'atlasTextures=', state.atlasTextures, 'pageErrors=', errors.length, errors[0] ?? '');
|
||||||
|
|
||||||
|
// Contract: the page count must never exceed the renderer's texture and
|
||||||
|
// sampler capacity, and rendering must never throw.
|
||||||
|
expect(errors, `GlyphRenderer must not crash when the atlas is saturated: ${errors[0] ?? ''}`).toEqual([]);
|
||||||
|
expect(state.pages, 'atlas page count must stay within the renderer texture capacity').toBeLessThanOrEqual(state.atlasTextures);
|
||||||
|
|
||||||
|
// The untouched header must still render correctly after the churn.
|
||||||
|
const after = await captureRowSignatures(ctx, 1, HEADER_COLS);
|
||||||
|
for (let i = 0; i < HEADER_COLS.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `header col ${HEADER_COLS[i]} corrupted after atlas saturation`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ctx.page.off('pageerror', onError);
|
||||||
|
// Restore the default so a lowered cap can never leak into other tests.
|
||||||
|
await resetMaxAtlasPages(ctx).catch(() => { /* page may already be unusable after a crash */ });
|
||||||
|
await ctx.page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* WebGL device-pixel-ratio (HiDPI) and font-size ("resize") atlas tests.
|
||||||
|
*
|
||||||
|
* A devicePixelRatio > 1 and font-size changes both change the device cell
|
||||||
|
* dimensions, which key the texture atlas cache and force the atlas to be
|
||||||
|
* rebuilt. Historically these paths have produced blank/oversized/garbled glyphs
|
||||||
|
* (e.g. xtermjs#4728, #5915/#5929). These tests assert glyphs render and stay
|
||||||
|
* correct across those transitions.
|
||||||
|
*
|
||||||
|
* Chromium + SwiftShader only (additive scope; Firefox/WebKit WebGL broken in CI
|
||||||
|
* per xtermjs#5854).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test, { Browser, expect } from '@playwright/test';
|
||||||
|
import { ITestContext, TerminalProxy, openTerminal } from '../../../test/playwright/TestUtils';
|
||||||
|
import {
|
||||||
|
assertTextureAtlasPresent,
|
||||||
|
captureRowSignatures,
|
||||||
|
expectSignatureMatches,
|
||||||
|
loadWebglStrict,
|
||||||
|
waitForRender,
|
||||||
|
writeAndWaitForRender
|
||||||
|
} from '../../../test/playwright/RendererTestUtils';
|
||||||
|
|
||||||
|
const REFERENCE_COLS = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a test context whose page uses a specific devicePixelRatio. The shared
|
||||||
|
* `createTestContext` helper always uses DPR 1 (`browser.newPage()`), so HiDPI
|
||||||
|
* tests build their own context here.
|
||||||
|
*/
|
||||||
|
async function createDprContext(browser: Browser, deviceScaleFactor: number): Promise<ITestContext> {
|
||||||
|
const context = await browser.newContext({ deviceScaleFactor });
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.on('pageerror', e => console.error(`[dpr ${deviceScaleFactor}]`, e));
|
||||||
|
await page.goto('/test');
|
||||||
|
const proxy = new TerminalProxy(page);
|
||||||
|
proxy.initPage();
|
||||||
|
return {
|
||||||
|
browser,
|
||||||
|
page,
|
||||||
|
termHandle: await page.evaluateHandle('window.term'),
|
||||||
|
proxy
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('WebGL HiDPI (devicePixelRatio) rendering', () => {
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
|
||||||
|
|
||||||
|
test('initializes the WebGL renderer and scales to device pixels at devicePixelRatio 2', async ({ browser }) => {
|
||||||
|
const ctx = await createDprContext(browser, 2);
|
||||||
|
try {
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
// GPU must be genuinely active at HiDPI (asserted inside loadWebglStrict).
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
await writeAndWaitForRender(ctx, 'A W .');
|
||||||
|
await assertTextureAtlasPresent(ctx);
|
||||||
|
|
||||||
|
// The WebGL renderer must render at the device resolution: device cell
|
||||||
|
// dimensions should be ~2x the CSS cell dimensions. This catches the
|
||||||
|
// DPR-scaling class of bugs (e.g. xtermjs#4728) without relying on
|
||||||
|
// screenshotting a WebGL canvas at a non-1 device scale factor.
|
||||||
|
const dims = await ctx.proxy.core.renderDimensions;
|
||||||
|
expect(dims.device.cell.width).toBeGreaterThan(dims.css.cell.width * 1.8);
|
||||||
|
expect(dims.device.cell.width).toBeLessThan(dims.css.cell.width * 2.2);
|
||||||
|
expect(dims.device.cell.height).toBeGreaterThan(dims.css.cell.height * 1.8);
|
||||||
|
expect(dims.device.cell.height).toBeLessThan(dims.css.cell.height * 2.2);
|
||||||
|
} finally {
|
||||||
|
await ctx.page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('WebGL font-size change atlas integrity', () => {
|
||||||
|
let ctx: ITestContext;
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
ctx = await createDprContext(browser, 1);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => await ctx.page.close());
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24, fontSize: 15 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('glyphs stay correct after cycling font size (atlas rebuilds)', async () => {
|
||||||
|
await writeAndWaitForRender(ctx, 'FontRef0123456789');
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
|
||||||
|
// Changing font size changes device cell dimensions -> the atlas is rebuilt.
|
||||||
|
for (const fontSize of [12, 18, 21, 15]) {
|
||||||
|
await ctx.proxy.setOption('fontSize', fontSize);
|
||||||
|
await writeAndWaitForRender(ctx, '\r\nchurn');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back at the original font size the reference glyphs must be unchanged.
|
||||||
|
await ctx.proxy.scrollToTop();
|
||||||
|
await waitForRender(ctx);
|
||||||
|
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
for (let i = 0; i < REFERENCE_COLS.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 16, `reference glyph at col ${REFERENCE_COLS[i]} corrupted after font-size cycling`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* WebGL glyph + texture-atlas integrity tests.
|
||||||
|
*
|
||||||
|
* These tests verify that *glyphs* render correctly (not just that cell colors
|
||||||
|
* are right) under conditions that stress the texture atlas: multiple atlas
|
||||||
|
* pages, page merges and atlas clears. The goal is to catch the "garbled glyph"
|
||||||
|
* corruption class reported in microsoft/vscode#322756.
|
||||||
|
*
|
||||||
|
* Note: CI runs WebGL on Chromium + SwiftShader (software WebGL). That catches
|
||||||
|
* renderer/atlas *logic* corruption (stale texture pages, missing model clears),
|
||||||
|
* but not GPU-driver-specific glitches. These tests target the former.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||||
|
import {
|
||||||
|
assertTextureAtlasPresent,
|
||||||
|
captureRowSignatures,
|
||||||
|
expectSignatureDiffers,
|
||||||
|
expectSignatureMatches,
|
||||||
|
floodDistinctGlyphs,
|
||||||
|
loadWebglStrict,
|
||||||
|
resetMaxAtlasPages,
|
||||||
|
setMaxAtlasPages,
|
||||||
|
waitForRender,
|
||||||
|
writeAndWaitForRender
|
||||||
|
} from '../../../test/playwright/RendererTestUtils';
|
||||||
|
|
||||||
|
let ctx: ITestContext;
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
ctx = await createTestContext(browser);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => await ctx.page.close());
|
||||||
|
|
||||||
|
test.describe('WebGL glyph + atlas integrity', () => {
|
||||||
|
// Additive scope: only run on Chromium (SwiftShader). Firefox/WebKit WebGL
|
||||||
|
// tests are known-broken in CI (xtermjs#5854) and out of scope here.
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
// Fresh terminal (and therefore a predictable atlas) per test.
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A lowered page cap must not leak into renderers built by later tests.
|
||||||
|
test.afterEach(async () => await resetMaxAtlasPages(ctx));
|
||||||
|
|
||||||
|
test('renders distinct, non-blank glyphs', async () => {
|
||||||
|
await writeAndWaitForRender(ctx, 'A W .');
|
||||||
|
await assertTextureAtlasPresent(ctx);
|
||||||
|
|
||||||
|
const [a, w, dot, blank] = await captureRowSignatures(ctx, 1, [1, 3, 5, 40]);
|
||||||
|
// A written cell must differ from an empty cell (glyph actually drawn).
|
||||||
|
expectSignatureDiffers(a, blank, 24, 'glyph "A" should not be blank');
|
||||||
|
expectSignatureDiffers(w, blank, 24, 'glyph "W" should not be blank');
|
||||||
|
// Different glyphs must look different (signature has discriminating power).
|
||||||
|
expectSignatureDiffers(a, w, 24, 'glyphs "A" and "W" should render differently');
|
||||||
|
expectSignatureDiffers(a, dot, 24, 'glyphs "A" and "." should render differently');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('glyphs on the first atlas page stay correct after more pages are allocated', async () => {
|
||||||
|
// Reference: a known row of glyphs that lands on the first atlas page.
|
||||||
|
await writeAndWaitForRender(ctx, 'REFERENCE_LINE_0123456789\r\n');
|
||||||
|
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
|
||||||
|
// Flood thousands of distinct glyphs (one screenful rendered at a time, so
|
||||||
|
// they actually rasterize) to force multiple atlas pages.
|
||||||
|
const pages = await floodDistinctGlyphs(ctx, 4);
|
||||||
|
expect(pages, 'flooding distinct glyphs should allocate more than one atlas page').toBeGreaterThan(1);
|
||||||
|
|
||||||
|
// Bring the reference line back into view and re-check its glyphs.
|
||||||
|
await ctx.proxy.scrollToTop();
|
||||||
|
await waitForRender(ctx);
|
||||||
|
const after = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `reference glyph at col ${cols[i]} corrupted after multi-page allocation`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('glyphs stay correct after a texture-atlas page merge', async () => {
|
||||||
|
// Force merges to happen almost immediately.
|
||||||
|
await writeAndWaitForRender(ctx, 'MERGE_REF_LINE_0123456789\r\n');
|
||||||
|
await setMaxAtlasPages(ctx, 4);
|
||||||
|
|
||||||
|
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
|
||||||
|
// Flood distinct glyphs (rendered per screenful) to exceed the tiny page cap
|
||||||
|
// and trigger merges.
|
||||||
|
const pages = await floodDistinctGlyphs(ctx, 12);
|
||||||
|
expect(pages, 'flood should have allocated multiple atlas pages (and merged)').toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await ctx.proxy.scrollToTop();
|
||||||
|
await waitForRender(ctx);
|
||||||
|
const after = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `reference glyph at col ${cols[i]} corrupted after atlas page merge`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('glyphs re-render correctly after clearTextureAtlas() on the same terminal', async () => {
|
||||||
|
await writeAndWaitForRender(ctx, 'CLEAR_REF_LINE_0123456789');
|
||||||
|
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
|
||||||
|
await ctx.proxy.clearTextureAtlas();
|
||||||
|
// Force a full redraw so glyphs are re-rasterized into the cleared atlas.
|
||||||
|
await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1);
|
||||||
|
await waitForRender(ctx);
|
||||||
|
|
||||||
|
const after = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `glyph at col ${cols[i]} corrupted after clearTextureAtlas()`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,8 +15,10 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
await openTerminal(ctx);
|
await openTerminal(ctx);
|
||||||
ctxWrapper.value = ctx;
|
ctxWrapper.value = ctx;
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.addon = new window.WebglAddon(true);
|
try {
|
||||||
window.term.loadAddon(window.addon);
|
window.addon = new window.WebglAddon(true);
|
||||||
|
window.term.loadAddon(window.addon);
|
||||||
|
} catch (e) {}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
test.afterAll(async () => await ctx.page.close());
|
test.afterAll(async () => await ctx.page.close());
|
||||||
@@ -24,15 +26,18 @@ test.afterAll(async () => await ctx.page.close());
|
|||||||
test.describe('WebGL Renderer Integration Tests', async () => {
|
test.describe('WebGL Renderer Integration Tests', async () => {
|
||||||
// HACK: webgl2 is often not supported in headless firefox on Linux
|
// HACK: webgl2 is often not supported in headless firefox on Linux
|
||||||
// https://github.com/microsoft/playwright/issues/11566
|
// https://github.com/microsoft/playwright/issues/11566
|
||||||
|
// also disable safari due to #5852
|
||||||
if (platform() === 'linux') {
|
if (platform() === 'linux') {
|
||||||
test.skip(({ browserName }) => browserName === 'firefox');
|
test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit');
|
||||||
}
|
}
|
||||||
|
|
||||||
injectSharedRendererTests(ctxWrapper);
|
injectSharedRendererTests(ctxWrapper);
|
||||||
injectSharedRendererTestsStandalone(ctxWrapper, async () => {
|
injectSharedRendererTestsStandalone(ctxWrapper, async () => {
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.addon = new window.WebglAddon(true);
|
try {
|
||||||
window.term.loadAddon(window.addon);
|
window.addon = new window.WebglAddon(true);
|
||||||
|
window.term.loadAddon(window.addon);
|
||||||
|
} catch (e) {}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Single-terminal guard for the vscode#322756 "garbled glyph" class: a pinned
|
||||||
|
* alt-buffer header must render identically while the body churns enough
|
||||||
|
* distinct colored glyphs to force texture-atlas page merges.
|
||||||
|
*
|
||||||
|
* Note this scenario is GREEN on current master: the single-terminal mid-frame
|
||||||
|
* merge race was fixed upstream (merge-retry loop in WebglRenderer.renderRows +
|
||||||
|
* globally monotonic AtlasPage versions). The test asserts the buffer content
|
||||||
|
* first so a content bug (e.g. an accidental scroll from the churn stream) can
|
||||||
|
* never masquerade as renderer corruption.
|
||||||
|
*/
|
||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||||
|
import { captureRowSignatures, expectSignatureMatches, getAtlasPageCount, loadWebglStrict, resetMaxAtlasPages, setMaxAtlasPages, writeAndWaitForRender } from '../../../test/playwright/RendererTestUtils';
|
||||||
|
import { generateColoredAsciiFlood } from '../../../test/playwright/SyntheticTui';
|
||||||
|
|
||||||
|
const HEADER = ' HEADER_REF_0123456789';
|
||||||
|
|
||||||
|
let ctx: ITestContext;
|
||||||
|
test.beforeAll(async ({ browser }) => { ctx = await createTestContext(browser); });
|
||||||
|
test.afterAll(async () => await ctx.page.close());
|
||||||
|
|
||||||
|
test.describe('single-terminal merge integrity (#322756 class)', () => {
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||||
|
test.describe.configure({ timeout: 60000 });
|
||||||
|
|
||||||
|
// A lowered page cap must not leak into renderers built by later tests.
|
||||||
|
test.afterEach(async () => await resetMaxAtlasPages(ctx));
|
||||||
|
|
||||||
|
test('pinned header renders identically across 30 merge-heavy body redraws', async () => {
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
await setMaxAtlasPages(ctx, 4);
|
||||||
|
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
// Alt-buffer TUI: pinned header on row 1, body churns below (like Copilot CLI).
|
||||||
|
await writeAndWaitForRender(ctx, `\x1b[?1049h\x1b[H${HEADER}`);
|
||||||
|
const ref = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
let maxDiff = 0;
|
||||||
|
for (let c = 0; c < 30; c++) {
|
||||||
|
// Redraw body rows 2..24 with unique glyphs; the header row is never touched.
|
||||||
|
let frame = '\x1b[2;1H';
|
||||||
|
frame += generateColoredAsciiFlood(23 * 78, c * 23 * 78);
|
||||||
|
await writeAndWaitForRender(ctx, frame);
|
||||||
|
const now = await captureRowSignatures(ctx, 1, cols); // forces GPU sync
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
const d = Math.max(...ref[i].map((v, k) => Math.abs(v - now[i][k])));
|
||||||
|
maxDiff = Math.max(maxDiff, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('[repro] pages=', await getAtlasPageCount(ctx), 'headerMaxDiff=', maxDiff);
|
||||||
|
|
||||||
|
// Guard: the header must still be in the buffer. If this fails the churn
|
||||||
|
// stream scrolled the screen and any pixel diff is meaningless.
|
||||||
|
const row1Text = await ctx.page.evaluate(() => (window as any).term.buffer.active.getLine(0)?.translateToString(true));
|
||||||
|
expect(row1Text, 'churn stream must not scroll the pinned header out of the buffer').toBe(HEADER);
|
||||||
|
|
||||||
|
const after = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], ref[i], 14, `header col ${cols[i]} garbled`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Reproduces the vscode#322756 "garbled glyph" corruption across terminals that
|
||||||
|
* SHARE a texture atlas (CharAtlasCache hands the same TextureAtlas to every
|
||||||
|
* terminal with an equal config — in VS Code that is every integrated terminal
|
||||||
|
* using the same font settings, including task/background terminals).
|
||||||
|
*
|
||||||
|
* Mechanism: when the atlas merges pages it raises a consume-once
|
||||||
|
* "clear model" flag (TextureAtlas.beginFrame). Only the FIRST terminal to
|
||||||
|
* render consumes it and rebuilds its vertex model; every other terminal keeps
|
||||||
|
* stale texture-page indexes/coordinates in its vertex buffer and draws wrong
|
||||||
|
* glyphs on its next partial render, even though its buffer content is intact.
|
||||||
|
*
|
||||||
|
* The DOM-renderer control shows the corruption is exclusive to the GPU path
|
||||||
|
* (`terminal.integrated.gpuAcceleration off` cannot reproduce it: the DOM
|
||||||
|
* renderer has no texture atlas).
|
||||||
|
*
|
||||||
|
* Chromium only (additive scope; Firefox/WebKit WebGL broken in CI, xtermjs#5854).
|
||||||
|
*/
|
||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||||
|
import {
|
||||||
|
captureRowSignatures,
|
||||||
|
expectSignatureMatches,
|
||||||
|
getAtlasPageCount,
|
||||||
|
loadWebglStrict,
|
||||||
|
setMaxAtlasPages,
|
||||||
|
writeAndWaitForRender
|
||||||
|
} from '../../../test/playwright/RendererTestUtils';
|
||||||
|
import { generateColoredAsciiFlood } from '../../../test/playwright/SyntheticTui';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a second terminal ("termB") on the test page with the given options
|
||||||
|
* and optionally loads the WebGL addon on it. Identical rendering options mean
|
||||||
|
* CharAtlasCache gives it the same atlas instance as `window.term`.
|
||||||
|
*/
|
||||||
|
async function createTerminalB(ctx: ITestContext, options: { useWebgl: boolean }): Promise<void> {
|
||||||
|
await ctx.page.evaluate(async (useWebgl) => {
|
||||||
|
const w = window as any;
|
||||||
|
w.termB?.dispose();
|
||||||
|
document.getElementById('terminal-container-b')?.remove();
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = 'terminal-container-b';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
w.termB = new w.Terminal({ cols: 80, rows: 24, allowProposedApi: true });
|
||||||
|
w.termB.open(el);
|
||||||
|
if (useWebgl) {
|
||||||
|
w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true });
|
||||||
|
w.termB.loadAddon(w.addonB);
|
||||||
|
}
|
||||||
|
}, options.useWebgl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes data to termB and resolves after its next render event. */
|
||||||
|
async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
|
||||||
|
await ctx.page.evaluate(d => new Promise<void>(resolve => {
|
||||||
|
const termB = (window as any).termB;
|
||||||
|
const disposable = termB.onRender(() => {
|
||||||
|
disposable.dispose();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
termB.write(d);
|
||||||
|
}), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTermBRow1Text(ctx: ITestContext): Promise<string | undefined> {
|
||||||
|
return ctx.page.evaluate(() => (window as any).termB.buffer.active.getLine(0)?.translateToString(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('shared-atlas garble across terminals (#322756)', () => {
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||||
|
test.describe.configure({ timeout: 60000 });
|
||||||
|
|
||||||
|
test('terminal B keeps rendering its header correctly while terminal A forces atlas merges', async ({ browser }) => {
|
||||||
|
const ctx: ITestContext = await createTestContext(browser);
|
||||||
|
try {
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
await createTerminalB(ctx, { useWebgl: true });
|
||||||
|
|
||||||
|
// Both terminals must be using the same atlas instance for this test to
|
||||||
|
// mean anything.
|
||||||
|
const atlasShared = await ctx.page.evaluate(() => {
|
||||||
|
const w = window as any;
|
||||||
|
const atlasA = w.term._core._renderService._renderer.value._charAtlas;
|
||||||
|
const atlasB = w.termB._core._renderService._renderer.value._charAtlas;
|
||||||
|
return !!atlasA && atlasA === atlasB;
|
||||||
|
});
|
||||||
|
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
|
||||||
|
|
||||||
|
// Lower the cap AFTER both renderers exist so their shaders/texture
|
||||||
|
// arrays keep the original size (this test targets stale vertex data,
|
||||||
|
// not the page-overflow crash).
|
||||||
|
await setMaxAtlasPages(ctx, 4);
|
||||||
|
|
||||||
|
// Render B's header pre-merge and capture its reference pixels.
|
||||||
|
await writeToBAndWaitForRender(ctx, HEADER);
|
||||||
|
await ctx.page.locator(TERM_B_SELECTOR).screenshot({ path: 'out-esbuild-test/playwright/termB-before.png' });
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
|
||||||
|
|
||||||
|
// Churn terminal A with distinct colored glyphs to force page merges in
|
||||||
|
// the SHARED atlas. A repairs itself (it consumes the clear-model flag);
|
||||||
|
// B is never told.
|
||||||
|
for (let c = 0; c < 10; c++) {
|
||||||
|
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78));
|
||||||
|
}
|
||||||
|
const pages = await getAtlasPageCount(ctx);
|
||||||
|
expect(pages, 'churn should have exercised multiple atlas pages').toBeGreaterThan(1);
|
||||||
|
|
||||||
|
// A tiny unrelated update to B's LAST row triggers a partial render in B.
|
||||||
|
// B's header row is untouched; with a healthy renderer its pixels must
|
||||||
|
// not change.
|
||||||
|
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
|
||||||
|
await ctx.page.locator(TERM_B_SELECTOR).screenshot({ path: 'out-esbuild-test/playwright/termB-after.png' });
|
||||||
|
|
||||||
|
// Guard: B's buffer content is intact, so any pixel change is renderer
|
||||||
|
// corruption by construction.
|
||||||
|
expect(await getTermBRow1Text(ctx), 'terminal B buffer must still contain the header').toBe(HEADER);
|
||||||
|
|
||||||
|
const after = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
|
||||||
|
for (let i = 0; i < HEADER_COLS.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas merges`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ctx.page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DOM renderer control: same workload cannot garble terminal B (no atlas without GPU acceleration)', async ({ browser }) => {
|
||||||
|
const ctx: ITestContext = await createTestContext(browser);
|
||||||
|
try {
|
||||||
|
// No WebGL addon on either terminal -> DOM renderer -> no texture atlas.
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
await createTerminalB(ctx, { useWebgl: false });
|
||||||
|
|
||||||
|
await writeToBAndWaitForRender(ctx, HEADER);
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
expect(await getTermBRow1Text(ctx), 'terminal B buffer must still contain the header').toBe(HEADER);
|
||||||
|
const after = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
|
||||||
|
for (let i = 0; i < HEADER_COLS.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `DOM-rendered terminal B header col ${HEADER_COLS[i]} unexpectedly changed`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ctx.page.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Single-terminal WebGL atlas stress: try to reproduce the "garbled glyph"
|
||||||
|
* corruption from microsoft/vscode#322756 in ONE terminal (no shared atlas),
|
||||||
|
* via heavy unique-glyph churn that forces atlas page merges, both with content
|
||||||
|
* pinned on screen (TUI-style) and mid-scroll. Chromium/SwiftShader only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||||
|
import {
|
||||||
|
captureRowSignatures,
|
||||||
|
expectSignatureMatches,
|
||||||
|
floodDistinctGlyphs,
|
||||||
|
getAtlasPageCount,
|
||||||
|
loadWebglStrict,
|
||||||
|
resetMaxAtlasPages,
|
||||||
|
setMaxAtlasPages,
|
||||||
|
waitForRender,
|
||||||
|
writeAndWaitForRender
|
||||||
|
} from '../../../test/playwright/RendererTestUtils';
|
||||||
|
import { generateUniqueGlyphFlood } from '../../../test/playwright/SyntheticTui';
|
||||||
|
|
||||||
|
let ctx: ITestContext;
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
ctx = await createTestContext(browser);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => await ctx.page.close());
|
||||||
|
|
||||||
|
test.describe('WebGL single-terminal heavy render + merge', () => {
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only');
|
||||||
|
test.describe.configure({ timeout: 30000 });
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24, scrollback: 5000 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A lowered page cap must not leak into renderers built by later tests.
|
||||||
|
test.afterEach(async () => await resetMaxAtlasPages(ctx));
|
||||||
|
|
||||||
|
test('heavy merge churn must not crash the GlyphRenderer (vscode#322756)', async () => {
|
||||||
|
// Guard: heavy merges must not throw from GlyphRenderer.render. Note the
|
||||||
|
// page cap here is lowered AFTER the renderer was built, so _atlasTextures
|
||||||
|
// keeps its original (large) length and this test cannot hit the
|
||||||
|
// page-overflow crash; that scenario needs the cap lowered BEFORE loading
|
||||||
|
// the addon and is covered separately (WebglAtlasOverflow.test.ts).
|
||||||
|
const errors: string[] = [];
|
||||||
|
const onError = (e: Error): void => { errors.push(e.message); };
|
||||||
|
ctx.page.on('pageerror', onError);
|
||||||
|
try {
|
||||||
|
await setMaxAtlasPages(ctx, 4);
|
||||||
|
await floodDistinctGlyphs(ctx, 10);
|
||||||
|
// Force a GPU read (screenshot) mid-churn so a render lands during/after a
|
||||||
|
// merge, then churn more — this reliably exposes the version crash.
|
||||||
|
await captureRowSignatures(ctx, 1, [1, 2, 3]);
|
||||||
|
const pages = await floodDistinctGlyphs(ctx, 10);
|
||||||
|
expect(pages, 'should have allocated/merged pages').toBeGreaterThan(1);
|
||||||
|
await ctx.page.waitForTimeout(300);
|
||||||
|
expect(errors, `renderer crashed during atlas merges: ${errors[0] ?? ''}`).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
ctx.page.off('pageerror', onError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pinned header stays correct while body churns many pages', async () => {
|
||||||
|
const cols = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
await writeAndWaitForRender(ctx, 'HEADER_REF_0123456789');
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
|
||||||
|
// Body churns unique glyphs (forces merges) while header line stays on screen.
|
||||||
|
const pages = await floodDistinctGlyphs(ctx, 8);
|
||||||
|
expect(pages, 'should have allocated/merged pages').toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await ctx.proxy.scrollToTop();
|
||||||
|
await waitForRender(ctx);
|
||||||
|
const after = await captureRowSignatures(ctx, 1, cols);
|
||||||
|
for (let i = 0; i < cols.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `header glyph col ${cols[i]} corrupted by single-terminal merges`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visible rows match content while scrolling (no merge)', async () => {
|
||||||
|
// Big scrollback of distinct glyphs across multiple pages (no tiny page cap,
|
||||||
|
// so no merge), then scroll up/down repeatedly.
|
||||||
|
for (let c = 0; c < 8; c++) {
|
||||||
|
await writeAndWaitForRender(ctx, generateUniqueGlyphFlood(24 * 40, 80, c * 24 * 40));
|
||||||
|
}
|
||||||
|
expect(await getAtlasPageCount(ctx)).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await ctx.proxy.scrollToTop();
|
||||||
|
await waitForRender(ctx);
|
||||||
|
const top = await captureRowSignatures(ctx, 1, [1, 2, 3, 4, 5]);
|
||||||
|
await ctx.proxy.scrollToBottom();
|
||||||
|
await ctx.proxy.scrollToTop();
|
||||||
|
await waitForRender(ctx);
|
||||||
|
const topAgain = await captureRowSignatures(ctx, 1, [1, 2, 3, 4, 5]);
|
||||||
|
for (let i = 0; i < top.length; i++) {
|
||||||
|
expectSignatureMatches(topAgain[i], top[i], 14, `row1 col${i + 1} changed after scroll round-trip`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* WebGL "heavy TUI" stress tests: large scrollback + scrolling, alternate-buffer
|
||||||
|
* full-screen redraws (like an agent CLI animating output), high-volume
|
||||||
|
* rendering and replay of recorded real-world TUI sessions (vim, colored ls).
|
||||||
|
*
|
||||||
|
* The shared invariant: a known reference row of glyphs, captured in a fresh
|
||||||
|
* atlas, must still render identically after the stress workload. If the WebGL
|
||||||
|
* texture atlas is corrupted by the workload the reference glyphs change and the
|
||||||
|
* test fails. See microsoft/vscode#322756.
|
||||||
|
*
|
||||||
|
* Chromium + SwiftShader only (additive scope; Firefox/WebKit WebGL are broken
|
||||||
|
* in CI per xtermjs#5854).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||||
|
import {
|
||||||
|
assertTextureAtlasPresent,
|
||||||
|
captureRowSignatures,
|
||||||
|
expectSignatureMatches,
|
||||||
|
loadWebglStrict,
|
||||||
|
waitForRender,
|
||||||
|
writeAndWaitForRender
|
||||||
|
} from '../../../test/playwright/RendererTestUtils';
|
||||||
|
import { generateMixedGlyphBlock, generateSyntheticTuiFrames, generateUniqueGlyphFlood } from '../../../test/playwright/SyntheticTui';
|
||||||
|
|
||||||
|
const REFERENCE_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
|
const REFERENCE_LINE = 'Ref0123456789ABCDEF';
|
||||||
|
|
||||||
|
let ctx: ITestContext;
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
ctx = await createTestContext(browser);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => await ctx.page.close());
|
||||||
|
|
||||||
|
test.describe('WebGL heavy TUI / scrolling stress', () => {
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL stress tests run on Chromium/SwiftShader only');
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24, scrollback: 5000 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reference glyphs survive heavy scrolling through large scrollback', async () => {
|
||||||
|
await writeAndWaitForRender(ctx, `${REFERENCE_LINE}\r\n`);
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
|
||||||
|
// Push the reference far up into scrollback with thousands of varied glyphs.
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await ctx.proxy.write(generateMixedGlyphBlock(i + 1, 30, 70));
|
||||||
|
}
|
||||||
|
await waitForRender(ctx);
|
||||||
|
|
||||||
|
// Scroll around, then back to the very top where the reference lives.
|
||||||
|
await ctx.proxy.scrollToTop();
|
||||||
|
await waitForRender(ctx);
|
||||||
|
await ctx.proxy.scrollPages(3);
|
||||||
|
await waitForRender(ctx);
|
||||||
|
await ctx.proxy.scrollToTop();
|
||||||
|
await waitForRender(ctx);
|
||||||
|
|
||||||
|
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
for (let i = 0; i < REFERENCE_COLS.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `reference glyph at col ${REFERENCE_COLS[i]} corrupted after heavy scrolling`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alternate-buffer TUI redraws do not corrupt the restored primary buffer', async () => {
|
||||||
|
// Reference content lives in the PRIMARY buffer.
|
||||||
|
await writeAndWaitForRender(ctx, `${REFERENCE_LINE}`);
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
|
||||||
|
// Animate a synthetic agent TUI in the alternate buffer.
|
||||||
|
const frames = generateSyntheticTuiFrames({ seed: 7, frames: 30, cols: 80, rows: 24, useAltBuffer: true });
|
||||||
|
for (const frame of frames) {
|
||||||
|
await ctx.proxy.write(frame);
|
||||||
|
}
|
||||||
|
await waitForRender(ctx);
|
||||||
|
await assertTextureAtlasPresent(ctx);
|
||||||
|
|
||||||
|
// Back in the primary buffer the reference glyphs must be intact.
|
||||||
|
await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1);
|
||||||
|
await waitForRender(ctx);
|
||||||
|
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
for (let i = 0; i < REFERENCE_COLS.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `primary-buffer glyph at col ${REFERENCE_COLS[i]} corrupted by alt-buffer TUI redraws`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reference glyphs survive a heavy mixed render + unique-glyph churn', async () => {
|
||||||
|
// Capture the reference rendering of a known line in a fresh atlas.
|
||||||
|
await writeAndWaitForRender(ctx, `${REFERENCE_LINE}`);
|
||||||
|
const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
|
||||||
|
// High-volume rendering: synthetic TUI frames (which clear/redraw the screen)
|
||||||
|
// plus a large unique-glyph flood to churn the atlas, mimicking a long agent
|
||||||
|
// CLI session.
|
||||||
|
const frames = generateSyntheticTuiFrames({ seed: 3, frames: 40, cols: 80, rows: 24 });
|
||||||
|
for (const frame of frames) {
|
||||||
|
await ctx.proxy.write(frame);
|
||||||
|
}
|
||||||
|
await ctx.proxy.write(generateUniqueGlyphFlood(3000, 80));
|
||||||
|
await waitForRender(ctx);
|
||||||
|
|
||||||
|
// The synthetic frames erase the screen, so re-render the reference line and
|
||||||
|
// assert the (heavily churned) atlas still draws those glyphs correctly.
|
||||||
|
await writeAndWaitForRender(ctx, `\x1b[2J\x1b[H${REFERENCE_LINE}`);
|
||||||
|
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
for (let i = 0; i < REFERENCE_COLS.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], reference[i], 14, `reference glyph at col ${REFERENCE_COLS[i]} corrupted after heavy render`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaying recorded TUI sessions renders without errors or atlas corruption', async () => {
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
const onError = (err: Error): void => {
|
||||||
|
pageErrors.push(err.message);
|
||||||
|
};
|
||||||
|
ctx.page.on('pageerror', onError);
|
||||||
|
try {
|
||||||
|
const fixtures = [
|
||||||
|
'./test/fixtures/escape_sequence_files/t0090-alt_screen.in',
|
||||||
|
'./test/fixtures/escape_sequence_files/t0502-bash_ls_color.in',
|
||||||
|
'./test/fixtures/escape_sequence_files/t0500-bash_long_line.in',
|
||||||
|
'./test/fixtures/escape_sequence_files/t0504-vim.in'
|
||||||
|
];
|
||||||
|
for (const fixture of fixtures) {
|
||||||
|
const data = readFileSync(fixture, { encoding: 'latin1' });
|
||||||
|
await ctx.proxy.write(data);
|
||||||
|
}
|
||||||
|
await waitForRender(ctx);
|
||||||
|
await assertTextureAtlasPresent(ctx);
|
||||||
|
|
||||||
|
// Re-rendering the final frame must be idempotent (self-consistent): if the
|
||||||
|
// atlas were corrupted, a forced redraw would differ from what's shown.
|
||||||
|
const before = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1);
|
||||||
|
await waitForRender(ctx);
|
||||||
|
const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS);
|
||||||
|
for (let i = 0; i < REFERENCE_COLS.length; i++) {
|
||||||
|
expectSignatureMatches(after[i], before[i], 14, `fixture replay frame not self-consistent at col ${REFERENCE_COLS[i]}`);
|
||||||
|
}
|
||||||
|
expect(pageErrors, `no page errors during fixture replay, got: ${pageErrors.join(', ')}`).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
ctx.page.off('pageerror', onError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
* Produces BEFORE/AFTER screenshots of a single terminal under merge-heavy
|
||||||
|
* churn for manual inspection (before.png / after.png, mono-*.png). On current
|
||||||
|
* master the single-terminal merge path is healthy, so the pinned header lines
|
||||||
|
* are expected to remain readable in the "after" images; a corrupted header in
|
||||||
|
* these captures indicates a renderer regression. Note the mono "after" body is
|
||||||
|
* SUPPOSED to look like glyph soup — that is the mixed box-drawing/braille
|
||||||
|
* payload rendered faithfully, not corruption.
|
||||||
|
*/
|
||||||
|
import test from '@playwright/test';
|
||||||
|
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
|
||||||
|
import { loadWebglStrict, setMaxAtlasPages, writeAndWaitForRender } from '../../../test/playwright/RendererTestUtils';
|
||||||
|
import { generateColoredAsciiFlood, generateMixedGlyphBlock } from '../../../test/playwright/SyntheticTui';
|
||||||
|
|
||||||
|
test.describe('visual #322756', () => {
|
||||||
|
test.skip(({ browserName }) => browserName !== 'chromium');
|
||||||
|
test.describe.configure({ timeout: 60000 });
|
||||||
|
|
||||||
|
test('capture before/after pinned header under merge churn', async ({ browser }) => {
|
||||||
|
const ctx: ITestContext = await createTestContext(browser);
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
await setMaxAtlasPages(ctx, 4);
|
||||||
|
const screen = ctx.page.locator('#terminal-container .xterm-screen');
|
||||||
|
// Header + readable sentences pinned at top; body region churns below.
|
||||||
|
await writeAndWaitForRender(ctx, '\x1b[?1049h\x1b[H\x1b[97mHEADER: THE QUICK BROWN FOX 0123456789\r\nSTATUS: this line should stay readable\r\n');
|
||||||
|
await screen.screenshot({ path: 'out-esbuild-test/playwright/before.png' });
|
||||||
|
for (let c = 0; c < 30; c++) {
|
||||||
|
await writeAndWaitForRender(ctx, '\x1b[5;1H' + generateColoredAsciiFlood(20 * 78, c * 20 * 78));
|
||||||
|
}
|
||||||
|
await screen.screenshot({ path: 'out-esbuild-test/playwright/after.png' });
|
||||||
|
await ctx.page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue-like framing: prefill realistic CLI text, force merges with a mixed
|
||||||
|
// glyph payload, then rewrite the top lines clean. The rewritten header lines
|
||||||
|
// must stay readable in mono-after.png.
|
||||||
|
test('capture mono before/after (issue-like)', async ({ browser }) => {
|
||||||
|
const ctx: ITestContext = await createTestContext(browser);
|
||||||
|
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||||
|
await loadWebglStrict(ctx);
|
||||||
|
await setMaxAtlasPages(ctx, 4);
|
||||||
|
const screen = ctx.page.locator('#terminal-container .xterm-screen');
|
||||||
|
// Prefill a screenful of normal-looking CLI output (all rendered fine).
|
||||||
|
await writeAndWaitForRender(ctx, '\x1b[?1049h\x1b[2J\x1b[H');
|
||||||
|
let pre = '';
|
||||||
|
for (let r = 0; r < 22; r++) {
|
||||||
|
pre += ` $ Shell inspect bundled MCP JSON schema for type enum values ${r} lines\r\n`;
|
||||||
|
}
|
||||||
|
await writeAndWaitForRender(ctx, pre);
|
||||||
|
await screen.screenshot({ path: 'out-esbuild-test/playwright/mono-before.png' });
|
||||||
|
// Overflow the atlas (cap=4) so pages MERGE while most rows keep their cells,
|
||||||
|
// then rewrite a few lines clean (like scroll/redraw).
|
||||||
|
for (let c = 0; c < 12; c++) {
|
||||||
|
let frame = '\x1b[3;1H';
|
||||||
|
frame += generateMixedGlyphBlock(c + 1, 18, 78);
|
||||||
|
await writeAndWaitForRender(ctx, frame);
|
||||||
|
}
|
||||||
|
await writeAndWaitForRender(ctx, '\x1b[1;1H\x1b[2K $ Shell grep installed Copilot CLI binary - 53 lines\r\n\x1b[2K Thought for 1s');
|
||||||
|
await screen.screenshot({ path: 'out-esbuild-test/playwright/mono-after.png' });
|
||||||
|
await ctx.page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,10 @@ const config: PlaywrightTestConfig = {
|
|||||||
{
|
{
|
||||||
name: 'Chromium',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium'
|
browserName: 'chromium',
|
||||||
|
launchOptions: {
|
||||||
|
args: ['--enable-unsafe-swiftshader']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-2
@@ -23,12 +23,12 @@ declare module '@xterm/addon-webgl' {
|
|||||||
public readonly onChangeTextureAtlas: IEvent<HTMLCanvasElement>;
|
public readonly onChangeTextureAtlas: IEvent<HTMLCanvasElement>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An event that is fired when the a new page is added to the texture atlas.
|
* An event that is fired when a new page is added to the texture atlas.
|
||||||
*/
|
*/
|
||||||
public readonly onAddTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
|
public readonly onAddTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An event that is fired when the a page is removed from the texture atlas.
|
* An event that is fired when a page is removed from the texture atlas.
|
||||||
*/
|
*/
|
||||||
public readonly onRemoveTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
|
public readonly onRemoveTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -19,14 +19,17 @@ for (const addon of addons) {
|
|||||||
// Demo job - This requires the others to be built so it's not included when building all
|
// Demo job - This requires the others to be built so it's not included when building all
|
||||||
// jobs.push(createJob('demo-client', [`--demo-client`]));
|
// jobs.push(createJob('demo-client', [`--demo-client`]));
|
||||||
|
|
||||||
await Promise.all(jobs.map((job, i) => {
|
const codes = await Promise.all(jobs.map(job => {
|
||||||
return new Promise(r => {
|
return new Promise(r => {
|
||||||
job.cp.on('exit', code => {
|
job.cp.on('exit', code => {
|
||||||
log(`Finished \x1b[32m${job.name}\x1b[0m${code ? ' \x1b[31mwith errors\x1b[0m' : ''}`);
|
log(`Finished \x1b[32m${job.name}\x1b[0m${code ? ' \x1b[31mwith errors\x1b[0m' : ''}`);
|
||||||
r(code);
|
r(code ?? 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
if (codes.some(code => code !== 0)) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} message
|
* @param {string} message
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ xterm.js version: {{version}}
|
|||||||
{{#CSI.length}}
|
{{#CSI.length}}
|
||||||
- [CSI](#csi)
|
- [CSI](#csi)
|
||||||
{{/CSI.length}}
|
{{/CSI.length}}
|
||||||
|
{{#APC.length}}
|
||||||
|
- [APC](#apc)
|
||||||
|
{{/APC.length}}
|
||||||
{{#DCS.length}}
|
{{#DCS.length}}
|
||||||
- [DCS](#dcs)
|
- [DCS](#dcs)
|
||||||
{{/DCS.length}}
|
{{/DCS.length}}
|
||||||
@@ -80,8 +83,9 @@ This document lists xterm.js' support of terminal sequences. The sequences are g
|
|||||||
- CSI - Control Sequence Introducer: sequence starting with \`ESC [\` (7bit) or CSI (\`\\x9B\`, 8bit)
|
- CSI - Control Sequence Introducer: sequence starting with \`ESC [\` (7bit) or CSI (\`\\x9B\`, 8bit)
|
||||||
- DCS - Device Control String: sequence starting with \`ESC P\` (7bit) or DCS (\`\\x90\`, 8bit)
|
- DCS - Device Control String: sequence starting with \`ESC P\` (7bit) or DCS (\`\\x90\`, 8bit)
|
||||||
- OSC - Operating System Command: sequence starting with \`ESC ]\` (7bit) or OSC (\`\\x9D\`, 8bit)
|
- OSC - Operating System Command: sequence starting with \`ESC ]\` (7bit) or OSC (\`\\x9D\`, 8bit)
|
||||||
|
- APC - Application Program Command: sequence starting with \`ESC _\` (7bit) or OSC (\`\\x9F\`, 8bit)
|
||||||
|
|
||||||
Application Program Command (APC), Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
|
Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
|
||||||
any sequence of these types will be silently ignored. They are also not hookable by the API.
|
any sequence of these types will be silently ignored. They are also not hookable by the API.
|
||||||
|
|
||||||
Note that the list only marks sequences implemented in xterm.js' core codebase as supported. Missing sequences are either
|
Note that the list only marks sequences implemented in xterm.js' core codebase as supported. Missing sequences are either
|
||||||
@@ -178,6 +182,33 @@ To denote the sequences the tables use the same abbreviations as xterm does:
|
|||||||
{{/CSI.length}}
|
{{/CSI.length}}
|
||||||
|
|
||||||
|
|
||||||
|
{{#APC.length}}
|
||||||
|
## APC
|
||||||
|
|
||||||
|
| Mnemonic | Name | Sequence | Short Description | Support |
|
||||||
|
| -------- | ---- | -------- | ----------------- | ------- |
|
||||||
|
{{#APC}}
|
||||||
|
| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} |
|
||||||
|
{{/APC}}
|
||||||
|
|
||||||
|
{{#APC.hasLongDescriptions}}
|
||||||
|
{{#APC}}
|
||||||
|
{{#longDescription.length}}
|
||||||
|
<section class="sequence-details">
|
||||||
|
|
||||||
|
### {{name}}
|
||||||
|
{{#longDescription}}
|
||||||
|
{{{.}}}
|
||||||
|
{{/longDescription}}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{{/longDescription.length}}
|
||||||
|
{{/APC}}
|
||||||
|
{{/APC.hasLongDescriptions}}
|
||||||
|
|
||||||
|
{{/APC.length}}
|
||||||
|
|
||||||
|
|
||||||
{{#DCS.length}}
|
{{#DCS.length}}
|
||||||
## DCS
|
## DCS
|
||||||
|
|
||||||
|
|||||||
+41
-11
@@ -33,17 +33,47 @@ if (files.length === 0) {
|
|||||||
|
|
||||||
console.log(`Linting ${files.length} changed file(s)...`);
|
console.log(`Linting ${files.length} changed file(s)...`);
|
||||||
|
|
||||||
const eslintArgs = ['--max-warnings', '0', '--no-warn-ignored'];
|
const cwd = path.join(__dirname, '..');
|
||||||
if (fix) {
|
const isWin = process.platform === 'win32';
|
||||||
eslintArgs.push('--fix');
|
|
||||||
|
/**
|
||||||
|
* @param {string} command
|
||||||
|
* @param {string[]} args
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function run(command, args) {
|
||||||
|
return new Promise(/** @type {(resolve: () => void, reject: (reason: Error) => void) => void} */ ((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd,
|
||||||
|
shell: isWin
|
||||||
|
});
|
||||||
|
child.on('close', code => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`${command} exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
eslintArgs.push(...files);
|
|
||||||
|
|
||||||
const eslint = process.platform === 'win32' ? 'eslint.cmd' : 'eslint';
|
async function main() {
|
||||||
const child = spawn(eslint, eslintArgs, {
|
const oxlintArgs = ['--type-aware', '--max-warnings', '0'];
|
||||||
stdio: 'inherit',
|
if (fix) {
|
||||||
cwd: path.join(__dirname, '..'),
|
oxlintArgs.push('--fix');
|
||||||
shell: process.platform === 'win32'
|
}
|
||||||
|
oxlintArgs.push(...files);
|
||||||
|
|
||||||
|
const oxlint = isWin ? 'oxlint.cmd' : 'oxlint';
|
||||||
|
await run(oxlint, oxlintArgs);
|
||||||
|
|
||||||
|
const eslintArgs = ['--config', 'eslint.config.naming.mjs', '--max-warnings', '0', ...files];
|
||||||
|
const eslint = isWin ? 'eslint.cmd' : 'eslint';
|
||||||
|
await run(eslint, eslintArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', code => process.exit(code ?? 0));
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import { basename, dirname, resolve, sep } from 'node:path';
|
import { basename, dirname, resolve, sep } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const nodeModulesPath = resolve(repoRoot, 'node_modules');
|
const nodeModulesPath = resolve(repoRoot, 'node_modules');
|
||||||
const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|||||||
|
|
||||||
/** @param {string} message */
|
/** @param {string} message */
|
||||||
function log(message) {
|
function log(message) {
|
||||||
console.info(`[setup-fast] ${message}`);
|
console.info(`[setup-full] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {string[]} args */
|
/** @param {string[]} args */
|
||||||
+12
-3
@@ -12,7 +12,7 @@ let argv = process.argv.slice(2);
|
|||||||
let suiteFilter = undefined;
|
let suiteFilter = undefined;
|
||||||
while (argv.some(e => e.startsWith('--suite='))) {
|
while (argv.some(e => e.startsWith('--suite='))) {
|
||||||
const i = argv.findIndex(e => e.startsWith('--suite='));
|
const i = argv.findIndex(e => e.startsWith('--suite='));
|
||||||
const match = argv[i].match(/--suite=(?<suitename>.+)/)
|
const match = argv[i].match(/--suite=(?<suitename>.+)/);
|
||||||
suiteFilter = match?.groups?.suitename ?? undefined;
|
suiteFilter = match?.groups?.suitename ?? undefined;
|
||||||
argv.splice(i, 1);
|
argv.splice(i, 1);
|
||||||
}
|
}
|
||||||
@@ -42,12 +42,18 @@ if (suiteFilter) {
|
|||||||
configs = configs.filter(e => e.name === suiteFilter);
|
configs = configs.filter(e => e.name === suiteFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (suiteFilter && configs.length === 0) {
|
||||||
|
console.error(`Unknown suite: ${suiteFilter}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
function npmBinScript(script) {
|
function npmBinScript(script) {
|
||||||
return path.resolve(__dirname, `../node_modules/.bin/` + (process.platform === 'win32' ?
|
return path.resolve(__dirname, `../node_modules/.bin/` + (process.platform === 'win32' ?
|
||||||
`${script}.cmd` : script));
|
`${script}.cmd` : script));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
let exitCode = 0;
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const command = npmBinScript('playwright');
|
const command = npmBinScript('playwright');
|
||||||
const args = ['test', '-c', config.path, ...argv];
|
const args = ['test', '-c', config.path, ...argv];
|
||||||
@@ -62,10 +68,13 @@ async function run() {
|
|||||||
|
|
||||||
if (run.error) {
|
if (run.error) {
|
||||||
console.error(run.error);
|
console.error(run.error);
|
||||||
process.exit(run.status ?? -1);
|
process.exit(run.status ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
process.exit(run.status);
|
if (run.status) {
|
||||||
|
exitCode = run.status;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
run();
|
run();
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ const ENC = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function printMenu() {
|
function printMenu() {
|
||||||
console.log('\x1b[2J\x1b\[HTest mouse reports [Ctrl-C to exit]');
|
console.log('\x1b[2J\x1b[HTest mouse reports [Ctrl-C to exit]');
|
||||||
console.log();
|
console.log();
|
||||||
console.log(' Selected protocol [Ctrl-A to switch]');
|
console.log(' Selected protocol [Ctrl-A to switch]');
|
||||||
const protocols = Object.keys(PROTOCOLS);
|
const protocols = Object.keys(PROTOCOLS);
|
||||||
|
|||||||
+3
-5
@@ -8,7 +8,7 @@ const path = require('path');
|
|||||||
|
|
||||||
const COVERAGE_LINES_THRESHOLD = 40;
|
const COVERAGE_LINES_THRESHOLD = 40;
|
||||||
|
|
||||||
// Add `out` to the NODE_PATH so absolute paths can be resolved.
|
// Add `out-esbuild` to NODE_PATH so addon path-mapped imports (common/*, browser/*) resolve.
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
env.NODE_PATH = path.resolve(__dirname, '../out-esbuild');
|
env.NODE_PATH = path.resolve(__dirname, '../out-esbuild');
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ if (process.argv.length > 2) {
|
|||||||
flagArgs = args.filter(e => e.startsWith('--'));
|
flagArgs = args.filter(e => e.startsWith('--'));
|
||||||
// ability to inject particular test files via
|
// ability to inject particular test files via
|
||||||
// npm run test [testFileA testFileB ...]
|
// npm run test [testFileA testFileB ...]
|
||||||
files = args.filter(e => !e.startsWith('--'));
|
const files = args.filter(e => !e.startsWith('--'));
|
||||||
if (files.length) {
|
if (files.length) {
|
||||||
testFiles = files;
|
testFiles = files;
|
||||||
}
|
}
|
||||||
@@ -39,8 +39,6 @@ if (checkCoverage) {
|
|||||||
...testFiles,
|
...testFiles,
|
||||||
...flagArgs
|
...flagArgs
|
||||||
];
|
];
|
||||||
console.info('executable', executable);
|
|
||||||
console.info('args', args);
|
|
||||||
const run = cp.spawnSync(
|
const run = cp.spawnSync(
|
||||||
executable,
|
executable,
|
||||||
args,
|
args,
|
||||||
@@ -51,7 +49,7 @@ if (checkCoverage) {
|
|||||||
stdio: 'inherit'
|
stdio: 'inherit'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
process.exit(run.status);
|
process.exit(run.status ?? -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = cp.spawnSync(
|
const run = cp.spawnSync(
|
||||||
|
|||||||
@@ -617,8 +617,12 @@ function addDomListener(element: HTMLElement, type: string, handler: (...args: a
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateTerminalSize(): void {
|
function updateTerminalSize(): void {
|
||||||
|
const showScrollbar = term!.options.scrollbar?.showScrollbar ?? true;
|
||||||
|
const scrollBarWidth = (term!.options.scrollback === 0 || !showScrollbar)
|
||||||
|
? 0
|
||||||
|
: (term!.options.scrollbar?.width ?? 14);
|
||||||
const width = optionsWindow.autoResize ? '100%'
|
const width = optionsWindow.autoResize ? '100%'
|
||||||
: ((term as any).dimensions.css.canvas.width + (term as any)._core.viewport.scrollBarWidth).toString() + 'px';
|
: ((term as any).dimensions.css.canvas.width + scrollBarWidth).toString() + 'px';
|
||||||
const height = optionsWindow.autoResize ? '100%'
|
const height = optionsWindow.autoResize ? '100%'
|
||||||
: ((term as any).dimensions.css.canvas.height).toString() + 'px';
|
: ((term as any).dimensions.css.canvas.height).toString() + 'px';
|
||||||
terminalContainer!.style.width = width;
|
terminalContainer!.style.width = width;
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
|||||||
const dtIip = document.createElement('dt');
|
const dtIip = document.createElement('dt');
|
||||||
dtIip.textContent = 'IIP (iTerm)';
|
dtIip.textContent = 'IIP (iTerm)';
|
||||||
dlIip.appendChild(dtIip);
|
dlIip.appendChild(dtIip);
|
||||||
this._addDdWithButton(dlIip, 'image-demo3', 'palette');
|
this._addDdWithButton(dlIip, 'image-demo3', 'palette (png File)');
|
||||||
|
this._addDdWithButton(dlIip, 'image-demo4', 'dice (qoi MultipartFile)');
|
||||||
container.appendChild(dlIip);
|
container.appendChild(dlIip);
|
||||||
|
|
||||||
// Kitty demos
|
// Kitty demos
|
||||||
@@ -141,6 +142,21 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
|||||||
this._terminal.write(`\x1b]1337;File=inline=1;size=${data.length}:${btoa(sdata)}\x1b\\`);
|
this._terminal.write(`\x1b]1337;File=inline=1;size=${data.length}:${btoa(sdata)}\x1b\\`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const iipDemoMulti = (url: string) => () => fetch(url)
|
||||||
|
.then(resp => resp.arrayBuffer())
|
||||||
|
.then(buffer => {
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
let sdata = '';
|
||||||
|
for (let i = 0; i < data.length; ++i) sdata += String.fromCharCode(data[i]);
|
||||||
|
const encoded = btoa(sdata);
|
||||||
|
this._terminal.write('\r\n');
|
||||||
|
this._terminal.write(`\x1b]1337;MultipartFile=inline=1\x1b\\`);
|
||||||
|
for (let i = 0; i < encoded.length; i += 100) {
|
||||||
|
this._terminal.write(`\x1b]1337;FilePart=${encoded.slice(i, i + 100)}\x1b\\`);
|
||||||
|
}
|
||||||
|
this._terminal.write(`\x1b]1337;FileEnd\x1b\\`);
|
||||||
|
});
|
||||||
|
|
||||||
const kittyDemo = (url: string) => () => fetch(url)
|
const kittyDemo = (url: string) => () => fetch(url)
|
||||||
.then(resp => resp.arrayBuffer())
|
.then(resp => resp.arrayBuffer())
|
||||||
.then(buffer => {
|
.then(buffer => {
|
||||||
@@ -158,6 +174,8 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
|||||||
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
|
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
|
||||||
document.getElementById('image-demo3')!.addEventListener('click',
|
document.getElementById('image-demo3')!.addEventListener('click',
|
||||||
iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
||||||
|
document.getElementById('image-demo4')!.addEventListener('click',
|
||||||
|
iipDemoMulti('https://raw.githubusercontent.com/jerch/xterm-wasm-parts/master/fixtures/qoi/dice.qoi'));
|
||||||
document.getElementById('image-demo-kitty1')!.addEventListener('click',
|
document.getElementById('image-demo-kitty1')!.addEventListener('click',
|
||||||
kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
||||||
|
|
||||||
@@ -176,7 +194,7 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
const pos = (this._terminal as any)._core._mouseService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows);
|
const pos = (this._terminal as any)._core._mouseCoordsService!.getCoords(ev, (this._terminal as any)._core.screenElement!, this._terminal.cols, this._terminal.rows);
|
||||||
const x = pos[0] - 1;
|
const x = pos[0] - 1;
|
||||||
const y = pos[1] - 1;
|
const y = pos[1] - 1;
|
||||||
const canvas = ev.shiftKey
|
const canvas = ev.shiftKey
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export class CellInspectorWindow extends BaseWindow implements IControlWindow {
|
|||||||
|
|
||||||
terminal.element.addEventListener('mousemove', (e: MouseEvent) => {
|
terminal.element.addEventListener('mousemove', (e: MouseEvent) => {
|
||||||
const core = (terminal as any)._core;
|
const core = (terminal as any)._core;
|
||||||
const coords = core._mouseService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
|
const coords = core._mouseCoordsService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
|
||||||
if (!coords) {
|
if (!coords) {
|
||||||
this._clearDisplay();
|
this._clearDisplay();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class GpuWindow extends BaseWindow implements IControlWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _styleAtlasPage(canvas: HTMLCanvasElement): void {
|
private _styleAtlasPage(canvas: HTMLCanvasElement): void {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// oxlint-disable-next-line eslint-js/no-restricted-syntax
|
||||||
const dpr = window.devicePixelRatio;
|
const dpr = window.devicePixelRatio;
|
||||||
canvas.style.width = `${canvas.width / dpr}px`;
|
canvas.style.width = `${canvas.width / dpr}px`;
|
||||||
canvas.style.height = `${canvas.height / dpr}px`;
|
canvas.style.height = `${canvas.height / dpr}px`;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user