Compare commits
382
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd6a339bcb | ||
|
|
a2552053f5 | ||
|
|
f3783729aa | ||
|
|
a97b77af3f | ||
|
|
275d6d8a80 | ||
|
|
8679eba407 | ||
|
|
3960db39a9 | ||
|
|
ff8a0d9c99 | ||
|
|
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+13
-13
@@ -207,31 +207,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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,10 @@ 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.
|
||||||
- [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.
|
||||||
|
|||||||
@@ -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,23 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Base64 implements IBase64 {
|
export class Base64 implements IBase64 {
|
||||||
public encodeText(data: string): string {
|
public encodeText(data: string): string {
|
||||||
return JSBase64.encode(data);
|
return btoa(data);
|
||||||
}
|
}
|
||||||
public decodeText(data: string): string {
|
public decodeText(data: string): string {
|
||||||
const text = JSBase64.decode(data);
|
try {
|
||||||
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
|
return atob(data);
|
||||||
return '';
|
} catch {}
|
||||||
}
|
return '';
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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,15 +68,24 @@ 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`);
|
||||||
|
|||||||
+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 this 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).
|
||||||
@@ -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,6 +146,7 @@ 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, lets look at the data flow and processing steps:
|
||||||
@@ -180,8 +187,8 @@ _How can I adjust the memory usage?_
|
|||||||
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 is only temporarily peaking, 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 memory peaking for 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 peaking 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
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -113,8 +113,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))) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"lib": [
|
"lib": [
|
||||||
"es2021",
|
"es2021",
|
||||||
],
|
],
|
||||||
// "downlevelIteration": true,
|
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -112,7 +114,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 +128,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 +137,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 +164,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
|
||||||
@@ -222,12 +226,12 @@ export class GlyphRenderer extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +292,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 +314,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 +350,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,7 +360,9 @@ 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
|
||||||
|
// monotonic, so a page object swap at the same index (which happens after a page merge)
|
||||||
|
// is detected by the same comparison.
|
||||||
for (let i = 0; i < this._atlas.pages.length; i++) {
|
for (let i = 0; i < this._atlas.pages.length; 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);
|
||||||
@@ -364,11 +370,15 @@ export class GlyphRenderer extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
this.invalidateAtlasTextures();
|
||||||
|
}
|
||||||
|
|
||||||
|
public invalidateAtlasTextures(): void {
|
||||||
for (const glTexture of this._atlasTextures) {
|
for (const glTexture of this._atlasTextures) {
|
||||||
glTexture.version = -1;
|
glTexture.version = -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -133,7 +134,9 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
|
|
||||||
private _requestClearModel = false;
|
private _requestClearModel = false;
|
||||||
public beginFrame(): boolean {
|
public beginFrame(): boolean {
|
||||||
return this._requestClearModel;
|
const result = this._requestClearModel;
|
||||||
|
this._requestClearModel = false;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearTexture(): void {
|
public clearTexture(): void {
|
||||||
@@ -193,7 +196,7 @@ export class TextureAtlas implements ITextureAtlas {
|
|||||||
|
|
||||||
// Merge into the new page
|
// Merge into the new page
|
||||||
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
|
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
|
||||||
mergedPage.version++;
|
mergedPage.version = ++AtlasPage.nextVersion;
|
||||||
|
|
||||||
// Delete the pages, shifting glyph texture pages as needed
|
// Delete the pages, shifting glyph texture pages as needed
|
||||||
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
|
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
|
||||||
@@ -251,7 +254,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +530,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
|
||||||
@@ -937,7 +940,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 +1050,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
|
||||||
@@ -1092,7 +1099,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -493,7 +513,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 +558,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 +573,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 +586,14 @@ 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._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0);
|
||||||
this._model.cells[j] = NULL_CELL_CODE;
|
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._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||||
this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
|
this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||||
this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
|
this._model.cells[j + RenderModelConstants.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ const config: PlaywrightTestConfig = {
|
|||||||
{
|
{
|
||||||
name: 'Chromium',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium'
|
browserName: 'chromium',
|
||||||
|
launchOptions: {
|
||||||
|
args: ['--enable-unsafe-swiftshader']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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));
|
|
||||||
|
|||||||
+1
-1
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
|||||||
'linkHandler',
|
'linkHandler',
|
||||||
'logger',
|
'logger',
|
||||||
'quirks',
|
'quirks',
|
||||||
|
'scrollbar',
|
||||||
'theme',
|
'theme',
|
||||||
'vtExtensions',
|
'vtExtensions',
|
||||||
'windowOptions',
|
'windowOptions',
|
||||||
@@ -251,6 +252,11 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
|||||||
input.value = 'auto';
|
input.value = 'auto';
|
||||||
this._handlers.updateTerminalSize();
|
this._handlers.updateTerminalSize();
|
||||||
}
|
}
|
||||||
|
const screenElement = document.querySelector('.xterm-screen');
|
||||||
|
if (screenElement instanceof HTMLElement) {
|
||||||
|
screenElement.style.outline = this._autoResize ? ''
|
||||||
|
: 'solid thin red';
|
||||||
|
}
|
||||||
} else if (o === 'theme') {
|
} else if (o === 'theme') {
|
||||||
value = this._getTheme();
|
value = this._getTheme();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -705,13 +705,13 @@ function loadTest(term: Terminal, addons: AddonCollection): void {
|
|||||||
term.write('', () => {
|
term.write('', () => {
|
||||||
const time = Math.round(performance.now() - start);
|
const time = Math.round(performance.now() - start);
|
||||||
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
|
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
|
||||||
term.write(`\n\r\nWrote ${byteCount}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
|
term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
|
||||||
// Send ^C to get a new prompt
|
// Send ^C to get a new prompt
|
||||||
(term as any)._core._onData.fire('\x03');
|
(term as any)._core._onData.fire('\x03');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
|
async function loadTestLongLines(term: Terminal, addons: AddonCollection): Promise<void> {
|
||||||
const rendererName = addons.webgl.instance ? 'webgl' : 'dom';
|
const rendererName = addons.webgl.instance ? 'webgl' : 'dom';
|
||||||
const testData = [];
|
const testData = [];
|
||||||
let byteCount = 0;
|
let byteCount = 0;
|
||||||
@@ -729,16 +729,23 @@ function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
|
|||||||
testData.push(data);
|
testData.push(data);
|
||||||
}
|
}
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
for (let i = 0; i < 1024 * 50; i++) {
|
for (let i = 0; i < 1024; i++) {
|
||||||
for (const d of testData) {
|
for (const d of testData) {
|
||||||
term.write(d);
|
try {
|
||||||
|
term.write(d);
|
||||||
|
} catch {
|
||||||
|
// Flush events when cap is hit and try again(workaround for not having flow control in
|
||||||
|
// demo)
|
||||||
|
await new Promise<void>(r => term.write('', () => r()));
|
||||||
|
term.write(d);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Wait for all data to be parsed before evaluating time
|
// Wait for all data to be parsed before evaluating time
|
||||||
term.write('', () => {
|
term.write('', () => {
|
||||||
const time = Math.round(performance.now() - start);
|
const time = Math.round(performance.now() - start);
|
||||||
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
|
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
|
||||||
term.write(`\n\r\nWrote ${byteCount}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
|
term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
|
||||||
// Send ^C to get a new prompt
|
// Send ^C to get a new prompt
|
||||||
(term as any)._core._onData.fire('\x03');
|
(term as any)._core._onData.fire('\x03');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class WebglWindow 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`;
|
||||||
|
|||||||
+42
-13
@@ -2,25 +2,54 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"target": "es2021",
|
"target": "es2021",
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
"outDir": "../out-demo/client",
|
"outDir": "../out-demo/client",
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@xterm/addon-attach": ["../../addons/addon-attach"],
|
"@xterm/addon-attach": [
|
||||||
"@xterm/addon-clipboard": ["../../addons/addon-clipboard"],
|
"../../addons/addon-attach"
|
||||||
"@xterm/addon-fit": ["../../addons/addon-fit"],
|
],
|
||||||
"@xterm/addon-image": ["../../addons/addon-image"],
|
"@xterm/addon-clipboard": [
|
||||||
"@xterm/addon-progress": ["../../addons/addon-progress"],
|
"../../addons/addon-clipboard"
|
||||||
"@xterm/addon-search": ["../../addons/addon-search"],
|
],
|
||||||
"@xterm/addon-serialize": ["../../addons/addon-serialize"],
|
"@xterm/addon-fit": [
|
||||||
"@xterm/addon-web-fonts": ["../../addons/addon-web-fonts"],
|
"../../addons/addon-fit"
|
||||||
"@xterm/addon-web-links": ["../../addons/addon-web-links"],
|
],
|
||||||
"@xterm/addon-webgl": ["../../addons/addon-webgl"],
|
"@xterm/addon-image": [
|
||||||
"@xterm/addon-unicode11": ["../../addons/addon-unicode11"],
|
"../../addons/addon-image"
|
||||||
"@xterm/addon-unicode-graphemes": ["../../addons/addon-unicode-graphemes"],
|
],
|
||||||
"@xterm/addon-ligatures": ["../../addons/addon-ligatures"],
|
"@xterm/addon-progress": [
|
||||||
|
"../../addons/addon-progress"
|
||||||
|
],
|
||||||
|
"@xterm/addon-search": [
|
||||||
|
"../../addons/addon-search"
|
||||||
|
],
|
||||||
|
"@xterm/addon-serialize": [
|
||||||
|
"../../addons/addon-serialize"
|
||||||
|
],
|
||||||
|
"@xterm/addon-web-fonts": [
|
||||||
|
"../../addons/addon-web-fonts"
|
||||||
|
],
|
||||||
|
"@xterm/addon-web-links": [
|
||||||
|
"../../addons/addon-web-links"
|
||||||
|
],
|
||||||
|
"@xterm/addon-webgl": [
|
||||||
|
"../../addons/addon-webgl"
|
||||||
|
],
|
||||||
|
"@xterm/addon-unicode11": [
|
||||||
|
"../../addons/addon-unicode11"
|
||||||
|
],
|
||||||
|
"@xterm/addon-unicode-graphemes": [
|
||||||
|
"../../addons/addon-unicode-graphemes"
|
||||||
|
],
|
||||||
|
"@xterm/addon-ligatures": [
|
||||||
|
"../../addons/addon-ligatures"
|
||||||
|
],
|
||||||
"*": ["./*"]
|
"*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function startServer(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
appWs.ws('/terminals/:pid', function (ws, req) {
|
appWs.ws('/terminals/:pid', function (ws, req) {
|
||||||
const term = terminals[parseInt(req.params.pid)];
|
const term = terminals[parseInt(Array.isArray(req.params.pid) ? req.params.pid[0] : req.params.pid)];
|
||||||
console.log('Connected to terminal ' + term.pid);
|
console.log('Connected to terminal ' + term.pid);
|
||||||
temporaryDisposable[term.pid].dispose();
|
temporaryDisposable[term.pid].dispose();
|
||||||
delete temporaryDisposable[term.pid];
|
delete temporaryDisposable[term.pid];
|
||||||
|
|||||||
+3
-1
@@ -37,12 +37,13 @@ export default tseslint.config(
|
|||||||
'@stylistic/comma-spacing': ['warn', { before: false, after: true }],
|
'@stylistic/comma-spacing': ['warn', { before: false, after: true }],
|
||||||
'@stylistic/indent': ['warn', 2],
|
'@stylistic/indent': ['warn', 2],
|
||||||
'@stylistic/semi': ['warn', 'always'],
|
'@stylistic/semi': ['warn', 'always'],
|
||||||
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: true }],
|
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: 'always' }],
|
||||||
'@stylistic/member-delimiter-style': ['warn', {
|
'@stylistic/member-delimiter-style': ['warn', {
|
||||||
multiline: { delimiter: 'semi', requireLast: true },
|
multiline: { delimiter: 'semi', requireLast: true },
|
||||||
singleline: { delimiter: 'comma', requireLast: false }
|
singleline: { delimiter: 'comma', requireLast: false }
|
||||||
}],
|
}],
|
||||||
'@stylistic/type-annotation-spacing': 'warn',
|
'@stylistic/type-annotation-spacing': 'warn',
|
||||||
|
'@stylistic/space-unary-ops': 'warn',
|
||||||
|
|
||||||
'@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }],
|
'@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }],
|
||||||
'@typescript-eslint/consistent-type-assertions': 'warn',
|
'@typescript-eslint/consistent-type-assertions': 'warn',
|
||||||
@@ -85,6 +86,7 @@ export default tseslint.config(
|
|||||||
'eqeqeq': ['warn', 'always'],
|
'eqeqeq': ['warn', 'always'],
|
||||||
'jsdoc/check-alignment': 'warn',
|
'jsdoc/check-alignment': 'warn',
|
||||||
'jsdoc/check-param-names': 'warn',
|
'jsdoc/check-param-names': 'warn',
|
||||||
|
'jsdoc/multiline-blocks': 'warn',
|
||||||
'jsdoc/no-multi-asterisks': 'warn',
|
'jsdoc/no-multi-asterisks': 'warn',
|
||||||
'keyword-spacing': 'warn',
|
'keyword-spacing': 'warn',
|
||||||
'max-len': ['warn', {
|
'max-len': ['warn', {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @ts-check
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
const ignores = [
|
||||||
|
'addons/*/src/third-party/*.ts',
|
||||||
|
'**/out/*',
|
||||||
|
'**/out-test/*',
|
||||||
|
'**/out-esbuild/*',
|
||||||
|
'**/out-esbuild-test/*',
|
||||||
|
'**/inwasm-sdks/*',
|
||||||
|
'**/node_modules',
|
||||||
|
'**/*.js',
|
||||||
|
'**/*.mjs'
|
||||||
|
];
|
||||||
|
|
||||||
|
const namingConventionMain = [
|
||||||
|
'warn',
|
||||||
|
{ selector: 'default', format: ['camelCase'], filter: { regex: '^[a-z]', match: true } },
|
||||||
|
{ selector: 'variable', format: ['camelCase', 'UPPER_CASE'] },
|
||||||
|
{ selector: 'variable', filter: '^I.+Service$', format: ['PascalCase'], prefix: ['I'] },
|
||||||
|
{ selector: 'memberLike', modifiers: ['private'], format: ['camelCase'], leadingUnderscore: 'require' },
|
||||||
|
{ selector: 'memberLike', modifiers: ['protected'], format: ['camelCase'], leadingUnderscore: 'require' },
|
||||||
|
{ selector: 'enumMember', format: ['UPPER_CASE'] },
|
||||||
|
{ selector: 'property', modifiers: ['public'], format: ['camelCase', 'UPPER_CASE'], filter: { regex: '^[a-z]', match: true } },
|
||||||
|
{ selector: 'method', modifiers: ['public'], format: ['camelCase', 'UPPER_CASE'], custom: { regex: '^on[A-Z].+', match: false } },
|
||||||
|
{ selector: 'method', modifiers: ['private'], format: ['camelCase'], leadingUnderscore: 'require', custom: { regex: '^on[A-Z].+', match: false } },
|
||||||
|
{ selector: 'method', modifiers: ['protected'], format: ['camelCase'], leadingUnderscore: 'require', custom: { regex: '^on[A-Z].+', match: false } },
|
||||||
|
{ selector: 'typeLike', format: ['PascalCase'] },
|
||||||
|
{ selector: 'interface', format: ['PascalCase'], prefix: ['I'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
const namingConventionTypings = [
|
||||||
|
'warn',
|
||||||
|
{ selector: 'typeLike', format: ['PascalCase'] },
|
||||||
|
{ selector: 'interface', format: ['PascalCase'], prefix: ['I'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
ignores: ['typings/**/*.d.ts', 'addons/**/typings/**'],
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tseslint.plugin
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parser: tseslint.parser,
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/naming-convention': namingConventionMain
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['typings/**/*.d.ts'],
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tseslint.plugin
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parser: tseslint.parser
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/naming-convention': namingConventionTypings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -17,7 +17,7 @@ export default tseslint.config(
|
|||||||
rules: {
|
rules: {
|
||||||
'@stylistic/indent': ['warn', 2],
|
'@stylistic/indent': ['warn', 2],
|
||||||
'@stylistic/semi': ['warn', 'always'],
|
'@stylistic/semi': ['warn', 'always'],
|
||||||
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: true }],
|
'@stylistic/quotes': ['warn', 'single', { allowTemplateLiterals: 'always' }],
|
||||||
'@stylistic/member-delimiter-style': ['warn', {
|
'@stylistic/member-delimiter-style': ['warn', {
|
||||||
multiline: { delimiter: 'semi', requireLast: true },
|
multiline: { delimiter: 'semi', requireLast: true },
|
||||||
singleline: { delimiter: 'comma', requireLast: false }
|
singleline: { delimiter: 'comma', requireLast: false }
|
||||||
|
|||||||
+5
-5
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
⚠ This package is experimental
|
⚠ This package is experimental
|
||||||
|
|
||||||
`xterm-headless` is a headless terminal that can be run in node.js. This is useful in combination with the frontend [`xterm`](https://www.npmjs.com/package/xterm) for example to keep track of a terminal's state on a remote server where the process is hosted.
|
`@xterm/headless` is a headless terminal that can be run in node.js. This is useful in combination with the frontend [`xterm`](https://www.npmjs.com/package/@xterm/xterm) for example to keep track of a terminal's state on a remote server where the process is hosted.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
First, you need to install the module, we ship exclusively through npm, so you need that installed and then add xterm.js as a dependency by running:
|
First, you need to install the module, we ship exclusively through npm, so you need that installed and then add xterm.js as a dependency by running:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install xterm-headless
|
npm install @xterm/headless
|
||||||
```
|
```
|
||||||
|
|
||||||
Then import as you would a regular node package. The recommended way to load `xterm-headless` is with TypeScript and the ES6 module syntax:
|
Then import as you would a regular node package. The recommended way to load `@xterm/headless` is with TypeScript and the ES6 module syntax:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import { Terminal } from '@xterm/headless';
|
import { Terminal } from '@xterm/headless';
|
||||||
@@ -20,12 +20,12 @@ import { Terminal } from '@xterm/headless';
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
The full API for `xterm-headless` is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm-headless.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
|
The full API for `@xterm/headless` is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm-headless.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
|
||||||
|
|
||||||
Note that some APIs are marked *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs.
|
Note that some APIs are marked *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs.
|
||||||
|
|
||||||
### Addons
|
### Addons
|
||||||
|
|
||||||
Addons in `xterm-headless` work the [same as in `xterm`](https://github.com/xtermjs/xterm.js/blob/master/README.md#addons) with the one caveat being that the addon needs to be packaged for node.js and not use any DOM APIs.
|
Addons in `@xterm/headless` work the [same as in `xterm`](https://github.com/xtermjs/xterm.js/blob/master/README.md#addons) with the one caveat being that the addon needs to be packaged for node.js and not use any DOM APIs.
|
||||||
|
|
||||||
Currently no official addons are packaged on npm.
|
Currently no official addons are packaged on npm.
|
||||||
|
|||||||
Generated
+3150
-2987
File diff suppressed because it is too large
Load Diff
+38
-41
@@ -29,6 +29,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"presetup": "npm run build",
|
"presetup": "npm run build",
|
||||||
"setup": "npm run esbuild",
|
"setup": "npm run esbuild",
|
||||||
|
"setup-full": "node bin/setup-full.mjs",
|
||||||
"postsetup": "npm run esbuild-demo-server",
|
"postsetup": "npm run esbuild-demo-server",
|
||||||
"start": "node demo/start",
|
"start": "node demo/start",
|
||||||
"dev": "concurrently -k -p [{name}] -n tsc,esbuild,esbuild-demo-client,esbuild-demo-server,server -c blue,yellow,cyan,green,magenta \"npm:tsc-watch\" \"npm:esbuild-watch\" \"npm:esbuild-demo-client-watch\" \"npm:esbuild-demo-server-watch\" \"npm:start\"",
|
"dev": "concurrently -k -p [{name}] -n tsc,esbuild,esbuild-demo-client,esbuild-demo-server,server -c blue,yellow,cyan,green,magenta \"npm:tsc-watch\" \"npm:esbuild-watch\" \"npm:esbuild-demo-client-watch\" \"npm:esbuild-demo-server-watch\" \"npm:start\"",
|
||||||
@@ -47,76 +48,72 @@
|
|||||||
"esbuild-demo-server-watch": "node bin/esbuild.mjs --demo-server --watch",
|
"esbuild-demo-server-watch": "node bin/esbuild.mjs --demo-server --watch",
|
||||||
"test": "npm run test-unit",
|
"test": "npm run test-unit",
|
||||||
"posttest": "npm run lint",
|
"posttest": "npm run lint",
|
||||||
"lint": "eslint --max-warnings 0 src/ addons/ demo/ test/",
|
"lint": "oxlint --type-aware --max-warnings 0 src addons demo test && eslint --config eslint.config.naming.mjs --max-warnings 0 src addons demo test",
|
||||||
"lint-changes": "node ./bin/lint_changes.js",
|
"lint-changes": "node ./bin/lint_changes.js",
|
||||||
"lint-changes-fix": "node ./bin/lint_changes.js --fix",
|
"lint-changes-fix": "node ./bin/lint_changes.js --fix",
|
||||||
"lint-fix": "eslint --fix src/ addons/ demo/ test/",
|
"lint-fix": "oxlint --type-aware --fix --max-warnings 0 src addons demo test",
|
||||||
"lint-api": "eslint --config eslint.config.typings.mjs --max-warnings 0 typings/",
|
"lint-api": "oxlint --max-warnings 0 -c .oxlintrc.typings.json typings && eslint --config eslint.config.naming.mjs --max-warnings 0 typings",
|
||||||
"test-unit": "node ./bin/test_unit.js",
|
"test-unit": "node ./bin/test_unit.js",
|
||||||
"test-unit-slow-tests": "npm run test-unit | grep \"ms)\"",
|
"test-unit-slow-tests": "npm run test-unit | grep \"ms)\"",
|
||||||
"test-unit-coverage": "node ./bin/test_unit.js --coverage",
|
"test-unit-coverage": "node ./bin/test_unit.js --coverage",
|
||||||
"test-unit-dev": "cross-env NODE_PATH='./out' mocha",
|
|
||||||
"test-integration": "node ./bin/test_integration.js --workers=75%",
|
"test-integration": "node ./bin/test_integration.js --workers=75%",
|
||||||
"test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=Chromium\"",
|
"test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=Chromium\"",
|
||||||
"test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"",
|
"test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"",
|
||||||
"test-integration-webkit": "node ./bin/test_integration.js --workers=75% \"--project=WebKit\"",
|
"test-integration-webkit": "node ./bin/test_integration.js --workers=75% \"--project=WebKit\"",
|
||||||
"test-integration-debug": "node ./bin/test_integration.js --workers=1 --headed --timeout=30000",
|
"test-integration-debug": "node ./bin/test_integration.js --workers=1 --headed --timeout=30000",
|
||||||
"benchmark": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json",
|
"benchmark": "xterm-benchmark -r 5 -c test/benchmark/benchmark.json",
|
||||||
"benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/*benchmark.js",
|
"benchmark-baseline": "xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/*benchmark.js",
|
||||||
"benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/*benchmark.js",
|
"benchmark-eval": "xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/*benchmark.js",
|
||||||
"clean": "rm -rf lib out addons/*/lib addons/*/out",
|
"clean": "rm -rf lib out addons/*/lib addons/*/out",
|
||||||
"vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts",
|
"vtfeatures": "node bin/extract_vtfeatures.js src/*/*.ts src/*/*/*.ts src/*.ts addons/**/src/*.ts",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"package": "webpack",
|
"package": "webpack",
|
||||||
"postpackage": "npm run esbuild-package",
|
"postpackage": "npm run esbuild-package",
|
||||||
"prepackage-headless": "npm run esbuild-package-headless-only",
|
"prepackage-headless": "npm run esbuild-package-headless-only",
|
||||||
"package-headless": "webpack --config ./webpack.config.headless.js",
|
"package-headless": "webpack --config ./webpack.config.headless.js",
|
||||||
"postpackage-headless": "node ./bin/package_headless.js",
|
"postpackage-headless": "node ./bin/package_headless.js",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package"
|
||||||
"agent:setup-repo": "node bin/agent/setup-repo.mjs"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@lunapaint/png-codec": "^0.2.0",
|
"@lunapaint/png-codec": "^0.2.0",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.59.1",
|
||||||
"@stylistic/eslint-plugin": "^4.4.1",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"@types/chai": "^4.2.22",
|
"@types/chai": "^5.2.3",
|
||||||
"@types/debug": "^4.1.7",
|
"@types/express": "^5.0.6",
|
||||||
"@types/deep-equal": "^1.0.1",
|
|
||||||
"@types/express": "4",
|
|
||||||
"@types/express-ws": "^3.0.1",
|
"@types/express-ws": "^3.0.1",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/mocha": "^9.0.0",
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/node": "^22.19.3",
|
"@types/node": "^22.19.3",
|
||||||
"@types/trusted-types": "^1.0.6",
|
|
||||||
"@types/utf8": "^3.0.0",
|
|
||||||
"@types/webpack": "^5.28.0",
|
"@types/webpack": "^5.28.0",
|
||||||
"@types/ws": "^8.2.0",
|
"@types/ws": "^8.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||||
"@typescript-eslint/parser": "^8.50.1",
|
"@typescript-eslint/parser": "^8.59.2",
|
||||||
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
|
"@typescript/native-preview": "^7.0.0-dev.20260509.2",
|
||||||
"chai": "^4.3.4",
|
"chai": "^6.2.2",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"cross-env": "^7.0.3",
|
"esbuild": "^0.28.0",
|
||||||
"deep-equal": "^2.0.5",
|
"eslint": "^10.3.0",
|
||||||
"esbuild": "~0.25.2",
|
"eslint-plugin-jsdoc": "^62.9.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint-plugin-oxlint": "^1.67.0",
|
||||||
"eslint-plugin-jsdoc": "^50.8.0",
|
"express": "^5.2.1",
|
||||||
"express": "^4.19.2",
|
|
||||||
"express-ws": "^5.0.2",
|
"express-ws": "^5.0.2",
|
||||||
"jsdom": "^27.3.0",
|
"jsdom": "^29.1.1",
|
||||||
"mocha": "^10.1.0",
|
"mocha": "^11.7.5",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"node-pty": "^1.2.0-beta.9",
|
"node-pty": "^1.2.0-beta.9",
|
||||||
"nyc": "^17.1.0",
|
"nyc": "^18.0.0",
|
||||||
"source-map-loader": "^3.0.0",
|
"oxlint": "^1.67.0",
|
||||||
|
"oxlint-plugin-eslint": "^1.67.0",
|
||||||
|
"oxlint-tsgolint": "^0.23.0",
|
||||||
|
"source-map-loader": "^5.0.0",
|
||||||
"source-map-support": "^0.5.20",
|
"source-map-support": "^0.5.20",
|
||||||
"ts-loader": "^9.3.1",
|
"ts-loader": "^9.5.7",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.50.1",
|
"typescript-eslint": "^8.59.2",
|
||||||
"utf8": "^3.0.0",
|
"webpack": "^5.106.2",
|
||||||
"webpack": "^5.61.0",
|
"webpack-cli": "^7.0.2",
|
||||||
"webpack-cli": "^4.9.1",
|
"ws": "^8.20.0",
|
||||||
"ws": "^8.2.3",
|
"xterm-benchmark": "^0.3.2"
|
||||||
"xterm-benchmark": "^0.3.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,18 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Strings from 'browser/LocalizableStrings';
|
import * as Strings from './LocalizableStrings';
|
||||||
import { ITerminal, IRenderDebouncer } from 'browser/Types';
|
import { ITerminal, IRenderDebouncer } from './Types';
|
||||||
import { TimeBasedDebouncer } from 'browser/TimeBasedDebouncer';
|
import { TimeBasedDebouncer } from './TimeBasedDebouncer';
|
||||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
import { Disposable, toDisposable } from '../common/Lifecycle';
|
||||||
import { ICoreBrowserService, IRenderService } from 'browser/services/Services';
|
import { ICoreBrowserService, IRenderService } from './services/Services';
|
||||||
import { IBuffer } from 'common/buffer/Types';
|
import { IBuffer } from '../common/buffer/Types';
|
||||||
import { IInstantiationService } from 'common/services/Services';
|
import { IInstantiationService } from '../common/services/Services';
|
||||||
import { addDisposableListener } from 'browser/Dom';
|
import { addDisposableListener } from './Dom';
|
||||||
|
|
||||||
const MAX_ROWS_TO_READ = 20;
|
const enum Constants {
|
||||||
|
MAX_ROWS_TO_READ = 20
|
||||||
|
}
|
||||||
|
|
||||||
const enum BoundaryPosition {
|
const enum BoundaryPosition {
|
||||||
TOP,
|
TOP,
|
||||||
@@ -137,7 +139,7 @@ export class AccessibilityManager extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _handleChar(char: string): void {
|
private _handleChar(char: string): void {
|
||||||
if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) {
|
if (this._liveRegionLineCount < Constants.MAX_ROWS_TO_READ + 1) {
|
||||||
if (this._charsToConsume.length > 0) {
|
if (this._charsToConsume.length > 0) {
|
||||||
// Have the screen reader ignore the char if it was just input
|
// Have the screen reader ignore the char if it was just input
|
||||||
const shiftedChar = this._charsToConsume.shift();
|
const shiftedChar = this._charsToConsume.shift();
|
||||||
@@ -150,7 +152,7 @@ export class AccessibilityManager extends Disposable {
|
|||||||
|
|
||||||
if (char === '\n') {
|
if (char === '\n') {
|
||||||
this._liveRegionLineCount++;
|
this._liveRegionLineCount++;
|
||||||
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
|
if (this._liveRegionLineCount === Constants.MAX_ROWS_TO_READ + 1) {
|
||||||
this._liveRegion.textContent = Strings.tooMuchOutput.get();
|
this._liveRegion.textContent = Strings.tooMuchOutput.get();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import * as Clipboard from 'browser/Clipboard';
|
import * as Clipboard from './Clipboard';
|
||||||
|
|
||||||
describe('evaluatePastedTextProcessing', () => {
|
describe('evaluatePastedTextProcessing', () => {
|
||||||
it('should replace carriage return and/or line feed with carriage return', () => {
|
it('should replace carriage return and/or line feed with carriage return', () => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ISelectionService } from 'browser/services/Services';
|
import { ISelectionService } from './services/Services';
|
||||||
import { ICoreService, IOptionsService } from 'common/services/Services';
|
import { ICoreService, IOptionsService } from '../common/services/Services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares text to be pasted into the terminal by normalizing the line endings
|
* Prepares text to be pasted into the terminal by normalizing the line endings
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { ColorContrastCache } from 'browser/ColorContrastCache';
|
import { ColorContrastCache } from './ColorContrastCache';
|
||||||
|
|
||||||
describe('ColorContrastCache', () => {
|
describe('ColorContrastCache', () => {
|
||||||
let cache: ColorContrastCache;
|
let cache: ColorContrastCache;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IColorContrastCache } from 'browser/Types';
|
import { IColorContrastCache } from './Types';
|
||||||
import { IColor } from 'common/Types';
|
import { IColor } from '../common/Types';
|
||||||
import { TwoKeyMap } from 'common/MultiKeyMap';
|
import { TwoKeyMap } from '../common/MultiKeyMap';
|
||||||
|
|
||||||
export class ColorContrastCache implements IColorContrastCache {
|
export class ColorContrastCache implements IColorContrastCache {
|
||||||
private _color: TwoKeyMap</* bg */number, /* fg */number, IColor | null> = new TwoKeyMap();
|
private _color: TwoKeyMap</* bg */number, /* fg */number, IColor | null> = new TwoKeyMap();
|
||||||
|
|||||||
@@ -22,42 +22,43 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker, IRenderDimensions as IRenderDimensionsApi } from '@xterm/xterm';
|
import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker, IRenderDimensions as IRenderDimensionsApi } from '@xterm/xterm';
|
||||||
import { copyHandler, handlePasteEvent, moveTextAreaUnderMouseCursor, paste, rightClickHandler } from 'browser/Clipboard';
|
import { copyHandler, handlePasteEvent, moveTextAreaUnderMouseCursor, paste, rightClickHandler } from './Clipboard';
|
||||||
import * as Strings from 'browser/LocalizableStrings';
|
import * as Strings from './LocalizableStrings';
|
||||||
import { OscLinkProvider } from 'browser/OscLinkProvider';
|
import { OscLinkProvider } from './OscLinkProvider';
|
||||||
import { CharacterJoinerHandler, CustomKeyEventHandler, CustomWheelEventHandler, IBrowser, IBufferRange, ICompositionHelper, ILinkifier2, ITerminal } from 'browser/Types';
|
import { CharacterJoinerHandler, CustomKeyEventHandler, CustomWheelEventHandler, IBrowser, IBufferRange, ICompositionHelper, ILinkifier2, ITerminal } from './Types';
|
||||||
import { Viewport } from 'browser/Viewport';
|
import { Viewport } from './Viewport';
|
||||||
import { BufferDecorationRenderer } from 'browser/decorations/BufferDecorationRenderer';
|
import { BufferDecorationRenderer } from './decorations/BufferDecorationRenderer';
|
||||||
import { OverviewRulerRenderer } from 'browser/decorations/OverviewRulerRenderer';
|
import { OverviewRulerRenderer } from './decorations/OverviewRulerRenderer';
|
||||||
import { CompositionHelper } from 'browser/input/CompositionHelper';
|
import { CompositionHelper } from './input/CompositionHelper';
|
||||||
import { DomRenderer } from 'browser/renderer/dom/DomRenderer';
|
import { DomRenderer } from './renderer/dom/DomRenderer';
|
||||||
import { IRenderer } from 'browser/renderer/shared/Types';
|
import { IRenderer } from './renderer/shared/Types';
|
||||||
import { CharSizeService } from 'browser/services/CharSizeService';
|
import { CharSizeService } from './services/CharSizeService';
|
||||||
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
|
import { CharacterJoinerService } from './services/CharacterJoinerService';
|
||||||
import { CoreBrowserService } from 'browser/services/CoreBrowserService';
|
import { CoreBrowserService } from './services/CoreBrowserService';
|
||||||
import { LinkProviderService } from 'browser/services/LinkProviderService';
|
import { LinkProviderService } from './services/LinkProviderService';
|
||||||
import { MouseService } from 'browser/services/MouseService';
|
import { MouseCoordsService } from './services/MouseCoordsService';
|
||||||
import { RenderService } from 'browser/services/RenderService';
|
import { MouseEventCssClasses, MouseService } from './services/MouseService';
|
||||||
import { SelectionService } from 'browser/services/SelectionService';
|
import { RenderService } from './services/RenderService';
|
||||||
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
|
import { SelectionService } from './services/SelectionService';
|
||||||
import { ThemeService } from 'browser/services/ThemeService';
|
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, IMouseService, IRenderService, ISelectionService, IThemeService } from './services/Services';
|
||||||
import { KeyboardService } from 'browser/services/KeyboardService';
|
import { ThemeService } from './services/ThemeService';
|
||||||
import { channels, color, rgb } from 'common/Color';
|
import { KeyboardService } from './services/KeyboardService';
|
||||||
import { CoreTerminal } from 'common/CoreTerminal';
|
import { channels, color, rgb } from '../common/Color';
|
||||||
import * as Browser from 'common/Platform';
|
import { CoreTerminal } from '../common/CoreTerminal';
|
||||||
import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
|
import * as Browser from '../common/Platform';
|
||||||
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
import { ColorRequestType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from '../common/Types';
|
||||||
import { IBuffer } from 'common/buffer/Types';
|
import { DEFAULT_ATTR_DATA } from '../common/buffer/BufferLine';
|
||||||
import { C0, C1ESCAPED } from 'common/data/EscapeSequences';
|
import { IBuffer } from '../common/buffer/Types';
|
||||||
import { toRgbString } from 'common/input/XParseColor';
|
import { C0, C1ESCAPED } from '../common/data/EscapeSequences';
|
||||||
import { DecorationService } from 'common/services/DecorationService';
|
import { toRgbString } from '../common/input/XParseColor';
|
||||||
import { IDecorationService } from 'common/services/Services';
|
import { DecorationService } from '../common/services/DecorationService';
|
||||||
|
import { IDecorationService } from '../common/services/Services';
|
||||||
import { WindowsOptionsReportType } from '../common/InputHandler';
|
import { WindowsOptionsReportType } from '../common/InputHandler';
|
||||||
import { AccessibilityManager } from './AccessibilityManager';
|
import { AccessibilityManager } from './AccessibilityManager';
|
||||||
import { Linkifier } from './Linkifier';
|
import { Linkifier } from './Linkifier';
|
||||||
import { Emitter, EventUtils, type IEvent } from 'common/Event';
|
import { Emitter, EventUtils, type IEvent } from '../common/Event';
|
||||||
import { addDisposableListener } from 'browser/Dom';
|
import { addDisposableListener } from './Dom';
|
||||||
import { MutableDisposable, toDisposable } from 'common/Lifecycle';
|
import { MutableDisposable, toDisposable } from '../common/Lifecycle';
|
||||||
|
|
||||||
export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||||
public textarea: HTMLTextAreaElement | undefined;
|
public textarea: HTMLTextAreaElement | undefined;
|
||||||
@@ -77,7 +78,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
public browser: IBrowser = Browser as any;
|
public browser: IBrowser = Browser as any;
|
||||||
|
|
||||||
private _customKeyEventHandler: CustomKeyEventHandler | undefined;
|
private _customKeyEventHandler: CustomKeyEventHandler | undefined;
|
||||||
private _customWheelEventHandler: CustomWheelEventHandler | undefined;
|
|
||||||
|
|
||||||
// Browser services
|
// Browser services
|
||||||
private readonly _decorationService: DecorationService;
|
private readonly _decorationService: DecorationService;
|
||||||
@@ -87,6 +87,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
// Optional browser services
|
// Optional browser services
|
||||||
private _charSizeService: ICharSizeService | undefined;
|
private _charSizeService: ICharSizeService | undefined;
|
||||||
private _coreBrowserService: ICoreBrowserService | undefined;
|
private _coreBrowserService: ICoreBrowserService | undefined;
|
||||||
|
private _mouseCoordsService: IMouseCoordsService | undefined;
|
||||||
private _mouseService: IMouseService | undefined;
|
private _mouseService: IMouseService | undefined;
|
||||||
private _renderService: IRenderService | undefined;
|
private _renderService: IRenderService | undefined;
|
||||||
private _themeService: IThemeService | undefined;
|
private _themeService: IThemeService | undefined;
|
||||||
@@ -211,7 +212,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
if (!this._themeService) return;
|
if (!this._themeService) return;
|
||||||
for (const req of event) {
|
for (const req of event) {
|
||||||
let acc: 'foreground' | 'background' | 'cursor' | 'ansi';
|
let acc: 'foreground' | 'background' | 'cursor' | 'ansi';
|
||||||
let ident = '';
|
let ident: string;
|
||||||
switch (req.index) {
|
switch (req.index) {
|
||||||
case SpecialColorIndex.FOREGROUND: // OSC 10 | 110
|
case SpecialColorIndex.FOREGROUND: // OSC 10 | 110
|
||||||
acc = 'foreground';
|
acc = 'foreground';
|
||||||
@@ -413,7 +414,16 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
this._register(addDisposableListener(this.textarea!, 'keyup', (ev: KeyboardEvent) => this._keyUp(ev), true));
|
this._register(addDisposableListener(this.textarea!, 'keyup', (ev: KeyboardEvent) => this._keyUp(ev), true));
|
||||||
this._register(addDisposableListener(this.textarea!, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true));
|
this._register(addDisposableListener(this.textarea!, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true));
|
||||||
this._register(addDisposableListener(this.textarea!, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true));
|
this._register(addDisposableListener(this.textarea!, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true));
|
||||||
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => this._compositionHelper!.compositionstart()));
|
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => {
|
||||||
|
// Ensure the textarea is synced to the latest cursor location before composition begins. This
|
||||||
|
// is to workaround a problem where highly dynamic TUIs like agentic CLIs reprint agressively
|
||||||
|
// would cause the IME to appear in the wrong position. The theory is that when the IME is
|
||||||
|
// triggered during a partial render the textarea position becomes locked and will not move
|
||||||
|
// until it is hidden and a custom move occurs.
|
||||||
|
this._syncTextArea();
|
||||||
|
this._compositionHelper!.compositionstart();
|
||||||
|
this._compositionHelper!.updateCompositionElements();
|
||||||
|
}));
|
||||||
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
|
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
|
||||||
this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
|
this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
|
||||||
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
|
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
|
||||||
@@ -495,7 +505,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
this.textarea,
|
this.textarea,
|
||||||
parent.ownerDocument.defaultView ?? window,
|
parent.ownerDocument.defaultView ?? window,
|
||||||
// Force unsafe null in node.js environment for tests
|
// Force unsafe null in node.js environment for tests
|
||||||
this._document ?? (typeof window !== 'undefined') ? window.document : null as any
|
this._document ?? ((typeof window !== 'undefined') ? window.document : null as any)
|
||||||
));
|
));
|
||||||
this._instantiationService.setService(ICoreBrowserService, this._coreBrowserService);
|
this._instantiationService.setService(ICoreBrowserService, this._coreBrowserService);
|
||||||
|
|
||||||
@@ -543,8 +553,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
|
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
|
||||||
this._helperContainer.appendChild(this._compositionView);
|
this._helperContainer.appendChild(this._compositionView);
|
||||||
|
|
||||||
this._mouseService = this._instantiationService.createInstance(MouseService);
|
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
|
||||||
this._instantiationService.setService(IMouseService, this._mouseService);
|
this._instantiationService.setService(IMouseCoordsService, this._mouseCoordsService);
|
||||||
|
|
||||||
const linkifier = this._linkifier.value = this._register(this._instantiationService.createInstance(Linkifier, this.screenElement));
|
const linkifier = this._linkifier.value = this._register(this._instantiationService.createInstance(Linkifier, this.screenElement));
|
||||||
|
|
||||||
@@ -553,8 +563,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this._onWillOpen.fire(this.element);
|
this._onWillOpen.fire(this.element);
|
||||||
|
} catch (e) {
|
||||||
|
this._logService.error('onWillOpen handler threw an exception', e);
|
||||||
}
|
}
|
||||||
catch { /* fails to load addon for some reason */ }
|
|
||||||
if (!this._renderService.hasRenderer()) {
|
if (!this._renderService.hasRenderer()) {
|
||||||
this._renderService.setRenderer(this._createRenderer());
|
this._renderService.setRenderer(this._createRenderer());
|
||||||
}
|
}
|
||||||
@@ -563,7 +574,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
this._renderService!.handleCursorMove();
|
this._renderService!.handleCursorMove();
|
||||||
this._syncTextArea();
|
this._syncTextArea();
|
||||||
}));
|
}));
|
||||||
this._register(this.onResize(() => this._renderService!.handleResize(this.cols, this.rows)));
|
this._register(this.onResize(() => {
|
||||||
|
this._renderService!.handleResize(this.cols, this.rows);
|
||||||
|
this._syncTextArea();
|
||||||
|
}));
|
||||||
this._register(this.onBlur(() => this._renderService!.handleBlur()));
|
this._register(this.onBlur(() => this._renderService!.handleBlur()));
|
||||||
this._register(this.onFocus(() => this._renderService!.handleFocus()));
|
this._register(this.onFocus(() => this._renderService!.handleFocus()));
|
||||||
|
|
||||||
@@ -579,6 +593,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
linkifier
|
linkifier
|
||||||
));
|
));
|
||||||
this._instantiationService.setService(ISelectionService, this._selectionService);
|
this._instantiationService.setService(ISelectionService, this._selectionService);
|
||||||
|
this._mouseService = this._instantiationService.createInstance(MouseService);
|
||||||
|
this._instantiationService.setService(IMouseService, this._mouseService);
|
||||||
this._register(this._selectionService.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent)));
|
this._register(this._selectionService.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent)));
|
||||||
this._register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire()));
|
this._register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire()));
|
||||||
this._register(this._selectionService.onRequestRedraw(e => this._renderService!.handleSelectionChanged(e.start, e.end, e.columnSelectMode)));
|
this._register(this._selectionService.onRequestRedraw(e => this._renderService!.handleSelectionChanged(e.start, e.end, e.columnSelectMode)));
|
||||||
@@ -602,11 +618,12 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e)));
|
this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e)));
|
||||||
|
|
||||||
// apply mouse event classes set by escape codes before terminal was attached
|
// apply mouse event classes set by escape codes before terminal was attached
|
||||||
if (this.coreMouseService.areMouseEventsActive) {
|
if (this.mouseStateService.areMouseEventsActive && !this.options.mouseEventsRequireAlt) {
|
||||||
this._selectionService.disable();
|
this._selectionService.disable();
|
||||||
this.element.classList.add('enable-mouse-events');
|
this.element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS);
|
||||||
} else {
|
} else {
|
||||||
this._selectionService.enable();
|
this._selectionService.enable();
|
||||||
|
this.element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.screenReaderMode) {
|
if (this.options.screenReaderMode) {
|
||||||
@@ -638,285 +655,18 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
|
|
||||||
// Listen for mouse events and translate
|
// Listen for mouse events and translate
|
||||||
// them into terminal mouse protocols.
|
// them into terminal mouse protocols.
|
||||||
this.bindMouse();
|
this._mouseService.bindMouse({
|
||||||
|
element: this.element!,
|
||||||
|
screenElement: this.screenElement!,
|
||||||
|
document: this._document!,
|
||||||
|
handleTouchScroll: amount => this._viewport?.handleTouchScroll(amount)
|
||||||
|
}, disposable => this._register(disposable), () => this.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createRenderer(): IRenderer {
|
private _createRenderer(): IRenderer {
|
||||||
return this._instantiationService.createInstance(DomRenderer, this, this._document!, this.element!, this.screenElement!, this._viewportElement!, this._helperContainer!, this.linkifier!);
|
return this._instantiationService.createInstance(DomRenderer, this, this._document!, this.element!, this.screenElement!, this._viewportElement!, this._helperContainer!, this.linkifier!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bind certain mouse events to the terminal.
|
|
||||||
* By default only 3 button + wheel up/down is ativated. For higher buttons
|
|
||||||
* no mouse report will be created. Typically the standard actions will be active.
|
|
||||||
*
|
|
||||||
* There are several reasons not to enable support for higher buttons/wheel:
|
|
||||||
* - Button 4 and 5 are typically used for history back and forward navigation,
|
|
||||||
* there is no straight forward way to supress/intercept those standard actions.
|
|
||||||
* - Support for higher buttons does not work in some platform/browser combinations.
|
|
||||||
* - Left/right wheel was not tested.
|
|
||||||
* - Emulators vary in mouse button support, typically only 3 buttons and
|
|
||||||
* wheel up/down work reliable.
|
|
||||||
*
|
|
||||||
* TODO: Move mouse event code into its own file.
|
|
||||||
*/
|
|
||||||
public bindMouse(): void {
|
|
||||||
const self = this;
|
|
||||||
const el = this.element!;
|
|
||||||
|
|
||||||
// send event to CoreMouseService
|
|
||||||
function sendEvent(ev: MouseEvent | WheelEvent): boolean {
|
|
||||||
// Get mouse coordinates
|
|
||||||
const pos = self._mouseService?.getMouseReportCoords(ev, self.screenElement!);
|
|
||||||
if (!pos) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let but: CoreMouseButton;
|
|
||||||
let action: CoreMouseAction | undefined;
|
|
||||||
switch ((ev as any).overrideType || ev.type) {
|
|
||||||
case 'mousemove':
|
|
||||||
action = CoreMouseAction.MOVE;
|
|
||||||
if (ev.buttons === undefined) {
|
|
||||||
// buttons is not supported on macOS, try to get a value from button instead
|
|
||||||
but = CoreMouseButton.NONE;
|
|
||||||
if (ev.button !== undefined) {
|
|
||||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// according to MDN buttons only reports up to button 5 (AUX2)
|
|
||||||
but = ev.buttons & 1 ? CoreMouseButton.LEFT :
|
|
||||||
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
|
|
||||||
ev.buttons & 2 ? CoreMouseButton.RIGHT :
|
|
||||||
CoreMouseButton.NONE; // fallback to NONE
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'mouseup':
|
|
||||||
action = CoreMouseAction.UP;
|
|
||||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
|
||||||
break;
|
|
||||||
case 'mousedown':
|
|
||||||
action = CoreMouseAction.DOWN;
|
|
||||||
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
|
|
||||||
break;
|
|
||||||
case 'wheel':
|
|
||||||
if (self._customWheelEventHandler && self._customWheelEventHandler(ev as WheelEvent) === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const deltaY = (ev as WheelEvent).deltaY;
|
|
||||||
if (deltaY === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const lines = self.coreMouseService.consumeWheelEvent(
|
|
||||||
ev as WheelEvent,
|
|
||||||
self._renderService?.dimensions?.device?.cell?.height,
|
|
||||||
self._coreBrowserService?.dpr
|
|
||||||
);
|
|
||||||
if (lines === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN;
|
|
||||||
but = CoreMouseButton.WHEEL;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// dont handle other event types by accident
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// exit if we cannot determine valid button/action values
|
|
||||||
// do nothing for higher buttons than wheel
|
|
||||||
if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.coreMouseService.triggerMouseEvent({
|
|
||||||
col: pos.col,
|
|
||||||
row: pos.row,
|
|
||||||
x: pos.x,
|
|
||||||
y: pos.y,
|
|
||||||
button: but,
|
|
||||||
action,
|
|
||||||
ctrl: ev.ctrlKey,
|
|
||||||
alt: ev.altKey,
|
|
||||||
shift: ev.shiftKey
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event listener state handling.
|
|
||||||
* We listen to the onProtocolChange event of CoreMouseService and put
|
|
||||||
* requested listeners in `requestedEvents`. With this the listeners
|
|
||||||
* have all bits to do the event listener juggling.
|
|
||||||
* Note: 'mousedown' currently is "always on" and not managed
|
|
||||||
* by onProtocolChange.
|
|
||||||
*/
|
|
||||||
const requestedEvents: { [key: string]: ((ev: MouseEvent | WheelEvent) => void) | null } = {
|
|
||||||
mouseup: null,
|
|
||||||
wheel: null,
|
|
||||||
mousedrag: null,
|
|
||||||
mousemove: null
|
|
||||||
};
|
|
||||||
const eventListeners: { [key: string]: (ev: any) => void | boolean } = {
|
|
||||||
mouseup: (ev: MouseEvent) => {
|
|
||||||
sendEvent(ev);
|
|
||||||
if (!ev.buttons) {
|
|
||||||
// if no other button is held remove global handlers
|
|
||||||
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
|
|
||||||
if (requestedEvents.mousedrag) {
|
|
||||||
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wheel: (ev: WheelEvent) => {
|
|
||||||
sendEvent(ev);
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
mousedrag: (ev: MouseEvent) => {
|
|
||||||
// deal only with move while a button is held
|
|
||||||
if (ev.buttons) {
|
|
||||||
sendEvent(ev);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mousemove: (ev: MouseEvent) => {
|
|
||||||
// deal only with move without any button
|
|
||||||
if (!ev.buttons) {
|
|
||||||
sendEvent(ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this._register(this.coreMouseService.onProtocolChange(events => {
|
|
||||||
// apply global changes on events
|
|
||||||
if (events) {
|
|
||||||
if (this.optionsService.rawOptions.logLevel === 'debug') {
|
|
||||||
this._logService.debug('Binding to mouse events:', this.coreMouseService.explainEvents(events));
|
|
||||||
}
|
|
||||||
this.element!.classList.add('enable-mouse-events');
|
|
||||||
this._selectionService!.disable();
|
|
||||||
} else {
|
|
||||||
this._logService.debug('Unbinding from mouse events.');
|
|
||||||
this.element!.classList.remove('enable-mouse-events');
|
|
||||||
this._selectionService!.enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
// add/remove handlers from requestedEvents
|
|
||||||
|
|
||||||
if (!(events & CoreMouseEventType.MOVE)) {
|
|
||||||
el.removeEventListener('mousemove', requestedEvents.mousemove!);
|
|
||||||
requestedEvents.mousemove = null;
|
|
||||||
} else if (!requestedEvents.mousemove) {
|
|
||||||
el.addEventListener('mousemove', eventListeners.mousemove);
|
|
||||||
requestedEvents.mousemove = eventListeners.mousemove;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(events & CoreMouseEventType.WHEEL)) {
|
|
||||||
el.removeEventListener('wheel', requestedEvents.wheel!);
|
|
||||||
requestedEvents.wheel = null;
|
|
||||||
} else if (!requestedEvents.wheel) {
|
|
||||||
el.addEventListener('wheel', eventListeners.wheel, { passive: false });
|
|
||||||
requestedEvents.wheel = eventListeners.wheel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(events & CoreMouseEventType.UP)) {
|
|
||||||
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
|
|
||||||
requestedEvents.mouseup = null;
|
|
||||||
} else {
|
|
||||||
requestedEvents.mouseup ??= eventListeners.mouseup;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(events & CoreMouseEventType.DRAG)) {
|
|
||||||
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag!);
|
|
||||||
requestedEvents.mousedrag = null;
|
|
||||||
} else {
|
|
||||||
requestedEvents.mousedrag ??= eventListeners.mousedrag;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
// force initial onProtocolChange so we dont miss early mouse requests
|
|
||||||
this.coreMouseService.activeProtocol = this.coreMouseService.activeProtocol;
|
|
||||||
|
|
||||||
// Ensure document-level listeners are removed on dispose
|
|
||||||
this._register(toDisposable(() => {
|
|
||||||
if (requestedEvents.mouseup) {
|
|
||||||
this._document!.removeEventListener('mouseup', requestedEvents.mouseup);
|
|
||||||
}
|
|
||||||
if (requestedEvents.mousedrag) {
|
|
||||||
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Always on" event listeners.
|
|
||||||
*/
|
|
||||||
this._register(addDisposableListener(el, 'mousedown', (ev: MouseEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.focus();
|
|
||||||
|
|
||||||
// Don't send the mouse button to the pty if mouse events are disabled or
|
|
||||||
// if the selection manager is having selection forced (ie. a modifier is
|
|
||||||
// held).
|
|
||||||
if (!this.coreMouseService.areMouseEventsActive || this._selectionService!.shouldForceSelection(ev)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendEvent(ev);
|
|
||||||
|
|
||||||
// Register additional global handlers which should keep reporting outside
|
|
||||||
// of the terminal element.
|
|
||||||
// Note: Other emulators also do this for 'mousedown' while a button
|
|
||||||
// is held, we currently limit 'mousedown' to the terminal only.
|
|
||||||
if (requestedEvents.mouseup) {
|
|
||||||
this._document!.addEventListener('mouseup', requestedEvents.mouseup);
|
|
||||||
}
|
|
||||||
if (requestedEvents.mousedrag) {
|
|
||||||
this._document!.addEventListener('mousemove', requestedEvents.mousedrag);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
this._register(addDisposableListener(el, 'wheel', (ev: WheelEvent) => {
|
|
||||||
// do nothing, if app side handles wheel itself
|
|
||||||
if (requestedEvents.wheel) return;
|
|
||||||
|
|
||||||
if (this._customWheelEventHandler && this._customWheelEventHandler(ev) === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.buffer.hasScrollback) {
|
|
||||||
// Convert wheel events into up/down events when the buffer does not have scrollback, this
|
|
||||||
// enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse
|
|
||||||
// events are not enabled.
|
|
||||||
// This used implementation used get the actual lines/partial lines scrolled from the
|
|
||||||
// viewport but since moving to the new viewport implementation has been simplified to
|
|
||||||
// simply send a single up or down sequence.
|
|
||||||
|
|
||||||
// Do nothing if there's no vertical scroll
|
|
||||||
const deltaY = (ev as WheelEvent).deltaY;
|
|
||||||
if (deltaY === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = self.coreMouseService.consumeWheelEvent(
|
|
||||||
ev as WheelEvent,
|
|
||||||
self._renderService?.dimensions?.device?.cell?.height,
|
|
||||||
self._coreBrowserService?.dpr
|
|
||||||
);
|
|
||||||
if (lines === 0) {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct and send sequences
|
|
||||||
const sequence = C0.ESC + (this.coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
|
|
||||||
this.coreService.triggerDataEvent(sequence, true);
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, { passive: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells the renderer to refresh terminal content between two rows (inclusive) at the next
|
* Tells the renderer to refresh terminal content between two rows (inclusive) at the next
|
||||||
* opportunity.
|
* opportunity.
|
||||||
@@ -990,7 +740,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public attachCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler): void {
|
public attachCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler): void {
|
||||||
this._customWheelEventHandler = customWheelEventHandler;
|
this.mouseStateService.setCustomWheelEventHandler(customWheelEventHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
|
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
|
||||||
@@ -1360,6 +1110,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
|||||||
|
|
||||||
this._setup();
|
this._setup();
|
||||||
super.reset();
|
super.reset();
|
||||||
|
this._mouseService?.reset();
|
||||||
this._selectionService?.reset();
|
this._selectionService?.reset();
|
||||||
this._decorationService.reset();
|
this._decorationService.reset();
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
* Minimal DOM helpers for xterm.js browser code.
|
* Minimal DOM helpers for xterm.js browser code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IntervalTimer } from 'common/Async';
|
import { IntervalTimer } from '../common/Async';
|
||||||
import { IDisposable } from 'common/Lifecycle';
|
import { IDisposable } from '../common/Lifecycle';
|
||||||
|
|
||||||
export function getWindow(e: Node | UIEvent | undefined | null): Window {
|
export function getWindow(e: Node | UIEvent | undefined | null): Window {
|
||||||
const candidateNode = e as Node | undefined | null;
|
const candidateNode = e as Node | undefined | null;
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { IBufferService } from 'common/services/Services';
|
import { IBufferService } from '../common/services/Services';
|
||||||
import { Linkifier } from './Linkifier';
|
import { Linkifier } from './Linkifier';
|
||||||
import { MockBufferService } from 'common/TestUtils.test';
|
import { MockBufferService } from '../common/TestUtils.test';
|
||||||
import { ILink } from 'browser/Types';
|
import { ILink } from './Types';
|
||||||
import { LinkProviderService } from 'browser/services/LinkProviderService';
|
import { LinkProviderService } from './services/LinkProviderService';
|
||||||
import jsdom = require('jsdom');
|
import jsdom = require('jsdom');
|
||||||
|
|
||||||
class TestLinkifier2 extends Linkifier {
|
class TestLinkifier2 extends Linkifier {
|
||||||
@@ -43,6 +43,20 @@ describe('Linkifier2', () => {
|
|||||||
},
|
},
|
||||||
activate: () => { }
|
activate: () => { }
|
||||||
};
|
};
|
||||||
|
const multiLineLink: ILink = {
|
||||||
|
text: 'foo',
|
||||||
|
range: {
|
||||||
|
start: {
|
||||||
|
x: 2,
|
||||||
|
y: 1
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
x: 4,
|
||||||
|
y: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
activate: () => { }
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const dom = new jsdom.JSDOM();
|
const dom = new jsdom.JSDOM();
|
||||||
@@ -86,4 +100,30 @@ describe('Linkifier2', () => {
|
|||||||
linkifier.linkLeave({ classList: { add: () => { } } } as any, link, {} as any);
|
linkifier.linkLeave({ classList: { add: () => { } } } as any, link, {} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('onShowLinkUnderline event range is correct for wrapped links', done => {
|
||||||
|
linkifier.onShowLinkUnderline(e => {
|
||||||
|
assert.equal(multiLineLink.range.start.x - 1, e.x1);
|
||||||
|
assert.equal(multiLineLink.range.start.y - 1, e.y1);
|
||||||
|
assert.equal(multiLineLink.range.end.x, e.x2);
|
||||||
|
assert.equal(multiLineLink.range.end.y - 1, e.y2);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
linkifier.linkHover({ classList: { add: () => { } } } as any, multiLineLink, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onHideLinkUnderline event range is correct for wrapped links', done => {
|
||||||
|
linkifier.onHideLinkUnderline(e => {
|
||||||
|
assert.equal(multiLineLink.range.start.x - 1, e.x1);
|
||||||
|
assert.equal(multiLineLink.range.start.y - 1, e.y1);
|
||||||
|
assert.equal(multiLineLink.range.end.x, e.x2);
|
||||||
|
assert.equal(multiLineLink.range.end.y - 1, e.y2);
|
||||||
|
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
linkifier.linkLeave({ classList: { add: () => { } } } as any, multiLineLink, {} as any);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-14
@@ -3,13 +3,13 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IBufferCellPosition, ILink, ILinkDecorations, ILinkWithState, ILinkifier2, ILinkifierEvent } from 'browser/Types';
|
import { IBufferCellPosition, ILink, ILinkDecorations, ILinkWithState, ILinkifier2, ILinkifierEvent } from './Types';
|
||||||
import { Disposable, dispose, toDisposable } from 'common/Lifecycle';
|
import { Disposable, dispose, toDisposable } from '../common/Lifecycle';
|
||||||
import { IDisposable } from 'common/Types';
|
import { IDisposable } from '../common/Types';
|
||||||
import { IBufferService } from 'common/services/Services';
|
import { IBufferService } from '../common/services/Services';
|
||||||
import { ILinkProviderService, IMouseService, IRenderService } from './services/Services';
|
import { ILinkProviderService, IMouseCoordsService, IRenderService } from './services/Services';
|
||||||
import { Emitter } from 'common/Event';
|
import { Emitter } from '../common/Event';
|
||||||
import { addDisposableListener } from 'browser/Dom';
|
import { addDisposableListener } from './Dom';
|
||||||
|
|
||||||
export class Linkifier extends Disposable implements ILinkifier2 {
|
export class Linkifier extends Disposable implements ILinkifier2 {
|
||||||
public get currentLink(): ILinkWithState | undefined { return this._currentLink; }
|
public get currentLink(): ILinkWithState | undefined { return this._currentLink; }
|
||||||
@@ -30,7 +30,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _element: HTMLElement,
|
private readonly _element: HTMLElement,
|
||||||
@IMouseService private readonly _mouseService: IMouseService,
|
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
|
||||||
@IRenderService private readonly _renderService: IRenderService,
|
@IRenderService private readonly _renderService: IRenderService,
|
||||||
@IBufferService private readonly _bufferService: IBufferService,
|
@IBufferService private readonly _bufferService: IBufferService,
|
||||||
@ILinkProviderService private readonly _linkProviderService: ILinkProviderService
|
@ILinkProviderService private readonly _linkProviderService: ILinkProviderService
|
||||||
@@ -60,7 +60,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
|||||||
private _handleMouseMove(event: MouseEvent): void {
|
private _handleMouseMove(event: MouseEvent): void {
|
||||||
this._lastMouseEvent = event;
|
this._lastMouseEvent = event;
|
||||||
|
|
||||||
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
|
const position = this._positionFromMouseEvent(event, this._element);
|
||||||
if (!position) {
|
if (!position) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
|
const position = this._positionFromMouseEvent(event, this._element);
|
||||||
if (!position) {
|
if (!position) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -251,7 +251,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService);
|
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
|
||||||
|
|
||||||
if (!position) {
|
if (!position) {
|
||||||
return;
|
return;
|
||||||
@@ -312,7 +312,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
|||||||
this._clearCurrentLink(start, end);
|
this._clearCurrentLink(start, end);
|
||||||
if (this._lastMouseEvent) {
|
if (this._lastMouseEvent) {
|
||||||
// re-eval previously active link after changes
|
// re-eval previously active link after changes
|
||||||
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService!);
|
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
|
||||||
if (position) {
|
if (position) {
|
||||||
this._askForLink(position, false);
|
this._askForLink(position, false);
|
||||||
}
|
}
|
||||||
@@ -378,8 +378,8 @@ export class Linkifier extends Disposable implements ILinkifier2 {
|
|||||||
* Get the buffer position from a mouse event
|
* Get the buffer position from a mouse event
|
||||||
* @param event
|
* @param event
|
||||||
*/
|
*/
|
||||||
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement, mouseService: IMouseService): IBufferCellPosition | undefined {
|
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement): IBufferCellPosition | undefined {
|
||||||
const coords = mouseService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
|
const coords = this._mouseCoordsService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
|
||||||
if (!coords) {
|
if (!coords) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { OscLinkProvider } from './OscLinkProvider';
|
||||||
|
import { ILink } from './Types';
|
||||||
|
import { createCellData, MockBufferService, MockOptionsService } from '../common/TestUtils.test';
|
||||||
|
import { IBufferService, IOscLinkService } from '../common/services/Services';
|
||||||
|
import { IOscLinkData } from '../common/Types';
|
||||||
|
import { IBufferLine } from '../common/buffer/Types';
|
||||||
|
|
||||||
|
class TestOscLinkService implements IOscLinkService {
|
||||||
|
public serviceBrand: any;
|
||||||
|
public registerLink(_linkData: IOscLinkData): number { return 0; }
|
||||||
|
public addLineToLink(_linkId: number, _y: number): void { }
|
||||||
|
public getLinkData(linkId: number): IOscLinkData | undefined {
|
||||||
|
return { uri: `https://example.com/${linkId}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(line: IBufferLine | undefined, x: number, text: string): void {
|
||||||
|
if (!line) {
|
||||||
|
throw new Error('Missing buffer line');
|
||||||
|
}
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
line.setCell(x + i, createCellData(0, text[i], 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUrl(line: IBufferLine | undefined, x: number, text: string, linkId: number): void {
|
||||||
|
if (!line) {
|
||||||
|
throw new Error('Missing buffer line');
|
||||||
|
}
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const cell = createCellData(0, text[i], 1);
|
||||||
|
cell.extended.urlId = linkId;
|
||||||
|
cell.updateExtended();
|
||||||
|
line.setCell(x + i, cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinks(provider: OscLinkProvider, y: number): Promise<ILink[]> {
|
||||||
|
return new Promise(resolve => provider.provideLinks(y, links => resolve(links ?? [])));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OscLinkProvider', () => {
|
||||||
|
let bufferService: IBufferService;
|
||||||
|
let provider: OscLinkProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const optionsService = new MockOptionsService();
|
||||||
|
bufferService = new MockBufferService(5, 5, optionsService);
|
||||||
|
provider = new OscLinkProvider(bufferService, optionsService, new TestOscLinkService());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a wrapped link range backward to the previous line', async () => {
|
||||||
|
const line1 = bufferService.buffer.lines.get(0);
|
||||||
|
const line2 = bufferService.buffer.lines.get(1);
|
||||||
|
setText(line1, 0, 'aa');
|
||||||
|
setUrl(line1, 2, 'bbb', 1);
|
||||||
|
setUrl(line2, 0, 'cccc', 1);
|
||||||
|
setText(line2, 4, 'x');
|
||||||
|
line2!.isWrapped = true;
|
||||||
|
|
||||||
|
const links = await getLinks(provider, 2);
|
||||||
|
assert.lengthOf(links, 1);
|
||||||
|
assert.deepEqual(links[0].range, {
|
||||||
|
start: { x: 3, y: 1 },
|
||||||
|
end: { x: 4, y: 2 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands a wrapped link range forward when a link ends at line boundary', async () => {
|
||||||
|
const line1 = bufferService.buffer.lines.get(0);
|
||||||
|
const line2 = bufferService.buffer.lines.get(1);
|
||||||
|
setUrl(line1, 0, 'aaaaa', 1);
|
||||||
|
setUrl(line2, 0, 'bb', 1);
|
||||||
|
setText(line2, 2, 'ccc');
|
||||||
|
line2!.isWrapped = true;
|
||||||
|
|
||||||
|
const links = await getLinks(provider, 1);
|
||||||
|
assert.lengthOf(links, 1);
|
||||||
|
assert.deepEqual(links[0].range, {
|
||||||
|
start: { x: 1, y: 1 },
|
||||||
|
end: { x: 2, y: 2 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not merge wrapped links with different url ids', async () => {
|
||||||
|
const line1 = bufferService.buffer.lines.get(0);
|
||||||
|
const line2 = bufferService.buffer.lines.get(1);
|
||||||
|
setUrl(line1, 0, 'aaaaa', 1);
|
||||||
|
setUrl(line2, 0, 'bbb', 2);
|
||||||
|
setText(line2, 3, 'cc');
|
||||||
|
line2!.isWrapped = true;
|
||||||
|
|
||||||
|
const links = await getLinks(provider, 1);
|
||||||
|
assert.lengthOf(links, 1);
|
||||||
|
assert.deepEqual(links[0].range, {
|
||||||
|
start: { x: 1, y: 1 },
|
||||||
|
end: { x: 5, y: 1 }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IBufferRange, ILink } from 'browser/Types';
|
import { IBufferRange, ILink } from './Types';
|
||||||
import { ILinkProvider } from 'browser/services/Services';
|
import { ILinkProvider } from './services/Services';
|
||||||
import { CellData } from 'common/buffer/CellData';
|
import { CellData } from '../common/buffer/CellData';
|
||||||
import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services';
|
import { IBufferLine } from '../common/buffer/Types';
|
||||||
|
import { IBufferService, IOptionsService, IOscLinkService } from '../common/services/Services';
|
||||||
|
|
||||||
export class OscLinkProvider implements ILinkProvider {
|
export class OscLinkProvider implements ILinkProvider {
|
||||||
private readonly _workCell = new CellData();
|
private readonly _workCell = new CellData();
|
||||||
@@ -57,19 +58,8 @@ export class OscLinkProvider implements ILinkProvider {
|
|||||||
if (finishLink || (currentStart !== -1 && x === lineLength - 1)) {
|
if (finishLink || (currentStart !== -1 && x === lineLength - 1)) {
|
||||||
const text = this._oscLinkService.getLinkData(currentLinkId)?.uri;
|
const text = this._oscLinkService.getLinkData(currentLinkId)?.uri;
|
||||||
if (text) {
|
if (text) {
|
||||||
// These ranges are 1-based
|
const endX = x + (!finishLink && x === lineLength - 1 ? 1 : 0);
|
||||||
const range: IBufferRange = {
|
const range = this._getRangeWithLineWrap(y, currentStart, endX, currentLinkId);
|
||||||
start: {
|
|
||||||
x: currentStart + 1,
|
|
||||||
y
|
|
||||||
},
|
|
||||||
end: {
|
|
||||||
// Offset end x if it's a link that ends on the last cell in the line
|
|
||||||
x: x + (!finishLink && x === lineLength - 1 ? 1 : 0),
|
|
||||||
y
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let ignoreLink = false;
|
let ignoreLink = false;
|
||||||
if (!linkHandler?.allowNonHttpProtocols) {
|
if (!linkHandler?.allowNonHttpProtocols) {
|
||||||
try {
|
try {
|
||||||
@@ -111,6 +101,82 @@ export class OscLinkProvider implements ILinkProvider {
|
|||||||
// id
|
// id
|
||||||
callback(result);
|
callback(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a single-line OSC 8 range to a contiguous wrapped range for the same link id.
|
||||||
|
*/
|
||||||
|
private _getRangeWithLineWrap(y: number, startX: number, endX: number, linkId: number): IBufferRange {
|
||||||
|
let startY = y;
|
||||||
|
let finalStartX = startX;
|
||||||
|
let endY = y;
|
||||||
|
let finalEndX = endX;
|
||||||
|
|
||||||
|
// Expand upward only when this segment starts at column 0 and the current line is wrapped.
|
||||||
|
while (finalStartX === 0) {
|
||||||
|
const currentLine = this._bufferService.buffer.lines.get(startY - 1);
|
||||||
|
if (!currentLine?.isWrapped) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const previousLine = this._bufferService.buffer.lines.get(startY - 2);
|
||||||
|
if (!previousLine) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const previousLineLength = previousLine.getTrimmedLength();
|
||||||
|
if (previousLineLength === 0 || !this._hasUrlId(previousLine, previousLineLength - 1, linkId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let previousStartX = previousLineLength - 1;
|
||||||
|
while (previousStartX > 0 && this._hasUrlId(previousLine, previousStartX - 1, linkId)) {
|
||||||
|
previousStartX--;
|
||||||
|
}
|
||||||
|
startY--;
|
||||||
|
finalStartX = previousStartX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand downward only when this segment reaches trimmed EOL and the next line is wrapped.
|
||||||
|
while (true) {
|
||||||
|
const currentLine = this._bufferService.buffer.lines.get(endY - 1);
|
||||||
|
if (!currentLine) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const currentLineLength = currentLine.getTrimmedLength();
|
||||||
|
if (finalEndX !== currentLineLength) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const nextLine = this._bufferService.buffer.lines.get(endY);
|
||||||
|
if (!nextLine?.isWrapped) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const nextLineLength = nextLine.getTrimmedLength();
|
||||||
|
if (nextLineLength === 0 || !this._hasUrlId(nextLine, 0, linkId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let nextEndX = 1;
|
||||||
|
while (nextEndX < nextLineLength && this._hasUrlId(nextLine, nextEndX, linkId)) {
|
||||||
|
nextEndX++;
|
||||||
|
}
|
||||||
|
endY++;
|
||||||
|
finalEndX = nextEndX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IBufferRange uses 1-based coordinates.
|
||||||
|
return {
|
||||||
|
start: {
|
||||||
|
x: finalStartX + 1,
|
||||||
|
y: startY
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
x: finalEndX,
|
||||||
|
y: endY
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _hasUrlId(line: IBufferLine, x: number, linkId: number): boolean {
|
||||||
|
const cell = this._workCell;
|
||||||
|
line.loadCell(x, cell);
|
||||||
|
return !!cell.hasExtendedAttrs() && cell.extended.urlId === linkId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultActivate(e: MouseEvent, uri: string): void {
|
function defaultActivate(e: MouseEvent, uri: string): void {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IRenderDebouncerWithCallback } from 'browser/Types';
|
import { IRenderDebouncerWithCallback } from './Types';
|
||||||
import { ICoreBrowserService } from 'browser/services/Services';
|
import { ICoreBrowserService } from './services/Services';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debounces calls to render terminal rows using animation frames.
|
* Debounces calls to render terminal rows using animation frames.
|
||||||
@@ -23,7 +23,7 @@ export class RenderDebouncer implements IRenderDebouncerWithCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
if (this._animationFrame) {
|
if (this._animationFrame !== undefined) {
|
||||||
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
|
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
|
||||||
this._animationFrame = undefined;
|
this._animationFrame = undefined;
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ export class RenderDebouncer implements IRenderDebouncerWithCallback {
|
|||||||
|
|
||||||
public addRefreshCallback(callback: FrameRequestCallback): number {
|
public addRefreshCallback(callback: FrameRequestCallback): number {
|
||||||
this._refreshCallbacks.push(callback);
|
this._refreshCallbacks.push(callback);
|
||||||
if (!this._animationFrame) {
|
if (this._animationFrame === undefined) {
|
||||||
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => this._innerRefresh());
|
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => this._innerRefresh());
|
||||||
}
|
}
|
||||||
return this._animationFrame;
|
return this._animationFrame;
|
||||||
@@ -46,7 +46,7 @@ export class RenderDebouncer implements IRenderDebouncerWithCallback {
|
|||||||
this._rowStart = this._rowStart !== undefined ? Math.min(this._rowStart, rowStart) : rowStart;
|
this._rowStart = this._rowStart !== undefined ? Math.min(this._rowStart, rowStart) : rowStart;
|
||||||
this._rowEnd = this._rowEnd !== undefined ? Math.max(this._rowEnd, rowEnd) : rowEnd;
|
this._rowEnd = this._rowEnd !== undefined ? Math.max(this._rowEnd, rowEnd) : rowEnd;
|
||||||
|
|
||||||
if (this._animationFrame) {
|
if (this._animationFrame !== undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MockCompositionHelper, MockRenderer, MockViewport, TestTerminal } from 'browser/TestUtils.test';
|
import { MockCompositionHelper, MockRenderer, MockViewport, TestTerminal } from './TestUtils.test';
|
||||||
import type { IBrowser } from 'browser/Types';
|
import type { IBrowser } from './Types';
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
import { DEFAULT_ATTR_DATA } from '../common/buffer/BufferLine';
|
||||||
import { CellData } from 'common/buffer/CellData';
|
import { CellData } from '../common/buffer/CellData';
|
||||||
import { MockUnicodeService } from 'common/TestUtils.test';
|
import { MockUnicodeService, createCellData } from '../common/TestUtils.test';
|
||||||
import { IMarker } from 'common/Types';
|
import { IMarker } from '../common/buffer/Types';
|
||||||
|
|
||||||
const INIT_COLS = 80;
|
const INIT_COLS = 80;
|
||||||
const INIT_ROWS = 24;
|
const INIT_ROWS = 24;
|
||||||
@@ -440,8 +440,8 @@ describe('Terminal', () => {
|
|||||||
describe('scroll() function', () => {
|
describe('scroll() function', () => {
|
||||||
describe('when scrollback > 0', () => {
|
describe('when scrollback > 0', () => {
|
||||||
it('should create a new line and scroll', () => {
|
it('should create a new line and scroll', () => {
|
||||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||||
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'b', 0));
|
||||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||||
assert.equal(term.buffer.lines.length, INIT_ROWS + 1);
|
assert.equal(term.buffer.lines.length, INIT_ROWS + 1);
|
||||||
@@ -451,9 +451,9 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should properly scroll inside a scroll region (scrollTop set)', () => {
|
it('should properly scroll inside a scroll region (scrollTop set)', () => {
|
||||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||||
term.buffer.scrollTop = 1;
|
term.buffer.scrollTop = 1;
|
||||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||||
@@ -463,11 +463,11 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
|
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
|
||||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||||
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
|
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
|
||||||
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
|
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
|
||||||
term.buffer.y = 3;
|
term.buffer.y = 3;
|
||||||
term.buffer.scrollBottom = 3;
|
term.buffer.scrollBottom = 3;
|
||||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||||
@@ -481,11 +481,11 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
|
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
|
||||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||||
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
|
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
|
||||||
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
|
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
|
||||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||||
term.buffer.scrollTop = 1;
|
term.buffer.scrollTop = 1;
|
||||||
term.buffer.scrollBottom = 3;
|
term.buffer.scrollBottom = 3;
|
||||||
@@ -506,9 +506,9 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should create a new line and shift everything up', () => {
|
it('should create a new line and shift everything up', () => {
|
||||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||||
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'c', 0));
|
||||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||||
assert.equal(term.buffer.lines.length, INIT_ROWS);
|
assert.equal(term.buffer.lines.length, INIT_ROWS);
|
||||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||||
@@ -521,9 +521,9 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should properly scroll inside a scroll region (scrollTop set)', () => {
|
it('should properly scroll inside a scroll region (scrollTop set)', () => {
|
||||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||||
term.buffer.scrollTop = 1;
|
term.buffer.scrollTop = 1;
|
||||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||||
@@ -533,11 +533,11 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
|
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
|
||||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||||
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
|
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
|
||||||
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
|
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
|
||||||
term.buffer.y = 3;
|
term.buffer.y = 3;
|
||||||
term.buffer.scrollBottom = 3;
|
term.buffer.scrollBottom = 3;
|
||||||
term.scroll(DEFAULT_ATTR_DATA.clone());
|
term.scroll(DEFAULT_ATTR_DATA.clone());
|
||||||
@@ -550,11 +550,11 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
|
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
|
||||||
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
|
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
|
||||||
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
|
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
|
||||||
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
|
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
|
||||||
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
|
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
|
||||||
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
|
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
|
||||||
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
|
||||||
term.buffer.scrollTop = 1;
|
term.buffer.scrollTop = 1;
|
||||||
term.buffer.scrollBottom = 3;
|
term.buffer.scrollBottom = 3;
|
||||||
@@ -871,7 +871,7 @@ describe('Terminal', () => {
|
|||||||
assert.equal(cell.getWidth(), 1);
|
assert.equal(cell.getWidth(), 1);
|
||||||
});
|
});
|
||||||
it('multiple combined é', async () => {
|
it('multiple combined é', async () => {
|
||||||
await term.writeP(Array(100).join('e\u0301'));
|
await term.writeP('e\u0301'.repeat(99));
|
||||||
for (let i = 0; i < term.cols; ++i) {
|
for (let i = 0; i < term.cols; ++i) {
|
||||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||||
assert.equal(cell.getChars(), 'e\u0301');
|
assert.equal(cell.getChars(), 'e\u0301');
|
||||||
@@ -884,7 +884,7 @@ describe('Terminal', () => {
|
|||||||
assert.equal(cell.getWidth(), 1);
|
assert.equal(cell.getWidth(), 1);
|
||||||
});
|
});
|
||||||
it('multiple surrogate with combined', async () => {
|
it('multiple surrogate with combined', async () => {
|
||||||
await term.writeP(Array(100).join('\uD800\uDC00\u0301'));
|
await term.writeP('\uD800\uDC00\u0301'.repeat(99));
|
||||||
for (let i = 0; i < term.cols; ++i) {
|
for (let i = 0; i < term.cols; ++i) {
|
||||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||||
assert.equal(cell.getChars(), '\uD800\uDC00\u0301');
|
assert.equal(cell.getChars(), '\uD800\uDC00\u0301');
|
||||||
@@ -912,7 +912,7 @@ describe('Terminal', () => {
|
|||||||
assert.equal(term.buffer.x, 3);
|
assert.equal(term.buffer.x, 3);
|
||||||
});
|
});
|
||||||
it('line of ¥ even', async () => {
|
it('line of ¥ even', async () => {
|
||||||
await term.writeP(Array(50).join('¥'));
|
await term.writeP('¥'.repeat(49));
|
||||||
for (let i = 0; i < term.cols; ++i) {
|
for (let i = 0; i < term.cols; ++i) {
|
||||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||||
if (i % 2) {
|
if (i % 2) {
|
||||||
@@ -932,7 +932,7 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
it('line of ¥ odd', async () => {
|
it('line of ¥ odd', async () => {
|
||||||
term.buffer.x = 1;
|
term.buffer.x = 1;
|
||||||
await term.writeP(Array(50).join('¥'));
|
await term.writeP('¥'.repeat(49));
|
||||||
for (let i = 1; i < term.cols - 1; ++i) {
|
for (let i = 1; i < term.cols - 1; ++i) {
|
||||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||||
if (!(i % 2)) {
|
if (!(i % 2)) {
|
||||||
@@ -956,7 +956,7 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
it('line of ¥ with combining odd', async () => {
|
it('line of ¥ with combining odd', async () => {
|
||||||
term.buffer.x = 1;
|
term.buffer.x = 1;
|
||||||
await term.writeP(Array(50).join('¥\u0301'));
|
await term.writeP('¥\u0301'.repeat(49));
|
||||||
for (let i = 1; i < term.cols - 1; ++i) {
|
for (let i = 1; i < term.cols - 1; ++i) {
|
||||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||||
if (!(i % 2)) {
|
if (!(i % 2)) {
|
||||||
@@ -979,7 +979,7 @@ describe('Terminal', () => {
|
|||||||
assert.equal(cell.getWidth(), 2);
|
assert.equal(cell.getWidth(), 2);
|
||||||
});
|
});
|
||||||
it('line of ¥ with combining even', async () => {
|
it('line of ¥ with combining even', async () => {
|
||||||
await term.writeP(Array(50).join('¥\u0301'));
|
await term.writeP('¥\u0301'.repeat(49));
|
||||||
for (let i = 0; i < term.cols; ++i) {
|
for (let i = 0; i < term.cols; ++i) {
|
||||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||||
if (i % 2) {
|
if (i % 2) {
|
||||||
@@ -999,7 +999,7 @@ describe('Terminal', () => {
|
|||||||
});
|
});
|
||||||
it('line of surrogate fullwidth with combining odd', async () => {
|
it('line of surrogate fullwidth with combining odd', async () => {
|
||||||
term.buffer.x = 1;
|
term.buffer.x = 1;
|
||||||
await term.writeP(Array(50).join('\ud843\ude6d\u0301'));
|
await term.writeP('\ud843\ude6d\u0301'.repeat(49));
|
||||||
for (let i = 1; i < term.cols - 1; ++i) {
|
for (let i = 1; i < term.cols - 1; ++i) {
|
||||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||||
if (!(i % 2)) {
|
if (!(i % 2)) {
|
||||||
@@ -1022,7 +1022,7 @@ describe('Terminal', () => {
|
|||||||
assert.equal(cell.getWidth(), 2);
|
assert.equal(cell.getWidth(), 2);
|
||||||
});
|
});
|
||||||
it('line of surrogate fullwidth with combining even', async () => {
|
it('line of surrogate fullwidth with combining even', async () => {
|
||||||
await term.writeP(Array(50).join('\ud843\ude6d\u0301'));
|
await term.writeP('\ud843\ude6d\u0301'.repeat(49));
|
||||||
for (let i = 0; i < term.cols; ++i) {
|
for (let i = 0; i < term.cols; ++i) {
|
||||||
term.buffer.lines.get(0)!.loadCell(i, cell);
|
term.buffer.lines.get(0)!.loadCell(i, cell);
|
||||||
if (i % 2) {
|
if (i % 2) {
|
||||||
@@ -1045,7 +1045,7 @@ describe('Terminal', () => {
|
|||||||
describe('insert mode', () => {
|
describe('insert mode', () => {
|
||||||
const cell = new CellData();
|
const cell = new CellData();
|
||||||
it('halfwidth - all', async () => {
|
it('halfwidth - all', async () => {
|
||||||
await term.writeP(Array(9).join('0123456789').slice(-80));
|
await term.writeP('0123456789'.repeat(8).slice(-80));
|
||||||
term.buffer.x = 10;
|
term.buffer.x = 10;
|
||||||
term.buffer.y = 0;
|
term.buffer.y = 0;
|
||||||
term.write('\x1b[4h');
|
term.write('\x1b[4h');
|
||||||
@@ -1057,7 +1057,7 @@ describe('Terminal', () => {
|
|||||||
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '4');
|
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '4');
|
||||||
});
|
});
|
||||||
it('fullwidth - insert', async () => {
|
it('fullwidth - insert', async () => {
|
||||||
await term.writeP(Array(9).join('0123456789').slice(-80));
|
await term.writeP('0123456789'.repeat(8).slice(-80));
|
||||||
term.buffer.x = 10;
|
term.buffer.x = 10;
|
||||||
term.buffer.y = 0;
|
term.buffer.y = 0;
|
||||||
term.write('\x1b[4h');
|
term.write('\x1b[4h');
|
||||||
@@ -1070,7 +1070,7 @@ describe('Terminal', () => {
|
|||||||
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '3');
|
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '3');
|
||||||
});
|
});
|
||||||
it('fullwidth - right border', async () => {
|
it('fullwidth - right border', async () => {
|
||||||
await term.writeP(Array(41).join('¥'));
|
await term.writeP('¥'.repeat(40));
|
||||||
term.buffer.x = 10;
|
term.buffer.x = 10;
|
||||||
term.buffer.y = 0;
|
term.buffer.y = 0;
|
||||||
term.write('\x1b[4h');
|
term.write('\x1b[4h');
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user