Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60aa4bf97 | ||
|
|
22acf01d43 | ||
|
|
afaed108e4 | ||
|
|
c27d6bb462 | ||
|
|
9c4f0f96ca | ||
|
|
18c370a154 | ||
|
|
850f4ad738 | ||
|
|
b665f6259a | ||
|
|
4e5994d8a2 | ||
|
|
4a0cb5ae6b | ||
|
|
9e72a4b6d3 | ||
|
|
f484584b70 | ||
|
|
fe9ebddd6f | ||
|
|
c6d72b7db6 | ||
|
|
9add7789b2 | ||
|
|
c55a248d52 | ||
|
|
0f0c615748 | ||
|
|
9309ccc480 | ||
|
|
ecd846c027 | ||
|
|
7ddca3a305 | ||
|
|
5ff85db4f3 | ||
|
|
ef84237ef3 | ||
|
|
2da6c9b9b7 | ||
|
|
eb55fd4ba3 | ||
|
|
1f3ebe1b91 | ||
|
|
dc01e4e97b | ||
|
|
9bd72abc59 | ||
|
|
d179d586e9 | ||
|
|
3b56138d0a | ||
|
|
950f1be20d | ||
|
|
41841d0aa8 | ||
|
|
3bebe6c18e | ||
|
|
a344c36940 | ||
|
|
fedf29e870 | ||
|
|
7fbe5a27fa | ||
|
|
d4a8e5f50e | ||
|
|
8405d584d8 | ||
|
|
3ee7df2596 | ||
|
|
0440861b84 | ||
|
|
73c99a8412 | ||
|
|
e5ffbe315a | ||
|
|
510a1b0d75 | ||
|
|
74bec251e6 | ||
|
|
6d08f5938d | ||
|
|
ee322c811f | ||
|
|
811be7b1ac | ||
|
|
17dd1ede33 | ||
|
|
2146e4940d | ||
|
|
d99803293e | ||
|
|
f28bfb7142 | ||
|
|
e4ebd082ce | ||
|
|
a7467a901d | ||
|
|
9902f54c93 | ||
|
|
c7baa96f97 | ||
|
|
11720c9dc3 | ||
|
|
0b1cacd1a5 | ||
|
|
865f835a5e | ||
|
|
bf055dd5fc | ||
|
|
255859606c | ||
|
|
e749cb6125 | ||
|
|
8df996f9e7 | ||
|
|
51f6186352 | ||
|
|
ae5c41c518 | ||
|
|
17e7dafa17 | ||
|
|
df9fb7e473 | ||
|
|
41d9c4080f | ||
|
|
f100e93ba5 | ||
|
|
1d3825d341 | ||
|
|
57315e6cc9 | ||
|
|
2be4fc68e5 | ||
|
|
66fabfc232 | ||
|
|
3e2a42eb29 | ||
|
|
6a47d09b4c | ||
|
|
b36001ee7b | ||
|
|
e64f25a0dd | ||
|
|
3bcb57545e | ||
|
|
dc726a2ae6 | ||
|
|
06e5ac7ab4 | ||
|
|
c6e3492a4a | ||
|
|
86291b6e96 | ||
|
|
c8c8b3a7fe | ||
|
|
50e90ddf8a | ||
|
|
7059a07c09 | ||
|
|
c5c6fc2a64 | ||
|
|
e0add7fe4b | ||
|
|
b0f5bc2d28 | ||
|
|
c5a11220c1 | ||
|
|
6f55150485 | ||
|
|
c8dddddbbb | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
5c28e27062 | ||
|
|
6e35b44f02 | ||
|
|
30c97a225c | ||
|
|
1186d09e05 | ||
|
|
d99e6bb2bc | ||
|
|
579a0a530f | ||
|
|
0b6220e5ea | ||
|
|
0d119b30d6 | ||
|
|
79c94de968 | ||
|
|
e3980a27db | ||
|
|
898c0d91c3 | ||
|
|
83401e3769 | ||
|
|
6b756318f0 | ||
|
|
3a9bfa94bc | ||
|
|
209e5aa6b9 | ||
|
|
24e2eb9d7b | ||
|
|
d5ae437dda | ||
|
|
5efff43e93 | ||
|
|
ee85a1da9e | ||
|
|
c30fedff6b | ||
|
|
21f51564d1 | ||
|
|
27b01b22ef | ||
|
|
387ec93a90 | ||
|
|
4388a4ac13 | ||
|
|
08c97d12b0 | ||
|
|
fbf1f7bc5d | ||
|
|
c8704930e3 | ||
|
|
e60c96823d | ||
|
|
375be97615 | ||
|
|
74642ce5d5 | ||
|
|
eccbc60aa0 | ||
|
|
6b64660934 | ||
|
|
10182cc656 | ||
|
|
3c7f313c34 | ||
|
|
457542b7b3 | ||
|
|
36118d9d4c | ||
|
|
cf8a4e466d | ||
|
|
4de03aaa38 | ||
|
|
677e5275f9 | ||
|
|
c66b0ed168 | ||
|
|
d4c3b693bb | ||
|
|
2f66b5fdb6 | ||
|
|
008aa1bfb5 | ||
|
|
1d0cf32b28 | ||
|
|
c01e4b7be7 | ||
|
|
fafb195f0f | ||
|
|
2c4997f529 | ||
|
|
f0d291f645 | ||
|
|
f6b98291cf | ||
|
|
16ea2ec0ef | ||
|
|
f7118b19c6 | ||
|
|
a0574f4e9c | ||
|
|
d9b5d150ae | ||
|
|
a25db1c76d | ||
|
|
c6a572fe21 | ||
|
|
b9fa53169f | ||
|
|
eb0f5383c3 | ||
|
|
a4502a6d00 | ||
|
|
c11a8f43f4 | ||
|
|
d7ba56cd28 | ||
|
|
be44833f12 | ||
|
|
fcb3cc1a21 | ||
|
|
376268157c | ||
|
|
5ceb02b9c6 | ||
|
|
d320898231 | ||
|
|
cf3939af4a | ||
|
|
38913e4ae1 | ||
|
|
e14746e6e9 | ||
|
|
82333b9414 | ||
|
|
9eeb2799c1 | ||
|
|
5630471480 | ||
|
|
e0c59e68b2 | ||
|
|
cc8e680790 | ||
|
|
452ee50c81 | ||
|
|
f41e53a0f0 | ||
|
|
596ab845d0 | ||
|
|
a59d527d1a | ||
|
|
b287397c4f | ||
|
|
aebbe81252 | ||
|
|
d155bb6767 | ||
|
|
3a147e8e10 | ||
|
|
c2db7d4c8a | ||
|
|
456bc73bae | ||
|
|
2e776c0820 | ||
|
|
08f85cf826 | ||
|
|
6f163beb97 | ||
|
|
b0d0bf8187 | ||
|
|
32e77c3b81 | ||
|
|
e95ec8d4bc | ||
|
|
cba6e3d191 | ||
|
|
d15b426bd4 | ||
|
|
c907edd75f | ||
|
|
5741e450e6 | ||
|
|
6ef653e589 | ||
|
|
a4c0e4690e | ||
|
|
a31f78bc8b | ||
|
|
ca8e8546b5 | ||
|
|
3d473b52b6 | ||
|
|
a2bec2aeab | ||
|
|
205cf6089d | ||
|
|
8b0a6f9785 | ||
|
|
db0226be5c | ||
|
|
ab5c06cf17 | ||
|
|
9a778f0c98 | ||
|
|
c102b4af50 | ||
|
|
f3a7cac49f | ||
|
|
0d2a58c9d0 | ||
|
|
8a00898533 | ||
|
|
edb0e1ceb3 | ||
|
|
93c1650446 | ||
|
|
1029e19b57 | ||
|
|
d44900faa2 | ||
|
|
5f724c5924 | ||
|
|
77695cb405 | ||
|
|
dc4ebfd6e3 | ||
|
|
fae6f03f90 | ||
|
|
d36af624cf | ||
|
|
a105d3e457 | ||
|
|
d5900c7eae | ||
|
|
beebe8b5dc | ||
|
|
ae58176bcb | ||
|
|
429b2882f1 | ||
|
|
ffac8c03ab | ||
|
|
a7c4a160a8 | ||
|
|
c9dc994090 | ||
|
|
e4710d3286 | ||
|
|
8f9045ed88 | ||
|
|
b8e5ec7304 | ||
|
|
6a3d2480e0 | ||
|
|
735afde77f | ||
|
|
53efc95170 | ||
|
|
3512f140de | ||
|
|
dbe35428cb | ||
|
|
297ac838a3 | ||
|
|
5316cb0812 | ||
|
|
c124550971 | ||
|
|
6525a7e68e | ||
|
|
f666135881 | ||
|
|
b2133d93f0 | ||
|
|
a5e4f90139 | ||
|
|
a27b8feb8f | ||
|
|
eea03cf0a9 | ||
|
|
e96748cc79 | ||
|
|
334c8170fe | ||
|
|
2bb9662bea | ||
|
|
7cba3c9fa1 | ||
|
|
bab7db13db | ||
|
|
1c79c5511c | ||
|
|
7b417f4585 | ||
|
|
dc487dfd6f | ||
|
|
36baf70bea | ||
|
|
87e0b9ab10 | ||
|
|
5e532c13c6 | ||
|
|
db131e0c22 | ||
|
|
51b9a713da | ||
|
|
972c069fa3 | ||
|
|
2c0ba3c058 | ||
|
|
e515647f99 | ||
|
|
7a2794a552 | ||
|
|
da2267a10f | ||
|
|
34e68b1ad4 | ||
|
|
1c42957461 | ||
|
|
77036d3f12 | ||
|
|
ccbf612ddb | ||
|
|
eed8423030 | ||
|
|
431d511c95 | ||
|
|
0ca1bd48be | ||
|
|
7ceb15bb83 | ||
|
|
5ecdc7da44 | ||
|
|
2e2270d01f | ||
|
|
042bb4bed9 | ||
|
|
7e3c41ba49 | ||
|
|
21e4301e38 | ||
|
|
3ef43aedb1 | ||
|
|
9eaf5b45dc | ||
|
|
39037ee806 | ||
|
|
e542affaca | ||
|
|
038883c41b | ||
|
|
6c3d4e4ea4 | ||
|
|
659cfddd8c | ||
|
|
c375696ee6 | ||
|
|
bc3c376e83 | ||
|
|
9fca3d8ecf | ||
|
|
8eed7b4163 | ||
|
|
c29938ee83 | ||
|
|
0e679ea6ae | ||
|
|
d0e8141f7c | ||
|
|
1d322c9e45 | ||
|
|
bad7e329df | ||
|
|
ae5e7e55c8 | ||
|
|
5bb2e46508 | ||
|
|
5938b8823d | ||
|
|
69d87c8a30 | ||
|
|
a247154650 | ||
|
|
b30d9c3533 | ||
|
|
60190c1c48 | ||
|
|
99d69d2236 | ||
|
|
de01d4608c | ||
|
|
d35481c3f9 | ||
|
|
e2d84928b7 | ||
|
|
9ffd16aa9b | ||
|
|
77fa03d11c | ||
|
|
01450f044a | ||
|
|
b04d816d8a | ||
|
|
f80dbf3d03 | ||
|
|
b58bd452af | ||
|
|
e8d99d50a6 | ||
|
|
bae314f67c | ||
|
|
cb8159c4f2 | ||
|
|
b87cba4c2a | ||
|
|
492d950b64 | ||
|
|
a1b69e5ac8 | ||
|
|
d77711936f | ||
|
|
5a79b08661 | ||
|
|
3e8fc48b33 | ||
|
|
8c9afdb364 | ||
|
|
f89524e577 | ||
|
|
a1c1702508 | ||
|
|
15c71e28c8 | ||
|
|
fd641370ec | ||
|
|
259cc67f12 | ||
|
|
b0b0868e90 | ||
|
|
d58e8597cd | ||
|
|
17d439515f | ||
|
|
ae171acb79 | ||
|
|
e2dabc77fd | ||
|
|
40342ecd1e | ||
|
|
1442f84e4e | ||
|
|
6f87ae77a7 | ||
|
|
5f25e05cd2 | ||
|
|
2af9d10171 | ||
|
|
f471c0add8 | ||
|
|
62dc0d9c51 | ||
|
|
5e95ef652d | ||
|
|
14525b334f | ||
|
|
bc1d87dade | ||
|
|
849110d300 | ||
|
|
8b6f8f3172 | ||
|
|
27825f9ed8 | ||
|
|
a507afb388 | ||
|
|
ec5b421dfb | ||
|
|
8f0e711a79 | ||
|
|
fc0dc3fb07 | ||
|
|
bea1628015 | ||
|
|
447746314e | ||
|
|
6a02d7c9f5 | ||
|
|
1ace108422 | ||
|
|
2c1441a52d | ||
|
|
304943658d | ||
|
|
8e67c0093c | ||
|
|
ee3b3626e8 | ||
|
|
b286f21f64 | ||
|
|
3d92a7e2ed | ||
|
|
b01ba7db28 | ||
|
|
1c73006e39 | ||
|
|
03b015d5d3 | ||
|
|
42d408a9a7 | ||
|
|
5ecd980a83 | ||
|
|
63ebe2df5b | ||
|
|
1b2d361b59 | ||
|
|
8e29591b9f | ||
|
|
48df6117ed | ||
|
|
14b6a71391 | ||
|
|
c24eb60bd3 | ||
|
|
2521bab72f | ||
|
|
0f1c9a47db |
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
|
||||||
|
## Running Benchmarks
|
||||||
|
|
||||||
|
- Full suite: `npm run benchmark`
|
||||||
|
- Single benchmark file:
|
||||||
|
- Tree: `npm run benchmark -- -t out-test/benchmark/Event.benchmark.js`
|
||||||
|
- Run file: `npm run benchmark -- -s "out-test/benchmark/Event.benchmark.js" out-test/benchmark/Event.benchmark.js`
|
||||||
|
- Single context/case:
|
||||||
|
- Use `-t` to get the path, then:
|
||||||
|
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
|
||||||
|
|
||||||
|
## Benchmark Case Selection
|
||||||
|
|
||||||
|
When writing benchmark instructions:
|
||||||
|
|
||||||
|
- Use `RuntimeCase` to measure pure runtime in ms.
|
||||||
|
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
|
||||||
|
- Keep `NODE_PATH=./out` (handled by the npm script).
|
||||||
+6
-2
@@ -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.
|
||||||
+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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ node_modules/
|
|||||||
.lock-wscript
|
.lock-wscript
|
||||||
lib/
|
lib/
|
||||||
out/
|
out/
|
||||||
|
out-benchmark/
|
||||||
out-demo/
|
out-demo/
|
||||||
out-test/
|
out-test/
|
||||||
out-esbuild/
|
out-esbuild/
|
||||||
@@ -22,6 +23,9 @@ build/
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
test-results/
|
test-results/
|
||||||
|
|
||||||
|
# Tracing/debug files
|
||||||
|
.cursor/nal-trace/
|
||||||
|
|
||||||
# Keep bundled code out of Git
|
# Keep bundled code out of Git
|
||||||
dist/
|
dist/
|
||||||
demo/dist/
|
demo/dist/
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ npm run build && npm run esbuild # Build all TypeScript and bundle
|
|||||||
**Testing**:
|
**Testing**:
|
||||||
- Unit tests: `npm run test-unit` (Mocha)
|
- Unit tests: `npm run test-unit` (Mocha)
|
||||||
- Unit tests filtering to file: `npm run test-unit -- **/fileName.ts
|
- Unit tests filtering to file: `npm run test-unit -- **/fileName.ts
|
||||||
- Per-addon unit tests: `npm run test-unit addons/addon-image/out-esbuild/*.test.js`
|
- Per-addon unit tests: `npm run test-unit -- addons/addon-image/out-esbuild/*.test.js`
|
||||||
- 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 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 +57,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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "../../node_modules/.bin/tsc -p .",
|
"build": "../../node_modules/.bin/tsgo -p .",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface IAttachOptions {
|
|||||||
bidirectional?: boolean;
|
bidirectional?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AttachAddon implements ITerminalAddon , IAttachApi {
|
export class AttachAddon implements ITerminalAddon, IAttachApi {
|
||||||
private _socket: WebSocket;
|
private _socket: WebSocket;
|
||||||
private _bidirectional: boolean;
|
private _bidirectional: boolean;
|
||||||
private _disposables: IDisposable[] = [];
|
private _disposables: IDisposable[] = [];
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
],
|
],
|
||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -16,13 +16,10 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "../../node_modules/.bin/tsc -p .",
|
"build": "../../node_modules/.bin/tsgo -p .",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"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`);
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
],
|
],
|
||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
+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>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "../../node_modules/.bin/tsc -p .",
|
"build": "../../node_modules/.bin/tsgo -p .",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -33,7 +35,7 @@ function _getComputedStyle(el: HTMLElement): CSSStyleDeclaration {
|
|||||||
return getWindow(el).getComputedStyle(el, null);
|
return getWindow(el).getComputedStyle(el, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FitAddon implements ITerminalAddon , IFitApi {
|
export class FitAddon implements ITerminalAddon, IFitApi {
|
||||||
private _terminal: Terminal | undefined;
|
private _terminal: Terminal | undefined;
|
||||||
|
|
||||||
public activate(terminal: Terminal): void {
|
public activate(terminal: Terminal): void {
|
||||||
@@ -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 {
|
||||||
@@ -69,9 +68,10 @@ export class FitAddon implements ITerminalAddon , IFitApi {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollbarWidth = (this._terminal.options.scrollback === 0
|
const showScrollbar = this._terminal.options.scrollbar?.showScrollbar ?? true;
|
||||||
|
const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar
|
||||||
? 0
|
? 0
|
||||||
: (this._terminal.options.overviewRuler?.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 = parseInt(parentElementStyle.getPropertyValue('height'));
|
||||||
@@ -88,8 +88,8 @@ export class FitAddon implements ITerminalAddon , IFitApi {
|
|||||||
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
],
|
],
|
||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -25,12 +25,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
|
||||||
|
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialization
|
// initialization
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
Binary file not shown.
|
After Width: | Height: | Size: 449 B |
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
@@ -18,13 +18,13 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepackage": "../../node_modules/.bin/tsc -p .",
|
"prepackage": "../../node_modules/.bin/tsgo -p .",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"sixel": "^0.16.0",
|
"sixel": "^0.16.0",
|
||||||
"xterm-wasm-parts": "^0.1.0"
|
"xterm-wasm-parts": "^0.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,22 @@
|
|||||||
*/
|
*/
|
||||||
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||||
import { ImageRenderer } from './ImageRenderer';
|
import { ImageRenderer } from './ImageRenderer';
|
||||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
import { IIPImageStorage } from './IIPImageStorage';
|
||||||
|
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 QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
import { HeaderParser, IHeaderFields, HeaderState } 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
|
||||||
// eslint-disable-next-line
|
const enum DecoderConst {
|
||||||
declare const Buffer: any;
|
// Limit held memory in base64 decoder (encoded bytes).
|
||||||
|
KEEP_DATA = 4194304,
|
||||||
// limit hold memory in base64 decoder
|
// Initial buffer allocation for the decoder.
|
||||||
const KEEP_DATA = 4194304;
|
INITIAL_DATA = 1048576,
|
||||||
|
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||||
|
OK = 0
|
||||||
|
}
|
||||||
|
|
||||||
// default IIP header values
|
// default IIP header values
|
||||||
const DEFAULT_HEADER: IHeaderFields = {
|
const DEFAULT_HEADER: IHeaderFields = {
|
||||||
@@ -31,15 +36,21 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
private _aborted = false;
|
private _aborted = false;
|
||||||
private _hp = new HeaderParser();
|
private _hp = new HeaderParser();
|
||||||
private _header: IHeaderFields = DEFAULT_HEADER;
|
private _header: IHeaderFields = DEFAULT_HEADER;
|
||||||
private _dec = new Base64Decoder(KEEP_DATA);
|
private _dec: Base64Decoder;
|
||||||
|
private _qoiDec: QoiDecoder;
|
||||||
private _metrics = UNSUPPORTED_TYPE;
|
private _metrics = UNSUPPORTED_TYPE;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _opts: IImageAddonOptions,
|
private readonly _opts: IImageAddonOptions,
|
||||||
private readonly _renderer: ImageRenderer,
|
private readonly _renderer: ImageRenderer,
|
||||||
private readonly _storage: ImageStorage,
|
private readonly _storage: IIPImageStorage,
|
||||||
private readonly _coreTerminal: ITerminalExt
|
private readonly _coreTerminal: ITerminalExt
|
||||||
) {}
|
) {
|
||||||
|
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
||||||
|
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
||||||
|
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
|
||||||
|
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
public reset(): void {}
|
public reset(): void {}
|
||||||
|
|
||||||
@@ -54,7 +65,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
if (this._aborted) return;
|
if (this._aborted) return;
|
||||||
|
|
||||||
if (this._hp.state === HeaderState.END) {
|
if (this._hp.state === HeaderState.END) {
|
||||||
if (this._dec.put(data, start, end)) {
|
if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {
|
||||||
this._dec.release();
|
this._dec.release();
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
}
|
}
|
||||||
@@ -70,8 +81,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._dec.init(this._header.size);
|
this._dec.init();
|
||||||
if (this._dec.put(data, dataPos, end)) {
|
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {
|
||||||
this._dec.release();
|
this._dec.release();
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
}
|
}
|
||||||
@@ -89,13 +100,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
let cond: number | boolean = true;
|
let cond: number | boolean = true;
|
||||||
if (cond = success) {
|
if (cond = success) {
|
||||||
if (cond = !this._dec.end()) {
|
if (cond = !this._dec.end()) {
|
||||||
this._metrics = imageType(this._dec.data8);
|
if (cond = this._dec.data8.length === this._header.size) {
|
||||||
if (cond = this._metrics.mime !== 'unsupported') {
|
this._metrics = imageType(this._dec.data8);
|
||||||
w = this._metrics.width;
|
if (cond = this._metrics.mime !== 'unsupported') {
|
||||||
h = this._metrics.height;
|
w = this._metrics.width;
|
||||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
h = this._metrics.height;
|
||||||
[w, h] = this._resize(w, h).map(Math.floor);
|
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||||
cond = w && h && w * h < this._opts.pixelLimit;
|
[w, h] = this._resize(w, h).map(Math.floor);
|
||||||
|
cond = w && h && w * h < this._opts.pixelLimit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,26 +118,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = new Blob([new Uint8Array(this._dec.data8)], { type: this._metrics.mime });
|
let blob: Blob | ImageData;
|
||||||
this._dec.release();
|
if (this._metrics.mime === 'image/qoi') {
|
||||||
|
const data = this._qoiDec.decode(this._dec.data8);
|
||||||
if (!window.createImageBitmap) {
|
blob = new ImageData(
|
||||||
const url = URL.createObjectURL(blob);
|
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
||||||
const img = new Image();
|
this._qoiDec.width,
|
||||||
return new Promise<boolean>(r => {
|
this._qoiDec.height
|
||||||
img.addEventListener('load', () => {
|
);
|
||||||
URL.revokeObjectURL(url);
|
this._qoiDec.release();
|
||||||
const canvas = ImageRenderer.createCanvas(window.document, w, h);
|
if (w === this._qoiDec.width && h === this._qoiDec.height) {
|
||||||
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
|
// use fast-path if we don't need to rescale
|
||||||
this._storage.addImage(canvas);
|
this._dec.release();
|
||||||
r(true);
|
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
|
||||||
});
|
canvas.getContext('2d')?.putImageData(blob, 0, 0);
|
||||||
img.src = url;
|
this._storage.addImage(canvas);
|
||||||
// sanity measure to avoid terminal blocking from dangling promise
|
return true;
|
||||||
// happens from corrupt data (onload never gets fired)
|
}
|
||||||
setTimeout(() => r(true), 1000);
|
} else {
|
||||||
});
|
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);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ declare const Buffer: any;
|
|||||||
|
|
||||||
|
|
||||||
export interface IHeaderFields {
|
export interface IHeaderFields {
|
||||||
|
[key: string]: number | string | Uint32Array | null | undefined;
|
||||||
// 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.
|
||||||
@@ -81,7 +82,7 @@ function toName(data: Uint32Array): string {
|
|||||||
return new TextDecoder().decode(b);
|
return new TextDecoder().decode(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DECODERS: {[key: string]: (v: Uint32Array) => any} = {
|
const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
|
||||||
inline: toInt,
|
inline: toInt,
|
||||||
size: toInt,
|
size: toInt,
|
||||||
name: toName,
|
name: toName,
|
||||||
@@ -100,7 +101,7 @@ export class HeaderParser {
|
|||||||
private _buffer = new Uint32Array(MAX_FIELDCHARS);
|
private _buffer = new Uint32Array(MAX_FIELDCHARS);
|
||||||
private _position = 0;
|
private _position = 0;
|
||||||
private _key = '';
|
private _key = '';
|
||||||
public fields: {[key: string]: any} = {};
|
public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this._buffer.fill(0);
|
this._buffer.fill(0);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ImageStorage } from './ImageStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IIP (iTerm Image Protocol) specific image storage controller.
|
||||||
|
*
|
||||||
|
* Wraps the shared ImageStorage with IIP protocol semantics:
|
||||||
|
* - Always uses scrolling mode (cursor advances with image)
|
||||||
|
*/
|
||||||
|
export class IIPImageStorage {
|
||||||
|
constructor(
|
||||||
|
private readonly _storage: ImageStorage
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an IIP image to storage.
|
||||||
|
* Always uses scrolling mode — cursor advances past the image.
|
||||||
|
*/
|
||||||
|
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||||
|
this._storage.addImage(img, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,24 +5,72 @@
|
|||||||
|
|
||||||
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';
|
||||||
|
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
|
||||||
|
import { KittyImageStorage } from './kitty/KittyImageStorage';
|
||||||
import { SixelHandler } from './SixelHandler';
|
import { SixelHandler } from './SixelHandler';
|
||||||
|
import { SixelImageStorage } from './SixelImageStorage';
|
||||||
|
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,
|
||||||
|
kittySizeLimit: 33554432
|
||||||
};
|
};
|
||||||
|
|
||||||
// max palette size supported by the sixel lib (compile time setting)
|
// max palette size supported by the sixel lib (compile time setting)
|
||||||
@@ -48,7 +96,7 @@ const enum GaStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class ImageAddon implements ITerminalAddon , IImageApi {
|
export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||||
private _opts: IImageAddonOptions;
|
private _opts: IImageAddonOptions;
|
||||||
private _defaultOpts: IImageAddonOptions;
|
private _defaultOpts: IImageAddonOptions;
|
||||||
private _storage: ImageStorage | undefined;
|
private _storage: ImageStorage | undefined;
|
||||||
@@ -56,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);
|
||||||
@@ -68,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 {
|
||||||
@@ -82,6 +133,7 @@ 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) {
|
||||||
@@ -90,7 +142,7 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
|||||||
// windowOptions.getCellSizePixels = true;
|
// windowOptions.getCellSizePixels = true;
|
||||||
// windowOptions.getWinSizeChars = true;
|
// windowOptions.getWinSizeChars = true;
|
||||||
// terminal.setOption('windowOptions', windowOptions);
|
// 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;
|
||||||
windowOps.getWinSizeChars = true;
|
windowOps.getWinSizeChars = true;
|
||||||
@@ -129,7 +181,8 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
|||||||
|
|
||||||
// SIXEL handler
|
// SIXEL handler
|
||||||
if (this._opts.sixelSupport) {
|
if (this._opts.sixelSupport) {
|
||||||
const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal);
|
const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);
|
||||||
|
const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);
|
||||||
this._handlers.set('sixel', sixelHandler);
|
this._handlers.set('sixel', sixelHandler);
|
||||||
this._disposeLater(
|
this._disposeLater(
|
||||||
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
|
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
|
||||||
@@ -138,12 +191,25 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
|||||||
|
|
||||||
// iTerm IIP handler
|
// iTerm IIP handler
|
||||||
if (this._opts.iipSupport) {
|
if (this._opts.iipSupport) {
|
||||||
const iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal);
|
const iipStorage = new IIPImageStorage(this._storage!);
|
||||||
|
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);
|
||||||
this._handlers.set('iip', iipHandler);
|
this._handlers.set('iip', iipHandler);
|
||||||
this._disposeLater(
|
this._disposeLater(
|
||||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kitty graphics handler
|
||||||
|
if (this._opts.kittySupport) {
|
||||||
|
const kittyStorage = new KittyImageStorage(this._storage!);
|
||||||
|
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
|
||||||
|
this._handlers.set('kitty', kittyHandler);
|
||||||
|
this._disposeLater(
|
||||||
|
kittyStorage,
|
||||||
|
kittyHandler,
|
||||||
|
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
|
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
|
|
||||||
import { toRGBA8888 } from 'sixel/lib/Colors';
|
import { toRGBA8888 } from 'sixel/lib/Colors';
|
||||||
import { IDisposable } from '@xterm/xterm';
|
import { IDisposable } from '@xterm/xterm';
|
||||||
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/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:
|
||||||
@@ -18,8 +20,9 @@ const PLACEHOLDER_HEIGHT = 24;
|
|||||||
* - draw image tiles onRender
|
* - draw image tiles onRender
|
||||||
*/
|
*/
|
||||||
export class ImageRenderer extends Disposable implements IDisposable {
|
export class ImageRenderer extends Disposable implements IDisposable {
|
||||||
public canvas: HTMLCanvasElement | undefined;
|
/** @deprecated Kept for backward compat — points to top layer canvas. */
|
||||||
private _ctx: CanvasRenderingContext2D | null | undefined;
|
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
|
||||||
|
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
|
||||||
private _placeholder: HTMLCanvasElement | undefined;
|
private _placeholder: HTMLCanvasElement | undefined;
|
||||||
private _placeholderBitmap: ImageBitmap | undefined;
|
private _placeholderBitmap: ImageBitmap | undefined;
|
||||||
private _optionsRefresh = this._register(new MutableDisposable());
|
private _optionsRefresh = this._register(new MutableDisposable());
|
||||||
@@ -38,7 +41,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
* Only the DOM output canvas should be on the terminal's document,
|
* Only the DOM output canvas should be on the terminal's document,
|
||||||
* which gets explicitly checked in `insertLayerToDom`.
|
* which gets explicitly checked in `insertLayerToDom`.
|
||||||
*/
|
*/
|
||||||
const canvas = (localDocument || document).createElement('canvas');
|
const canvas = (localDocument ?? document).createElement('canvas');
|
||||||
canvas.width = width | 0;
|
canvas.width = width | 0;
|
||||||
canvas.height = height | 0;
|
canvas.height = height | 0;
|
||||||
return canvas;
|
return canvas;
|
||||||
@@ -86,6 +89,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
});
|
});
|
||||||
this._register(toDisposable(() => {
|
this._register(toDisposable(() => {
|
||||||
this.removeLayerFromDom();
|
this.removeLayerFromDom();
|
||||||
|
this.removeLayerFromDom('bottom');
|
||||||
if (this._terminal._core && this._oldOpen) {
|
if (this._terminal._core && this._oldOpen) {
|
||||||
this._terminal._core.open = this._oldOpen;
|
this._terminal._core.open = this._oldOpen;
|
||||||
this._oldOpen = undefined;
|
this._oldOpen = undefined;
|
||||||
@@ -95,8 +99,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
this._oldSetRenderer = undefined;
|
this._oldSetRenderer = undefined;
|
||||||
}
|
}
|
||||||
this._renderService = undefined;
|
this._renderService = undefined;
|
||||||
this.canvas = undefined;
|
this._layers.clear();
|
||||||
this._ctx = undefined;
|
|
||||||
this._placeholderBitmap?.close();
|
this._placeholderBitmap?.close();
|
||||||
this._placeholderBitmap = undefined;
|
this._placeholderBitmap = undefined;
|
||||||
this._placeholder = undefined;
|
this._placeholder = undefined;
|
||||||
@@ -109,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();
|
||||||
@@ -140,27 +143,38 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
/**
|
/**
|
||||||
* Clear a region of the image layer canvas.
|
* Clear a region of the image layer canvas.
|
||||||
*/
|
*/
|
||||||
public clearLines(start: number, end: number): void {
|
public clearLines(start: number, end: number, layer?: ImageLayer): void {
|
||||||
this._ctx?.clearRect(
|
const y = start * (this.dimensions?.css.cell.height || 0);
|
||||||
0,
|
const w = this.dimensions?.css.canvas.width || 0;
|
||||||
start * (this.dimensions?.css.cell.height || 0),
|
const h = (++end - start) * (this.dimensions?.css.cell.height || 0);
|
||||||
this.dimensions?.css.canvas.width || 0,
|
if (!layer || layer === 'top') {
|
||||||
(++end - start) * (this.dimensions?.css.cell.height || 0)
|
this._layers.get('top')?.clearRect(0, y, w, h);
|
||||||
);
|
}
|
||||||
|
if (!layer || layer === 'bottom') {
|
||||||
|
this._layers.get('bottom')?.clearRect(0, y, w, h);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear whole image canvas.
|
* Clear whole image canvas.
|
||||||
*/
|
*/
|
||||||
public clearAll(): void {
|
public clearAll(layer?: ImageLayer): void {
|
||||||
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
|
if (!layer || layer === 'top') {
|
||||||
|
const ctx = this._layers.get('top');
|
||||||
|
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
}
|
||||||
|
if (!layer || layer === 'bottom') {
|
||||||
|
const ctx = this._layers.get('bottom');
|
||||||
|
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw neighboring tiles on the image layer canvas.
|
* Draw neighboring tiles on the image layer canvas.
|
||||||
*/
|
*/
|
||||||
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
|
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
|
||||||
if (!this._ctx) {
|
const ctx = this._layers.get(imgSpec.layer);
|
||||||
|
if (!ctx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { width, height } = this.cellSize;
|
const { width, height } = this.cellSize;
|
||||||
@@ -187,7 +201,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
// Note: For not pixel perfect aligned cells like in the DOM renderer
|
// Note: For not pixel perfect aligned cells like in the DOM renderer
|
||||||
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
|
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
|
||||||
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
|
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
|
||||||
this._ctx.drawImage(
|
ctx.drawImage(
|
||||||
img,
|
img,
|
||||||
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
|
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
|
||||||
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
|
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
|
||||||
@@ -227,7 +241,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
* Draw a line with placeholder on the image layer canvas.
|
* Draw a line with placeholder on the image layer canvas.
|
||||||
*/
|
*/
|
||||||
public drawPlaceholder(col: number, row: number, count: number = 1): void {
|
public drawPlaceholder(col: number, row: number, count: number = 1): void {
|
||||||
if (this._ctx) {
|
const ctx = this._layers.get('top');
|
||||||
|
if (ctx) {
|
||||||
const { width, height } = this.cellSize;
|
const { width, height } = this.cellSize;
|
||||||
|
|
||||||
// Don't try to draw anything, if we cannot get valid renderer metrics.
|
// Don't try to draw anything, if we cannot get valid renderer metrics.
|
||||||
@@ -236,13 +251,13 @@ 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);
|
||||||
}
|
}
|
||||||
if (!this._placeholder) return;
|
if (!this._placeholder) return;
|
||||||
this._ctx.drawImage(
|
ctx.drawImage(
|
||||||
this._placeholderBitmap || this._placeholder!,
|
this._placeholderBitmap ?? this._placeholder!,
|
||||||
col * width,
|
col * width,
|
||||||
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
|
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
|
||||||
width * count,
|
width * count,
|
||||||
@@ -260,12 +275,13 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
* Checked once from `ImageStorage.render`.
|
* Checked once from `ImageStorage.render`.
|
||||||
*/
|
*/
|
||||||
public rescaleCanvas(): void {
|
public rescaleCanvas(): void {
|
||||||
if (!this.canvas) {
|
const w = this.dimensions?.css.canvas.width || 0;
|
||||||
return;
|
const h = this.dimensions?.css.canvas.height || 0;
|
||||||
}
|
for (const ctx of this._layers.values()) {
|
||||||
if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
|
if (ctx.canvas.width !== w || ctx.canvas.height !== h) {
|
||||||
this.canvas.width = this.dimensions!.css.canvas.width || 0;
|
ctx.canvas.width = w;
|
||||||
this.canvas.height = this.dimensions!.css.canvas.height || 0;
|
ctx.canvas.height = h;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,38 +320,68 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
|||||||
this._renderService = this._terminal._core._renderService;
|
this._renderService = this._terminal._core._renderService;
|
||||||
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
|
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
|
||||||
this._renderService.setRenderer = (renderer: any) => {
|
this._renderService.setRenderer = (renderer: any) => {
|
||||||
this.removeLayerFromDom();
|
for (const key of [...this._layers.keys()]) {
|
||||||
|
this.removeLayerFromDom(key);
|
||||||
|
}
|
||||||
this._oldSetRenderer?.call(this._renderService, renderer);
|
this._oldSetRenderer?.call(this._renderService, renderer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public insertLayerToDom(): void {
|
public insertLayerToDom(layer: ImageLayer = 'top'): void {
|
||||||
// make sure that the terminal is attached to a document and to DOM
|
// make sure that the terminal is attached to a document and to DOM
|
||||||
if (this.document && this._terminal._core.screenElement) {
|
if (!this.document || !this._terminal._core.screenElement) {
|
||||||
if (!this.canvas) {
|
|
||||||
this.canvas = ImageRenderer.createCanvas(
|
|
||||||
this.document, this.dimensions?.css.canvas.width || 0,
|
|
||||||
this.dimensions?.css.canvas.height || 0
|
|
||||||
);
|
|
||||||
this.canvas.classList.add('xterm-image-layer');
|
|
||||||
this._terminal._core.screenElement.appendChild(this.canvas);
|
|
||||||
this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
|
|
||||||
this.clearAll();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
|
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._layers.has(layer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = ImageRenderer.createCanvas(
|
||||||
|
this.document, this.dimensions?.css.canvas.width || 0,
|
||||||
|
this.dimensions?.css.canvas.height || 0
|
||||||
|
);
|
||||||
|
canvas.classList.add(`xterm-image-layer-${layer}`);
|
||||||
|
const screenElement = this._terminal._core.screenElement;
|
||||||
|
// Use isolation to create a stacking context without overriding z-index,
|
||||||
|
// which would conflict with integrators (e.g. VS Code) that set their
|
||||||
|
// own z-index on the screen element.
|
||||||
|
screenElement.style.isolation = 'isolate';
|
||||||
|
if (layer === 'bottom') {
|
||||||
|
// Use z-index:-1 so it paints behind non-positioned text elements.
|
||||||
|
// The screen element needs to be a stacking context (via isolation)
|
||||||
|
// to contain the negative z-index, otherwise it would go behind the
|
||||||
|
// entire terminal.
|
||||||
|
canvas.style.zIndex = '-1';
|
||||||
|
screenElement.insertBefore(canvas, screenElement.firstChild);
|
||||||
|
} else {
|
||||||
|
// Explicit z-index ensures the image canvas reliably stacks above
|
||||||
|
// the text layer (DOM renderer rows). z-index: 0 is below the
|
||||||
|
// selection overlay (z-index: 1).
|
||||||
|
canvas.style.zIndex = '0';
|
||||||
|
screenElement.appendChild(canvas);
|
||||||
|
}
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||||||
|
if (!ctx) {
|
||||||
|
canvas.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._layers.set(layer, ctx);
|
||||||
|
this.clearAll(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
|
||||||
|
const ctx = this._layers.get(layer);
|
||||||
|
if (ctx) {
|
||||||
|
ctx.canvas.remove();
|
||||||
|
this._layers.delete(layer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeLayerFromDom(): void {
|
public hasLayer(layer: ImageLayer): boolean {
|
||||||
if (this.canvas) {
|
return this._layers.has(layer);
|
||||||
this._ctx = undefined;
|
|
||||||
this.canvas.remove();
|
|
||||||
this.canvas = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -359,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,7 @@
|
|||||||
|
|
||||||
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 } from './Types';
|
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types';
|
||||||
|
|
||||||
|
|
||||||
// fallback default cell size
|
// fallback default cell size
|
||||||
@@ -124,6 +124,8 @@ 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;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _terminal: ITerminalExt,
|
private _terminal: ITerminalExt,
|
||||||
@@ -132,8 +134,10 @@ export class ImageStorage implements IDisposable {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
this.setLimit(this._opts.storageLimit);
|
this.setLimit(this._opts.storageLimit);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
console.error(e.message);
|
if (e instanceof Error) {
|
||||||
|
console.error(e.message);
|
||||||
|
}
|
||||||
console.warn(`storageLimit is set to ${this.getLimit()} MB`);
|
console.warn(`storageLimit is set to ${this.getLimit()} MB`);
|
||||||
}
|
}
|
||||||
this._viewportMetrics = {
|
this._viewportMetrics = {
|
||||||
@@ -187,11 +191,13 @@ export class ImageStorage implements IDisposable {
|
|||||||
|
|
||||||
private _delImg(id: number): void {
|
private _delImg(id: number): void {
|
||||||
const spec = this._images.get(id);
|
const spec = this._images.get(id);
|
||||||
|
if (!spec) return;
|
||||||
this._images.delete(id);
|
this._images.delete(id);
|
||||||
// FIXME: really ugly workaround to get bitmaps deallocated :(
|
// FIXME: really ugly workaround to get bitmaps deallocated :(
|
||||||
if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) {
|
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {
|
||||||
spec.orig.close();
|
spec.orig.close();
|
||||||
}
|
}
|
||||||
|
this.onImageDeleted?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,27 +221,27 @@ export class ImageStorage implements IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only advance text cursor.
|
* Delete an image by its internal storage ID.
|
||||||
* This is an edge case from empty sixels carrying only a height but no pixels.
|
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
||||||
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
|
|
||||||
*/
|
*/
|
||||||
public advanceCursor(height: number): void {
|
public deleteImage(id: number): void {
|
||||||
if (this._opts.sixelScrolling) {
|
const spec = this._images.get(id);
|
||||||
let cellSize = this._renderer.cellSize;
|
if (spec) {
|
||||||
if (cellSize.width === -1 || cellSize.height === -1) {
|
spec.marker?.dispose();
|
||||||
cellSize = CELL_SIZE_DEFAULT;
|
this._delImg(id);
|
||||||
}
|
|
||||||
const rows = Math.ceil(height / cellSize.height);
|
|
||||||
for (let i = 1; i < rows; ++i) {
|
|
||||||
this._terminal._core._inputHandler.lineFeed();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 scrolling - When true, cursor advances with the image (lineFeed per row).
|
||||||
|
* When false, image is placed at (0,0) and cursor is restored (DECSET 80 / sixel origin mode).
|
||||||
|
* @param layer - Which canvas layer to render on ('top' or 'bottom').
|
||||||
|
* @param zIndex - Z-index for image layering within the same layer.
|
||||||
|
* @returns The internal image ID assigned to the stored image.
|
||||||
*/
|
*/
|
||||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
public addImage(img: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer = 'top', zIndex: number = 0): 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);
|
||||||
|
|
||||||
@@ -257,7 +263,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
let offset = originX;
|
let offset = originX;
|
||||||
let tileCount = 0;
|
let tileCount = 0;
|
||||||
|
|
||||||
if (!this._opts.sixelScrolling) {
|
if (!scrolling) {
|
||||||
buffer.x = 0;
|
buffer.x = 0;
|
||||||
buffer.y = 0;
|
buffer.y = 0;
|
||||||
offset = 0;
|
offset = 0;
|
||||||
@@ -271,7 +277,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 (this._opts.sixelScrolling) {
|
if (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;
|
||||||
@@ -281,7 +287,7 @@ 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 (this._opts.sixelScrolling) {
|
if (scrolling) {
|
||||||
buffer.x = offset;
|
buffer.x = offset;
|
||||||
} else {
|
} else {
|
||||||
buffer.x = originX;
|
buffer.x = originX;
|
||||||
@@ -324,11 +330,15 @@ export class ImageStorage implements IDisposable {
|
|||||||
actualCellSize: { ...cellSize }, // clone needed, since later modified
|
actualCellSize: { ...cellSize }, // clone needed, since later modified
|
||||||
marker: endMarker || undefined,
|
marker: endMarker || undefined,
|
||||||
tileCount,
|
tileCount,
|
||||||
bufferType: this._terminal.buffer.active.type
|
bufferType: this._terminal.buffer.active.type,
|
||||||
|
layer,
|
||||||
|
zIndex
|
||||||
};
|
};
|
||||||
|
|
||||||
// finally add the image
|
// finally add the image
|
||||||
this._images.set(imageId, imgSpec);
|
this._images.set(imageId, imgSpec);
|
||||||
|
this.onImageAdded?.();
|
||||||
|
return imageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -338,16 +348,30 @@ export class ImageStorage implements IDisposable {
|
|||||||
*/
|
*/
|
||||||
// TODO: Should we move this to the ImageRenderer?
|
// TODO: Should we move this to the ImageRenderer?
|
||||||
public render(range: { start: number, end: number }): void {
|
public render(range: { start: number, end: number }): void {
|
||||||
// setup image canvas in case we have none yet, but have images in store
|
// Determine which layers have images
|
||||||
if (!this._renderer.canvas && this._images.size) {
|
let hasTopImages = false;
|
||||||
this._renderer.insertLayerToDom();
|
let hasBottomImages = false;
|
||||||
// safety measure - in case we cannot spawn a canvas at all, just exit
|
for (const spec of this._images.values()) {
|
||||||
if (!this._renderer.canvas) {
|
if (spec.layer === 'bottom') {
|
||||||
return;
|
hasBottomImages = true;
|
||||||
|
} else {
|
||||||
|
hasTopImages = true;
|
||||||
}
|
}
|
||||||
|
if (hasTopImages && hasBottomImages) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazily insert layers that are needed
|
||||||
|
if (hasTopImages && !this._renderer.hasLayer('top')) {
|
||||||
|
this._renderer.insertLayerToDom('top');
|
||||||
|
if (!this._renderer.hasLayer('top')) return;
|
||||||
|
}
|
||||||
|
if (hasBottomImages && !this._renderer.hasLayer('bottom')) {
|
||||||
|
this._renderer.insertLayerToDom('bottom');
|
||||||
|
}
|
||||||
|
|
||||||
// rescale if needed
|
// rescale if needed
|
||||||
this._renderer.rescaleCanvas();
|
this._renderer.rescaleCanvas();
|
||||||
|
|
||||||
// exit early if we dont have any images to test for
|
// exit early if we dont have any images to test for
|
||||||
if (!this._images.size) {
|
if (!this._images.size) {
|
||||||
if (!this._fullyCleared) {
|
if (!this._fullyCleared) {
|
||||||
@@ -355,12 +379,25 @@ export class ImageStorage implements IDisposable {
|
|||||||
this._fullyCleared = true;
|
this._fullyCleared = true;
|
||||||
this._needsFullClear = false;
|
this._needsFullClear = false;
|
||||||
}
|
}
|
||||||
if (this._renderer.canvas) {
|
if (this._renderer.hasLayer('top')) {
|
||||||
this._renderer.removeLayerFromDom();
|
this._renderer.removeLayerFromDom('top');
|
||||||
|
}
|
||||||
|
if (this._renderer.hasLayer('bottom')) {
|
||||||
|
this._renderer.removeLayerFromDom('bottom');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove layers no longer needed
|
||||||
|
if (!hasTopImages && this._renderer.hasLayer('top')) {
|
||||||
|
this._renderer.clearAll('top');
|
||||||
|
this._renderer.removeLayerFromDom('top');
|
||||||
|
}
|
||||||
|
if (!hasBottomImages && this._renderer.hasLayer('bottom')) {
|
||||||
|
this._renderer.clearAll('bottom');
|
||||||
|
this._renderer.removeLayerFromDom('bottom');
|
||||||
|
}
|
||||||
|
|
||||||
// buffer switches force a full clear
|
// buffer switches force a full clear
|
||||||
if (this._needsFullClear) {
|
if (this._needsFullClear) {
|
||||||
this._renderer.clearAll();
|
this._renderer.clearAll();
|
||||||
@@ -375,50 +412,77 @@ export class ImageStorage implements IDisposable {
|
|||||||
// clear drawing area
|
// clear drawing area
|
||||||
this._renderer.clearLines(start, end);
|
this._renderer.clearLines(start, end);
|
||||||
|
|
||||||
// walk all cells in viewport and draw tiles found
|
// Collect draw calls so we can sort by z-index (lower z drawn first).
|
||||||
|
const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];
|
||||||
|
const placeholderCalls: { col: number, row: number, count: number }[] = [];
|
||||||
|
|
||||||
|
// walk all cells in viewport and collect tiles found
|
||||||
|
// Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag)
|
||||||
|
// because text writes clear the BG flag but leave image tile data intact.
|
||||||
|
// This lets top-layer images survive text overwrites (kitty C=1 behavior).
|
||||||
for (let row = start; row <= end; ++row) {
|
for (let row = start; row <= end; ++row) {
|
||||||
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
|
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
for (let col = 0; col < cols; ++col) {
|
for (let col = 0; col < cols; ++col) {
|
||||||
|
let e: IExtendedAttrsImage;
|
||||||
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
|
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
|
||||||
let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS;
|
e = line._extendedAttrs[col] ?? EMPTY_ATTRS;
|
||||||
const imageId = e.imageId;
|
} else {
|
||||||
if (imageId === undefined || imageId === -1) {
|
const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
|
||||||
|
if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const imgSpec = this._images.get(imageId);
|
e = maybeImg;
|
||||||
if (e.tileId !== -1) {
|
}
|
||||||
const startTile = e.tileId;
|
const imageId = e.imageId;
|
||||||
const startCol = col;
|
if (imageId === undefined || imageId === -1) {
|
||||||
let count = 1;
|
continue;
|
||||||
/**
|
}
|
||||||
* merge tiles to the right into a single draw call, if:
|
const imgSpec = this._images.get(imageId);
|
||||||
* - not at end of line
|
if (e.tileId !== -1) {
|
||||||
* - cell has same image id
|
const startTile = e.tileId;
|
||||||
* - cell has consecutive tile id
|
const startCol = col;
|
||||||
*/
|
let count = 1;
|
||||||
while (
|
/**
|
||||||
++col < cols
|
* merge tiles to the right into a single draw call, if:
|
||||||
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
|
* - not at end of line
|
||||||
&& (e = line._extendedAttrs[col] || EMPTY_ATTRS)
|
* - cell has same image id
|
||||||
&& (e.imageId === imageId)
|
* - cell has consecutive tile id
|
||||||
&& (e.tileId === startTile + count)
|
* Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED.
|
||||||
) {
|
*/
|
||||||
count++;
|
while (++col < cols) {
|
||||||
|
const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
|
||||||
|
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
col--;
|
e = nextE;
|
||||||
if (imgSpec) {
|
count++;
|
||||||
if (imgSpec.actual) {
|
|
||||||
this._renderer.draw(imgSpec, startTile, startCol, row, count);
|
|
||||||
}
|
|
||||||
} else if (this._opts.showPlaceholder) {
|
|
||||||
this._renderer.drawPlaceholder(startCol, row, count);
|
|
||||||
}
|
|
||||||
this._fullyCleared = false;
|
|
||||||
}
|
}
|
||||||
|
col--;
|
||||||
|
if (imgSpec) {
|
||||||
|
if (imgSpec.actual) {
|
||||||
|
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
|
||||||
|
}
|
||||||
|
} else if (this._opts.showPlaceholder) {
|
||||||
|
placeholderCalls.push({ col: startCol, row, count });
|
||||||
|
}
|
||||||
|
this._fullyCleared = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by z-index so lower z draws first (higher z renders on top)
|
||||||
|
drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);
|
||||||
|
|
||||||
|
// Draw placeholders first (lowest priority)
|
||||||
|
for (const call of placeholderCalls) {
|
||||||
|
this._renderer.drawPlaceholder(call.col, call.row, call.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw images in z-index order
|
||||||
|
for (const call of drawCalls) {
|
||||||
|
this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public viewportResize(metrics: { cols: number, rows: number }): void {
|
public viewportResize(metrics: { cols: number, rows: number }): void {
|
||||||
@@ -442,7 +506,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
for (let row = 0; row < rows; ++row) {
|
for (let row = 0; row < rows; ++row) {
|
||||||
const line = buffer.lines.get(row) as IBufferLineExt;
|
const line = buffer.lines.get(row) as IBufferLineExt;
|
||||||
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
|
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
|
||||||
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] || EMPTY_ATTRS;
|
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;
|
||||||
const imageId = e.imageId;
|
const imageId = e.imageId;
|
||||||
if (imageId === undefined || imageId === -1) {
|
if (imageId === undefined || imageId === -1) {
|
||||||
continue;
|
continue;
|
||||||
@@ -487,7 +551,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
const buffer = this._terminal._core.buffer;
|
const buffer = this._terminal._core.buffer;
|
||||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
||||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
|
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
|
||||||
if (e.imageId && e.imageId !== -1) {
|
if (e.imageId && e.imageId !== -1) {
|
||||||
const orig = this._images.get(e.imageId)?.orig;
|
const orig = this._images.get(e.imageId)?.orig;
|
||||||
if (window.ImageBitmap && orig instanceof ImageBitmap) {
|
if (window.ImageBitmap && orig instanceof ImageBitmap) {
|
||||||
@@ -507,7 +571,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
const buffer = this._terminal._core.buffer;
|
const buffer = this._terminal._core.buffer;
|
||||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
||||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
|
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
|
||||||
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
|
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
|
||||||
const spec = this._images.get(e.imageId);
|
const spec = this._images.get(e.imageId);
|
||||||
if (spec) {
|
if (spec) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ImageStorage } from './ImageStorage';
|
import { SixelImageStorage } from './SixelImageStorage';
|
||||||
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
|
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
|
||||||
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
|
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
|
||||||
import { RGBA8888 } from 'sixel/lib/Types';
|
import { RGBA8888 } from 'sixel/lib/Types';
|
||||||
@@ -26,7 +26,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _opts: IImageAddonOptions,
|
private readonly _opts: IImageAddonOptions,
|
||||||
private readonly _storage: ImageStorage,
|
private readonly _storage: SixelImageStorage,
|
||||||
private readonly _coreTerminal: ITerminalExt
|
private readonly _coreTerminal: ITerminalExt
|
||||||
) {
|
) {
|
||||||
DecoderAsync({
|
DecoderAsync({
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||||
|
import { IImageAddonOptions, ITerminalExt } from './Types';
|
||||||
|
import { ImageRenderer } from './ImageRenderer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sixel-specific image storage controller.
|
||||||
|
*
|
||||||
|
* Wraps the shared ImageStorage with sixel protocol semantics:
|
||||||
|
* - Cursor behavior governed by DECSET 80 (sixelScrolling option)
|
||||||
|
* - advanceCursor for empty sixels carrying only height
|
||||||
|
*/
|
||||||
|
export class SixelImageStorage {
|
||||||
|
constructor(
|
||||||
|
private readonly _storage: ImageStorage,
|
||||||
|
private readonly _opts: IImageAddonOptions,
|
||||||
|
private readonly _renderer: ImageRenderer,
|
||||||
|
private readonly _terminal: ITerminalExt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a sixel image to storage.
|
||||||
|
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
|
||||||
|
*/
|
||||||
|
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||||
|
this._storage.addImage(img, this._opts.sixelScrolling);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only advance text cursor.
|
||||||
|
* This is an edge case from empty sixels carrying only a height but no pixels.
|
||||||
|
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
|
||||||
|
*/
|
||||||
|
public advanceCursor(height: number): void {
|
||||||
|
if (this._opts.sixelScrolling) {
|
||||||
|
let cellSize = this._renderer.cellSize;
|
||||||
|
if (cellSize.width === -1 || cellSize.height === -1) {
|
||||||
|
cellSize = CELL_SIZE_DEFAULT;
|
||||||
|
}
|
||||||
|
const rows = Math.ceil(height / cellSize.height);
|
||||||
|
for (let i = 1; i < rows; ++i) {
|
||||||
|
this._terminal._core._inputHandler.lineFeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
|
|||||||
// private imports from base repo we build against
|
// private imports from base repo we build against
|
||||||
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, 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 { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/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';
|
||||||
@@ -22,7 +22,7 @@ export const enum Cell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// export some privates for local usage
|
// export some privates for local usage
|
||||||
export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin ctor options.
|
* Plugin ctor options.
|
||||||
@@ -38,6 +38,8 @@ export interface IImageAddonOptions {
|
|||||||
sixelSizeLimit: number;
|
sixelSizeLimit: number;
|
||||||
iipSupport: boolean;
|
iipSupport: boolean;
|
||||||
iipSizeLimit: number;
|
iipSizeLimit: number;
|
||||||
|
kittySupport: boolean;
|
||||||
|
kittySizeLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IResetHandler {
|
export interface IResetHandler {
|
||||||
@@ -97,6 +99,8 @@ export interface ICellSize {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageLayer = 'top' | 'bottom';
|
||||||
|
|
||||||
export interface IImageSpec {
|
export interface IImageSpec {
|
||||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||||
origCellSize: ICellSize;
|
origCellSize: ICellSize;
|
||||||
@@ -105,4 +109,6 @@ export interface IImageSpec {
|
|||||||
marker: IMarker | undefined;
|
marker: IMarker | undefined;
|
||||||
tileCount: number;
|
tileCount: number;
|
||||||
bufferType: 'alternate' | 'normal';
|
bufferType: 'alternate' | 'normal';
|
||||||
|
layer: ImageLayer;
|
||||||
|
zIndex: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,819 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IDisposable } from '@xterm/xterm';
|
||||||
|
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
|
||||||
|
import { ImageRenderer } from '../ImageRenderer';
|
||||||
|
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
|
||||||
|
import { KittyImageStorage } from './KittyImageStorage';
|
||||||
|
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||||
|
import {
|
||||||
|
KittyAction,
|
||||||
|
KittyFormat,
|
||||||
|
KittyCompression,
|
||||||
|
IKittyCommand,
|
||||||
|
IPendingTransmission,
|
||||||
|
IKittyImageData,
|
||||||
|
KittyPixelConstants,
|
||||||
|
parseKittyCommand
|
||||||
|
} from './KittyGraphicsTypes';
|
||||||
|
|
||||||
|
const enum Constants {
|
||||||
|
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
|
||||||
|
|
||||||
|
// Kitty graphics protocol handler with streaming base64 decoding.
|
||||||
|
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
|
||||||
|
private _aborted = false;
|
||||||
|
private _decodeError = false;
|
||||||
|
|
||||||
|
private _activeDecoder: Base64Decoder | null = null;
|
||||||
|
private readonly _maxEncodedBytes: number;
|
||||||
|
private readonly _initialEncodedBytes: number;
|
||||||
|
|
||||||
|
// Streaming related states
|
||||||
|
|
||||||
|
// True while receiving control data (before semicolon).
|
||||||
|
private _inControlData = true;
|
||||||
|
|
||||||
|
// Buffer for control data.
|
||||||
|
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
|
||||||
|
private _controlLength = 0;
|
||||||
|
|
||||||
|
// Pre-calculated encoded size limit
|
||||||
|
private _encodedSizeLimit = 0;
|
||||||
|
private _totalEncodedSize = 0;
|
||||||
|
|
||||||
|
// Parsed command. These are the control data before semicolon.
|
||||||
|
private _parsedCommand: IKittyCommand | null = null;
|
||||||
|
|
||||||
|
// Storage related states
|
||||||
|
|
||||||
|
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
|
||||||
|
// Tracks the pending key of the most recently started chunked upload.
|
||||||
|
// Per spec, subsequent chunks only need m= (and optionally q=), without i=.
|
||||||
|
// When a chunk arrives with no i=, this key is used to find the pending upload.
|
||||||
|
private _lastPendingKey: number | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly _opts: IImageAddonOptions,
|
||||||
|
private readonly _renderer: ImageRenderer,
|
||||||
|
private readonly _kittyStorage: KittyImageStorage,
|
||||||
|
private readonly _coreTerminal: ITerminalExt
|
||||||
|
) {
|
||||||
|
// Convert decoded size limit -> max encoded bytes.
|
||||||
|
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
||||||
|
// ensure we preallocate more than configured limit while using 4mb initial size.
|
||||||
|
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this._cleanupAllPending();
|
||||||
|
if (this._activeDecoder) {
|
||||||
|
this._activeDecoder.release();
|
||||||
|
this._activeDecoder = null;
|
||||||
|
}
|
||||||
|
this._kittyStorage.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removePendingEntry(key: number): void {
|
||||||
|
this._pendingTransmissions.delete(key);
|
||||||
|
if (this._lastPendingKey === key) {
|
||||||
|
this._lastPendingKey = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cleanupAllPending(): void {
|
||||||
|
for (const pending of this._pendingTransmissions.values()) {
|
||||||
|
pending.decoder.release();
|
||||||
|
}
|
||||||
|
this._pendingTransmissions.clear();
|
||||||
|
this._lastPendingKey = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
this._aborted = false;
|
||||||
|
this._decodeError = false;
|
||||||
|
this._inControlData = true;
|
||||||
|
this._controlLength = 0;
|
||||||
|
this._parsedCommand = null;
|
||||||
|
// Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
|
||||||
|
this._encodedSizeLimit = this._maxEncodedBytes;
|
||||||
|
this._totalEncodedSize = 0;
|
||||||
|
this._activeDecoder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public put(data: Uint32Array, start: number, end: number): void {
|
||||||
|
if (this._aborted) return;
|
||||||
|
|
||||||
|
if (!this._inControlData) {
|
||||||
|
this._streamPayload(data, start, end);
|
||||||
|
} else {
|
||||||
|
// Scan for semicolon
|
||||||
|
let controlEnd = end;
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
if (data[i] === Constants.SEMICOLON) {
|
||||||
|
this._inControlData = false;
|
||||||
|
controlEnd = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy control data
|
||||||
|
const copyLength = controlEnd - start;
|
||||||
|
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
|
||||||
|
this._aborted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
|
||||||
|
this._controlLength += copyLength;
|
||||||
|
|
||||||
|
if (!this._inControlData) {
|
||||||
|
// Found semicolon - parse control data early for validation
|
||||||
|
this._parsedCommand = parseKittyCommand(this._parseControlDataString());
|
||||||
|
|
||||||
|
// Early validation: i+I conflict
|
||||||
|
if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
|
||||||
|
this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
|
||||||
|
this._aborted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete action doesn't need payload - skip streaming
|
||||||
|
if (this._parsedCommand.action === KittyAction.DELETE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream remaining as payload
|
||||||
|
const payloadStart = controlEnd + 1;
|
||||||
|
if (payloadStart < end) {
|
||||||
|
this._streamPayload(data, payloadStart, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream payload bytes into the base64 decoder.
|
||||||
|
private _streamPayload(data: Uint32Array, start: number, end: number): void {
|
||||||
|
if (this._aborted) return;
|
||||||
|
|
||||||
|
// Check size limit (compare encoded bytes against pre-calculated limit)
|
||||||
|
// Include cumulative size from pending transmission for multi-chunk images.
|
||||||
|
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
|
||||||
|
const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;
|
||||||
|
const pending = this._pendingTransmissions.get(pendingKey);
|
||||||
|
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
|
||||||
|
this._totalEncodedSize += end - start;
|
||||||
|
const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
|
||||||
|
if (cumulativeEncodedSize > this._encodedSizeLimit) {
|
||||||
|
const decoderToRelease = this._activeDecoder ?? pending?.decoder;
|
||||||
|
if (decoderToRelease) {
|
||||||
|
decoderToRelease.release();
|
||||||
|
}
|
||||||
|
this._activeDecoder = null;
|
||||||
|
if (pending) {
|
||||||
|
this._removePendingEntry(pendingKey);
|
||||||
|
}
|
||||||
|
this._aborted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._decodeError) return;
|
||||||
|
|
||||||
|
if (pending?.decoder && !this._activeDecoder) {
|
||||||
|
this._activeDecoder = pending.decoder;
|
||||||
|
}
|
||||||
|
if (!this._activeDecoder) {
|
||||||
|
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||||
|
this._activeDecoder.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
|
||||||
|
this._activeDecoder.release();
|
||||||
|
this._activeDecoder = null;
|
||||||
|
this._decodeError = true;
|
||||||
|
if (pending) {
|
||||||
|
this._removePendingEntry(pendingKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public end(success: boolean): boolean | Promise<boolean> {
|
||||||
|
if (this._aborted || !success) {
|
||||||
|
if (this._activeDecoder) {
|
||||||
|
this._activeDecoder.release();
|
||||||
|
this._activeDecoder = null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No semicolon = no payload (delete, capability query)
|
||||||
|
if (this._inControlData) {
|
||||||
|
return this._handleNoPayloadCommand();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use command parsed early in put() - i+I already validated there
|
||||||
|
const cmd = this._parsedCommand!;
|
||||||
|
|
||||||
|
// Delete action was handled by skipping payload - just execute
|
||||||
|
if (cmd.action === KittyAction.DELETE) {
|
||||||
|
return this._handleDelete(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
|
||||||
|
const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;
|
||||||
|
const isMoreComing = cmd.more === 1;
|
||||||
|
const pending = this._pendingTransmissions.get(pendingKey);
|
||||||
|
|
||||||
|
if (isMoreComing) {
|
||||||
|
if (this._activeDecoder) {
|
||||||
|
if (pending) {
|
||||||
|
pending.totalEncodedSize += this._totalEncodedSize;
|
||||||
|
pending.decodeError = pending.decodeError || this._decodeError;
|
||||||
|
} else {
|
||||||
|
this._pendingTransmissions.set(pendingKey, {
|
||||||
|
cmd: { ...cmd },
|
||||||
|
decoder: this._activeDecoder,
|
||||||
|
totalEncodedSize: this._totalEncodedSize,
|
||||||
|
decodeError: this._decodeError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._lastPendingKey = pendingKey;
|
||||||
|
this._activeDecoder = null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final chunk received — clear the last pending key
|
||||||
|
if (pending) {
|
||||||
|
this._lastPendingKey = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decodeError = this._decodeError;
|
||||||
|
let finalCmd = cmd;
|
||||||
|
let decoder = this._activeDecoder;
|
||||||
|
|
||||||
|
if (pending) {
|
||||||
|
finalCmd = pending.cmd;
|
||||||
|
decoder = pending.decoder;
|
||||||
|
decodeError = decodeError || pending.decodeError;
|
||||||
|
this._pendingTransmissions.delete(pendingKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageBytes = new Uint8Array(0);
|
||||||
|
if (decoder) {
|
||||||
|
if (decoder.end() !== DECODER_OK) {
|
||||||
|
decodeError = true;
|
||||||
|
}
|
||||||
|
imageBytes = decoder.data8;
|
||||||
|
}
|
||||||
|
this._activeDecoder = null;
|
||||||
|
|
||||||
|
// Handle command first — handlers create Blob/ImageData from imageBytes,
|
||||||
|
// which copies the data. Only then is it safe to release the decoder's
|
||||||
|
// wasm memory that imageBytes points into.
|
||||||
|
const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
|
||||||
|
if (decoder) {
|
||||||
|
decoder.release();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command handling
|
||||||
|
|
||||||
|
private _parseControlDataString(): string {
|
||||||
|
let str = '';
|
||||||
|
for (let i = 0; i < this._controlLength; i++) {
|
||||||
|
str += String.fromCodePoint(this._controlData[i]);
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleNoPayloadCommand(): boolean | Promise<boolean> {
|
||||||
|
const cmd = parseKittyCommand(this._parseControlDataString());
|
||||||
|
|
||||||
|
// Per spec: specifying both i and I is an error
|
||||||
|
if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
|
||||||
|
this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = cmd.action ?? 't';
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case KittyAction.DELETE:
|
||||||
|
return this._handleDelete(cmd);
|
||||||
|
case KittyAction.QUERY:
|
||||||
|
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
|
||||||
|
return true;
|
||||||
|
case KittyAction.PLACEMENT:
|
||||||
|
return this._handlePlacement(cmd);
|
||||||
|
default:
|
||||||
|
// TODO: Implement remaining actions when needed:
|
||||||
|
// - a=f (frame): animation frame operations
|
||||||
|
// - a=a (animation): animation control
|
||||||
|
// - a=c (compose): compose images
|
||||||
|
if (cmd.id !== undefined) {
|
||||||
|
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
||||||
|
const action = cmd.action ?? 't';
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case KittyAction.TRANSMIT: {
|
||||||
|
const result = this._handleTransmit(cmd, bytes, decodeError);
|
||||||
|
// Only send response when _handleTransmit didn't already respond
|
||||||
|
// (it handles unsupported transmission medium responses internally)
|
||||||
|
if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {
|
||||||
|
if (decodeError) {
|
||||||
|
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
|
||||||
|
} else if (bytes.length > 0) {
|
||||||
|
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case KittyAction.TRANSMIT_DISPLAY:
|
||||||
|
return this._handleTransmitDisplay(cmd, bytes, decodeError);
|
||||||
|
case KittyAction.QUERY:
|
||||||
|
return this._handleQuery(cmd, bytes, decodeError);
|
||||||
|
case KittyAction.PLACEMENT:
|
||||||
|
// a=p ignores any payload — image data was already transmitted
|
||||||
|
return this._handlePlacement(cmd);
|
||||||
|
default:
|
||||||
|
// TODO: Implement remaining actions when needed:
|
||||||
|
// - a=f (frame): animation frame operations
|
||||||
|
// - a=a (animation): animation control
|
||||||
|
// - a=c (compose): compose images
|
||||||
|
if (cmd.id !== undefined) {
|
||||||
|
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handlePlacement(cmd: IKittyCommand): boolean | Promise<boolean> {
|
||||||
|
if (cmd.id === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const id = cmd.id;
|
||||||
|
const image = this._kittyStorage.getImage(id);
|
||||||
|
if (!image) {
|
||||||
|
this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const result = this._displayImage(image, cmd);
|
||||||
|
return result.then(success => {
|
||||||
|
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
||||||
|
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
|
||||||
|
// Currently only supports direct transmission (t=d, the default).
|
||||||
|
// - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
|
||||||
|
// - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
|
||||||
|
// - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
|
||||||
|
// These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
|
||||||
|
// 1. Check cmd.transmission (t key) before treating bytes as image data
|
||||||
|
// 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
|
||||||
|
// 3. For t=d: treat bytes as image data (current behavior)
|
||||||
|
// When implementing, also update _handleQuery to accept these transmission mediums.
|
||||||
|
const transmission = cmd.transmission ?? 'd';
|
||||||
|
if (transmission !== 'd') {
|
||||||
|
if (cmd.id !== undefined) {
|
||||||
|
this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decodeError || bytes.length === 0) return true;
|
||||||
|
|
||||||
|
this._kittyStorage.storeImage(cmd.id, {
|
||||||
|
data: new Blob([bytes as BlobPart]),
|
||||||
|
width: cmd.width ?? 0,
|
||||||
|
height: cmd.height ?? 0,
|
||||||
|
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
|
||||||
|
compression: cmd.compression ?? ''
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
||||||
|
if (decodeError) {
|
||||||
|
if (cmd.id !== undefined) {
|
||||||
|
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._handleTransmit(cmd, bytes, decodeError);
|
||||||
|
|
||||||
|
const id = cmd.id ?? this._kittyStorage.lastImageId;
|
||||||
|
const image = this._kittyStorage.getImage(id);
|
||||||
|
if (image) {
|
||||||
|
const result = this._displayImage(image, cmd);
|
||||||
|
if (cmd.id !== undefined) {
|
||||||
|
return result.then(success => {
|
||||||
|
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.then(() => true);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
||||||
|
const id = cmd.id ?? 0;
|
||||||
|
const quiet = cmd.quiet ?? 0;
|
||||||
|
|
||||||
|
// Per spec: reject unsupported transmission mediums (only t=d is supported atm)
|
||||||
|
// TODO: When filesystem support is added (Node.js/Electron), update this to accept
|
||||||
|
// t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.
|
||||||
|
const transmission = cmd.transmission ?? 'd';
|
||||||
|
if (transmission !== 'd') {
|
||||||
|
this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check decode error first (invalid base64)
|
||||||
|
if (decodeError) {
|
||||||
|
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability query (no payload) - just respond OK
|
||||||
|
if (bytes.length === 0) {
|
||||||
|
this._sendResponse(id, 'OK', quiet);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = cmd.format ?? KittyFormat.RGBA;
|
||||||
|
|
||||||
|
if (format === KittyFormat.PNG) {
|
||||||
|
this._sendResponse(id, 'OK', quiet);
|
||||||
|
} else {
|
||||||
|
const width = cmd.width ?? 0;
|
||||||
|
const height = cmd.height ?? 0;
|
||||||
|
|
||||||
|
if (!width || !height) {
|
||||||
|
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
|
const expectedBytes = width * height * bytesPerPixel;
|
||||||
|
|
||||||
|
if (bytes.length < expectedBytes) {
|
||||||
|
this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._sendResponse(id, 'OK', quiet);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleDelete(cmd: IKittyCommand): boolean {
|
||||||
|
// Per spec: default delete selector is 'a' (delete all visible placements)
|
||||||
|
const selector = cmd.deleteSelector ?? 'a';
|
||||||
|
|
||||||
|
// TODO: Distinguish lowercase (delete placements only) from uppercase
|
||||||
|
// (delete placements + free stored image data). Currently both variants
|
||||||
|
// free everything since we don't separate stored data from placements.
|
||||||
|
switch (selector) {
|
||||||
|
case 'a':
|
||||||
|
case 'A':
|
||||||
|
this._cleanupAllPending();
|
||||||
|
this._kittyStorage.deleteAll();
|
||||||
|
break;
|
||||||
|
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) {
|
||||||
|
const pending = this._pendingTransmissions.get(cmd.id);
|
||||||
|
if (pending) {
|
||||||
|
pending.decoder.release();
|
||||||
|
}
|
||||||
|
this._removePendingEntry(cmd.id);
|
||||||
|
this._kittyStorage.deleteById(cmd.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
|
||||||
|
const isOk = message === 'OK';
|
||||||
|
if (isOk && quiet >= 1) return;
|
||||||
|
if (!isOk && quiet >= 2) return;
|
||||||
|
|
||||||
|
const pPart = placementId ? `,p=${placementId}` : '';
|
||||||
|
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
|
||||||
|
this._coreTerminal._core.coreService.triggerDataEvent(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image display
|
||||||
|
|
||||||
|
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
|
||||||
|
return this._decodeAndDisplay(image, cmd)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||||
|
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cropX = Math.max(0, cmd.x ?? 0);
|
||||||
|
const cropY = Math.max(0, cmd.y ?? 0);
|
||||||
|
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
|
||||||
|
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
|
||||||
|
|
||||||
|
const maxCropW = Math.max(0, bitmap.width - cropX);
|
||||||
|
const maxCropH = Math.max(0, bitmap.height - cropY);
|
||||||
|
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
|
||||||
|
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
|
||||||
|
|
||||||
|
if (finalCropW === 0 || finalCropH === 0) {
|
||||||
|
throw new Error('invalid source rectangle');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
|
||||||
|
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
|
||||||
|
bitmap.close();
|
||||||
|
bitmap = cropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||||
|
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
let imgCols: number;
|
||||||
|
let imgRows: number;
|
||||||
|
if (cmd.columns !== undefined && cmd.rows !== undefined) {
|
||||||
|
imgCols = cmd.columns;
|
||||||
|
imgRows = cmd.rows;
|
||||||
|
} else if (cmd.columns !== undefined) {
|
||||||
|
imgCols = cmd.columns;
|
||||||
|
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
|
||||||
|
} else if (cmd.rows !== undefined) {
|
||||||
|
imgRows = cmd.rows;
|
||||||
|
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
|
||||||
|
} else {
|
||||||
|
imgCols = Math.ceil(bitmap.width / cw);
|
||||||
|
imgRows = Math.ceil(bitmap.height / ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let w = bitmap.width;
|
||||||
|
let h = bitmap.height;
|
||||||
|
|
||||||
|
// Scale bitmap to fit placement rectangle when c/r are specified
|
||||||
|
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
||||||
|
w = Math.round(imgCols * cw);
|
||||||
|
h = Math.round(imgRows * ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (w * h > this._opts.pixelLimit) {
|
||||||
|
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';
|
||||||
|
|
||||||
|
if (w !== bitmap.width || h !== bitmap.height) {
|
||||||
|
const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||||
|
bitmap.close();
|
||||||
|
bitmap = scaled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ImageBitmap from already-decoded image data.
|
||||||
|
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
|
||||||
|
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
|
||||||
|
|
||||||
|
if (image.compression === KittyCompression.ZLIB) {
|
||||||
|
bytes = await this._decompressZlib(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.format === KittyFormat.PNG) {
|
||||||
|
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
|
||||||
|
if (!window.createImageBitmap) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const img = new Image();
|
||||||
|
return new Promise<ImageBitmap>((resolve, reject) => {
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
|
||||||
|
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
||||||
|
createImageBitmap(canvas).then(resolve).catch(reject);
|
||||||
|
});
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
});
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw pixel data
|
||||||
|
const width = image.width;
|
||||||
|
const height = image.height;
|
||||||
|
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error('Width and height required for raw pixel data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
|
const expectedBytes = width * height * bytesPerPixel;
|
||||||
|
|
||||||
|
if (bytes.length < expectedBytes) {
|
||||||
|
throw new Error('Insufficient pixel data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixelCount = width * height;
|
||||||
|
|
||||||
|
if (image.format === KittyFormat.RGBA) {
|
||||||
|
// RGBA: use bytes directly — no copy needed
|
||||||
|
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).
|
||||||
|
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
|
||||||
|
// Assumes little-endian (all modern browsers/Node.js).
|
||||||
|
const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
|
||||||
|
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
||||||
|
const dst32 = new Uint32Array(data.buffer);
|
||||||
|
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
|
||||||
|
|
||||||
|
let srcOffset = 0;
|
||||||
|
let dstOffset = 0;
|
||||||
|
for (let i = 0; i < alignedPixels; i += 4) {
|
||||||
|
const b0 = src32[srcOffset++];
|
||||||
|
const b1 = src32[srcOffset++];
|
||||||
|
const b2 = src32[srcOffset++];
|
||||||
|
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
|
||||||
|
dst32[dstOffset++] = 0xFF000000 | b0;
|
||||||
|
dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
|
||||||
|
dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
|
||||||
|
dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle remaining 1–3 pixels
|
||||||
|
let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
|
let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
|
||||||
|
for (let i = alignedPixels; i < pixelCount; i++) {
|
||||||
|
data[dstByte] = bytes[srcByte];
|
||||||
|
data[dstByte + 1] = bytes[srcByte + 1];
|
||||||
|
data[dstByte + 2] = bytes[srcByte + 2];
|
||||||
|
data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
|
||||||
|
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
|
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createImageBitmap(new ImageData(data, width, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
|
||||||
|
try {
|
||||||
|
return await this._decompress(compressed, 'deflate');
|
||||||
|
} catch {
|
||||||
|
return await this._decompress(compressed, 'deflate-raw');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
|
||||||
|
const ds = new DecompressionStream(format);
|
||||||
|
const writer = ds.writable.getWriter();
|
||||||
|
writer.write(compressed as BufferSource);
|
||||||
|
writer.close();
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
const reader = ds.readable.getReader();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const result = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
result.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get images(): ReadonlyMap<number, IKittyImageData> {
|
||||||
|
return this._kittyStorage.images;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
|
||||||
|
return this._kittyStorage.kittyIdToStorageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
|
||||||
|
return this._pendingTransmissions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { parseKittyCommand, KittyAction, KittyFormat } from './KittyGraphicsTypes';
|
||||||
|
|
||||||
|
describe('KittyGraphicsTypes', () => {
|
||||||
|
describe('parseKittyCommand', () => {
|
||||||
|
it('should parse control data with action and format', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100');
|
||||||
|
assert.strictEqual(cmd.action, 'T');
|
||||||
|
assert.strictEqual(cmd.format, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse control data with all options', () => {
|
||||||
|
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
|
||||||
|
assert.strictEqual(cmd.action, 't');
|
||||||
|
assert.strictEqual(cmd.format, 32);
|
||||||
|
assert.strictEqual(cmd.id, 5);
|
||||||
|
assert.strictEqual(cmd.width, 10);
|
||||||
|
assert.strictEqual(cmd.height, 20);
|
||||||
|
assert.strictEqual(cmd.columns, 3);
|
||||||
|
assert.strictEqual(cmd.rows, 2);
|
||||||
|
assert.strictEqual(cmd.more, 1);
|
||||||
|
assert.strictEqual(cmd.quiet, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty control data', () => {
|
||||||
|
const cmd = parseKittyCommand('');
|
||||||
|
assert.strictEqual(cmd.action, undefined);
|
||||||
|
assert.strictEqual(cmd.format, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse transmit action', () => {
|
||||||
|
const cmd = parseKittyCommand('a=t,f=100');
|
||||||
|
assert.strictEqual(cmd.action, KittyAction.TRANSMIT);
|
||||||
|
assert.strictEqual(cmd.format, KittyFormat.PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse delete action', () => {
|
||||||
|
const cmd = parseKittyCommand('a=d,i=5');
|
||||||
|
assert.strictEqual(cmd.action, KittyAction.DELETE);
|
||||||
|
assert.strictEqual(cmd.id, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse empty action as empty string', () => {
|
||||||
|
const cmd = parseKittyCommand('a=,f=100');
|
||||||
|
assert.strictEqual(cmd.action, '');
|
||||||
|
assert.strictEqual(cmd.format, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave action undefined when key is not present', () => {
|
||||||
|
const cmd = parseKittyCommand('f=100,i=5');
|
||||||
|
assert.strictEqual(cmd.action, undefined);
|
||||||
|
assert.strictEqual(cmd.format, 100);
|
||||||
|
assert.strictEqual(cmd.id, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse compression key', () => {
|
||||||
|
const cmd = parseKittyCommand('a=t,f=32,o=z');
|
||||||
|
assert.strictEqual(cmd.action, 't');
|
||||||
|
assert.strictEqual(cmd.format, 32);
|
||||||
|
assert.strictEqual(cmd.compression, 'z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse cursor movement key', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100,C=1');
|
||||||
|
assert.strictEqual(cmd.cursorMovement, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse cursor movement key C=0', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100,C=0');
|
||||||
|
assert.strictEqual(cmd.cursorMovement, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse x and y offset', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,x=10,y=20');
|
||||||
|
assert.strictEqual(cmd.x, 10);
|
||||||
|
assert.strictEqual(cmd.y, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle keys without values', () => {
|
||||||
|
const cmd = parseKittyCommand('a=t,f=,i=5');
|
||||||
|
assert.strictEqual(cmd.action, 't');
|
||||||
|
assert.ok(isNaN(cmd.format!));
|
||||||
|
assert.strictEqual(cmd.id, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse z-index key with positive value', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100,z=10');
|
||||||
|
assert.strictEqual(cmd.zIndex, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse z-index key with zero', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100,z=0');
|
||||||
|
assert.strictEqual(cmd.zIndex, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse z-index key with negative value', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100,z=-1');
|
||||||
|
assert.strictEqual(cmd.zIndex, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave zIndex undefined when not specified', () => {
|
||||||
|
const cmd = parseKittyCommand('a=T,f=100');
|
||||||
|
assert.strictEqual(cmd.zIndex, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse delete selector key', () => {
|
||||||
|
const cmd = parseKittyCommand('a=d,d=i,i=5');
|
||||||
|
assert.strictEqual(cmd.action, 'd');
|
||||||
|
assert.strictEqual(cmd.deleteSelector, 'i');
|
||||||
|
assert.strictEqual(cmd.id, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse uppercase delete selector', () => {
|
||||||
|
const cmd = parseKittyCommand('a=d,d=A');
|
||||||
|
assert.strictEqual(cmd.deleteSelector, 'A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse delete selector d=a (all)', () => {
|
||||||
|
const cmd = parseKittyCommand('a=d,d=a');
|
||||||
|
assert.strictEqual(cmd.deleteSelector, 'a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave deleteSelector undefined when not specified', () => {
|
||||||
|
const cmd = parseKittyCommand('a=d,i=5');
|
||||||
|
assert.strictEqual(cmd.deleteSelector, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse placement id key', () => {
|
||||||
|
const cmd = parseKittyCommand('a=d,d=i,i=5,p=3');
|
||||||
|
assert.strictEqual(cmd.placementId, 3);
|
||||||
|
assert.strictEqual(cmd.deleteSelector, 'i');
|
||||||
|
assert.strictEqual(cmd.id, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave placementId undefined when not specified', () => {
|
||||||
|
const cmd = parseKittyCommand('a=d,d=i,i=5');
|
||||||
|
assert.strictEqual(cmd.placementId, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse image number key', () => {
|
||||||
|
const cmd = parseKittyCommand('a=t,f=100,I=42');
|
||||||
|
assert.strictEqual(cmd.imageNumber, 42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Kitty graphics protocol types, constants, and parsing utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||||
|
|
||||||
|
// Kitty graphics protocol action types.
|
||||||
|
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
|
||||||
|
export const enum KittyAction {
|
||||||
|
TRANSMIT = 't',
|
||||||
|
TRANSMIT_DISPLAY = 'T',
|
||||||
|
QUERY = 'q',
|
||||||
|
PLACEMENT = 'p',
|
||||||
|
DELETE = 'd'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kitty graphics protocol format types.
|
||||||
|
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||||
|
export const enum KittyFormat {
|
||||||
|
RGB = 24,
|
||||||
|
RGBA = 32,
|
||||||
|
PNG = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kitty graphics protocol compression types.
|
||||||
|
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
|
||||||
|
export const enum KittyCompression {
|
||||||
|
NONE = '',
|
||||||
|
ZLIB = 'z'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kitty graphics protocol control data keys.
|
||||||
|
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||||
|
export const enum KittyKey {
|
||||||
|
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
|
||||||
|
ACTION = 'a',
|
||||||
|
// Image format (24=RGB, 32=RGBA, 100=PNG)
|
||||||
|
FORMAT = 'f',
|
||||||
|
// Image ID for referencing stored images
|
||||||
|
ID = 'i',
|
||||||
|
// Image number (alternative to ID, terminal assigns ID)
|
||||||
|
IMAGE_NUMBER = 'I',
|
||||||
|
// Source image width in pixels
|
||||||
|
WIDTH = 's',
|
||||||
|
// Source image height in pixels
|
||||||
|
HEIGHT = 'v',
|
||||||
|
// The left edge (in pixels) of the image area to display
|
||||||
|
X_OFFSET = 'x',
|
||||||
|
// The top edge (in pixels) of the image area to display
|
||||||
|
Y_OFFSET = 'y',
|
||||||
|
// Width (in pixels) of the source rectangle to display
|
||||||
|
SOURCE_WIDTH = 'w',
|
||||||
|
// Height (in pixels) of the source rectangle to display
|
||||||
|
SOURCE_HEIGHT = 'h',
|
||||||
|
// Horizontal offset (in pixels) within the first cell
|
||||||
|
X_PLACEMENT_OFFSET = 'X',
|
||||||
|
// Vertical offset (in pixels) within the first cell
|
||||||
|
Y_PLACEMENT_OFFSET = 'Y',
|
||||||
|
// Number of terminal columns to display the image over
|
||||||
|
COLUMNS = 'c',
|
||||||
|
// Number of terminal rows to display the image over
|
||||||
|
ROWS = 'r',
|
||||||
|
// More data flag (1=more chunks coming, 0=final chunk)
|
||||||
|
MORE = 'm',
|
||||||
|
// Compression type (z=zlib). This is essential for chunking larger images.
|
||||||
|
COMPRESSION = 'o',
|
||||||
|
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
|
||||||
|
QUIET = 'q',
|
||||||
|
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
|
||||||
|
CURSOR_MOVEMENT = 'C',
|
||||||
|
// Z-index for image layering (negative = behind text, 0+ = on top)
|
||||||
|
Z_INDEX = 'z',
|
||||||
|
// Transmission medium (d=direct, f=file, t=temp file, s=shared memory)
|
||||||
|
TRANSMISSION = 't',
|
||||||
|
// Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d
|
||||||
|
DELETE_SELECTOR = 'd',
|
||||||
|
// Placement ID for targeting specific placements
|
||||||
|
PLACEMENT_ID = 'p'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pixel format constants
|
||||||
|
export const enum KittyPixelConstants {
|
||||||
|
BYTES_PER_PIXEL_RGB = 3,
|
||||||
|
BYTES_PER_PIXEL_RGBA = 4,
|
||||||
|
ALPHA_OPAQUE = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsed Kitty graphics command.
|
||||||
|
export interface IKittyCommand {
|
||||||
|
action?: string;
|
||||||
|
format?: number;
|
||||||
|
id?: number;
|
||||||
|
imageNumber?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
sourceWidth?: number;
|
||||||
|
sourceHeight?: number;
|
||||||
|
xOffset?: number;
|
||||||
|
yOffset?: number;
|
||||||
|
columns?: number;
|
||||||
|
rows?: number;
|
||||||
|
more?: number;
|
||||||
|
quiet?: number;
|
||||||
|
cursorMovement?: number;
|
||||||
|
zIndex?: number;
|
||||||
|
transmission?: string;
|
||||||
|
deleteSelector?: string;
|
||||||
|
placementId?: number;
|
||||||
|
compression?: string;
|
||||||
|
payload?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending chunked transmission state.
|
||||||
|
// Stores metadata from the first chunk while accumulating decoded payload data.
|
||||||
|
export interface IPendingTransmission {
|
||||||
|
// The parsed command from the first chunk (contains action, format, dimensions, etc.)
|
||||||
|
cmd: IKittyCommand;
|
||||||
|
// Decoder used across chunked payloads
|
||||||
|
decoder: Base64Decoder;
|
||||||
|
// Total encoded (base64) bytes received across all chunks - for size limit enforcement
|
||||||
|
totalEncodedSize: number;
|
||||||
|
// Whether any chunk has failed to decode
|
||||||
|
decodeError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored Kitty image data.
|
||||||
|
export interface IKittyImageData {
|
||||||
|
id: number;
|
||||||
|
// Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit
|
||||||
|
data: Blob;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
format: 24 | 32 | 100;
|
||||||
|
compression?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses Kitty graphics control data into a command object.
|
||||||
|
export function parseKittyCommand(data: string): IKittyCommand {
|
||||||
|
const cmd: IKittyCommand = {};
|
||||||
|
const parts = data.split(',');
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const eqIdx = part.indexOf('=');
|
||||||
|
if (eqIdx === -1) continue;
|
||||||
|
|
||||||
|
const key = part.substring(0, eqIdx);
|
||||||
|
const value = part.substring(eqIdx + 1);
|
||||||
|
|
||||||
|
// Handle string keys first
|
||||||
|
if (key === KittyKey.ACTION) {
|
||||||
|
cmd.action = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === KittyKey.COMPRESSION) {
|
||||||
|
cmd.compression = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === KittyKey.TRANSMISSION) {
|
||||||
|
cmd.transmission = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === KittyKey.DELETE_SELECTOR) {
|
||||||
|
cmd.deleteSelector = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const numValue = parseInt(value);
|
||||||
|
switch (key) {
|
||||||
|
case KittyKey.FORMAT: cmd.format = numValue; break;
|
||||||
|
case KittyKey.ID: cmd.id = numValue; break;
|
||||||
|
case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;
|
||||||
|
case KittyKey.WIDTH: cmd.width = numValue; break;
|
||||||
|
case KittyKey.HEIGHT: cmd.height = numValue; break;
|
||||||
|
case KittyKey.X_OFFSET: cmd.x = numValue; break;
|
||||||
|
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
|
||||||
|
case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;
|
||||||
|
case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;
|
||||||
|
case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;
|
||||||
|
case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;
|
||||||
|
case KittyKey.COLUMNS: cmd.columns = numValue; break;
|
||||||
|
case KittyKey.ROWS: cmd.rows = numValue; break;
|
||||||
|
case KittyKey.MORE: cmd.more = numValue; break;
|
||||||
|
case KittyKey.QUIET: cmd.quiet = numValue; break;
|
||||||
|
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
|
||||||
|
case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;
|
||||||
|
case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IDisposable } from '@xterm/xterm';
|
||||||
|
import { ImageStorage } from '../ImageStorage';
|
||||||
|
import { ImageLayer } from '../Types';
|
||||||
|
import { IKittyImageData } from './KittyGraphicsTypes';
|
||||||
|
|
||||||
|
// Kitty-specific image storage controller.
|
||||||
|
//
|
||||||
|
// Wraps shared ImageStorage with kitty protocol semantics:
|
||||||
|
// - tracks transmitted image payloads by kitty image id
|
||||||
|
// - tracks kitty image id -> shared ImageStorage id mapping for displayed images
|
||||||
|
// - mirrors shared-storage evictions into kitty maps
|
||||||
|
// - applies protocol-level undisplayed-image eviction policy
|
||||||
|
export class KittyImageStorage implements IDisposable {
|
||||||
|
private static readonly _maxStoredImages = 256;
|
||||||
|
|
||||||
|
private _nextImageId = 1;
|
||||||
|
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 _storageIdToKittyId: Map<number, number> = new Map();
|
||||||
|
|
||||||
|
private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;
|
||||||
|
private readonly _wrappedOnImageDeleted: (storageId: number) => void;
|
||||||
|
private readonly _handleStorageImageDeleted = (storageId: number): void => {
|
||||||
|
const kittyId = this._storageIdToKittyId.get(storageId);
|
||||||
|
if (kittyId !== undefined) {
|
||||||
|
this._kittyIdToStorageId.delete(kittyId);
|
||||||
|
this._storageIdToKittyId.delete(storageId);
|
||||||
|
this._images.delete(kittyId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly _storage: ImageStorage
|
||||||
|
) {
|
||||||
|
this._previousOnImageDeleted = this._storage.onImageDeleted;
|
||||||
|
this._wrappedOnImageDeleted = (storageId: number) => {
|
||||||
|
this._previousOnImageDeleted?.(storageId);
|
||||||
|
this._handleStorageImageDeleted(storageId);
|
||||||
|
};
|
||||||
|
this._storage.onImageDeleted = this._wrappedOnImageDeleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this._nextImageId = 1;
|
||||||
|
this._images.clear();
|
||||||
|
this._kittyIdToStorageId.clear();
|
||||||
|
this._storageIdToKittyId.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
this.reset();
|
||||||
|
if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {
|
||||||
|
this._storage.onImageDeleted = this._previousOnImageDeleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>): number {
|
||||||
|
const imageId = id ?? this._nextImageId++;
|
||||||
|
|
||||||
|
const oldStorageId = this._kittyIdToStorageId.get(imageId);
|
||||||
|
if (oldStorageId !== undefined) {
|
||||||
|
this._storage.deleteImage(oldStorageId);
|
||||||
|
this._kittyIdToStorageId.delete(imageId);
|
||||||
|
this._storageIdToKittyId.delete(oldStorageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {
|
||||||
|
this._evictUndisplayedImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._images.set(imageId, {
|
||||||
|
...imageData,
|
||||||
|
id: imageId
|
||||||
|
});
|
||||||
|
return imageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {
|
||||||
|
// Clean up stale reverse-mapping from a previous placement of the same
|
||||||
|
// kitty image. The old shared-storage entry is kept (it may still be
|
||||||
|
// visible on screen) but its reverse mapping is removed so that eviction
|
||||||
|
// of the old entry won't incorrectly delete the kitty image data.
|
||||||
|
const oldStorageId = this._kittyIdToStorageId.get(kittyId);
|
||||||
|
if (oldStorageId !== undefined) {
|
||||||
|
this._storageIdToKittyId.delete(oldStorageId);
|
||||||
|
}
|
||||||
|
const storageId = this._storage.addImage(image, scrolling, layer, zIndex);
|
||||||
|
this._kittyIdToStorageId.set(kittyId, storageId);
|
||||||
|
this._storageIdToKittyId.set(storageId, kittyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getImage(kittyId: number): IKittyImageData | undefined {
|
||||||
|
return this._images.get(kittyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteById(kittyId: number): void {
|
||||||
|
this._images.delete(kittyId);
|
||||||
|
const storageId = this._kittyIdToStorageId.get(kittyId);
|
||||||
|
if (storageId !== undefined) {
|
||||||
|
this._storage.deleteImage(storageId);
|
||||||
|
this._kittyIdToStorageId.delete(kittyId);
|
||||||
|
this._storageIdToKittyId.delete(storageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public deleteAll(): void {
|
||||||
|
this._images.clear();
|
||||||
|
for (const storageId of this._kittyIdToStorageId.values()) {
|
||||||
|
this._storage.deleteImage(storageId);
|
||||||
|
}
|
||||||
|
this._kittyIdToStorageId.clear();
|
||||||
|
this._storageIdToKittyId.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get images(): ReadonlyMap<number, IKittyImageData> {
|
||||||
|
return this._images;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get kittyIdToStorageId(): ReadonlyMap<number, number> {
|
||||||
|
return this._kittyIdToStorageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get lastImageId(): number {
|
||||||
|
return this._nextImageId - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _evictUndisplayedImages(): void {
|
||||||
|
for (const [kittyId] of this._images) {
|
||||||
|
if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!this._kittyIdToStorageId.has(kittyId)) {
|
||||||
|
this._images.delete(kittyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,11 @@
|
|||||||
"types": [
|
"types": [
|
||||||
"../../../node_modules/@types/mocha"
|
"../../../node_modules/@types/mocha"
|
||||||
],
|
],
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"browser/*": [ "../../../src/browser/*" ],
|
"browser/*": [ "../../../src/browser/*" ],
|
||||||
"common/*": [ "../../../src/common/*" ],
|
"common/*": [ "../../../src/common/*" ],
|
||||||
"vs/*": [ "../../../src/vs/*" ],
|
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ],
|
||||||
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
|
"*": [ "./*" ]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
],
|
],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../../src/browser" },
|
{ "path": "../../../src/browser" },
|
||||||
{ "path": "../../../src/common" },
|
{ "path": "../../../src/common" }
|
||||||
{ "path": "../../../src/vs" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -23,6 +24,8 @@ export interface IImageAddonOptions {
|
|||||||
sixelSizeLimit: number;
|
sixelSizeLimit: number;
|
||||||
iipSupport: boolean;
|
iipSupport: boolean;
|
||||||
iipSizeLimit: number;
|
iipSizeLimit: number;
|
||||||
|
kittySupport: boolean;
|
||||||
|
kittySizeLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
@@ -72,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 }) => {
|
||||||
@@ -83,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();
|
||||||
@@ -130,11 +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,
|
||||||
|
kittySizeLimit: 33554432
|
||||||
};
|
};
|
||||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||||
});
|
});
|
||||||
@@ -149,7 +153,9 @@ test.describe('ImageAddon', () => {
|
|||||||
storageLimit: 10,
|
storageLimit: 10,
|
||||||
showPlaceholder: false,
|
showPlaceholder: false,
|
||||||
iipSupport: false,
|
iipSupport: false,
|
||||||
iipSizeLimit: 1000
|
iipSizeLimit: 1000,
|
||||||
|
kittySupport: false,
|
||||||
|
kittySizeLimit: 1000
|
||||||
};
|
};
|
||||||
await ctx.page.evaluate(opts => {
|
await ctx.page.evaluate(opts => {
|
||||||
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
||||||
@@ -190,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);
|
||||||
@@ -294,6 +312,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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,3 +407,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')`);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
],
|
],
|
||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
+15
-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 {
|
||||||
@@ -76,6 +76,15 @@ declare module '@xterm/addon-image' {
|
|||||||
iipSupport?: boolean;
|
iipSupport?: boolean;
|
||||||
/** IIP sequence size limit (default 20000000 bytes). */
|
/** IIP sequence size limit (default 20000000 bytes). */
|
||||||
iipSizeLimit?: number;
|
iipSizeLimit?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kitty graphics protocol settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Whether Kitty graphics protocol is enabled (default is true). */
|
||||||
|
kittySupport?: boolean;
|
||||||
|
/** Kitty image size limit in bytes (default 20000000 bytes). */
|
||||||
|
kittySizeLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageAddon implements ITerminalAddon {
|
export class ImageAddon implements ITerminalAddon {
|
||||||
@@ -107,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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
"node": ">8.0.0"
|
"node": ">8.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p src",
|
"build": "tsgo -p src",
|
||||||
"watch": "tsc -w -p src",
|
"watch": "tsgo -w -p src",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"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",
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lru-cache": "^5.1.0",
|
"@types/lru-cache": "^5.1.0",
|
||||||
"@types/opentype.js": "^0.7.0",
|
"@types/opentype.js": "^0.7.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.15.2",
|
||||||
"font-finder": "^1.1.0",
|
"font-finder": "^1.1.0",
|
||||||
"mkdirp": "0.5.5",
|
"mkdirp": "0.5.5",
|
||||||
"yauzl": "^2.10.0"
|
"yauzl": "^3.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface ITerminalAddon {
|
|||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LigaturesAddon implements ITerminalAddon , ILigaturesApi {
|
export class LigaturesAddon implements ITerminalAddon, ILigaturesApi {
|
||||||
private readonly _fallbackLigatures: string[];
|
private readonly _fallbackLigatures: string[];
|
||||||
private readonly _fontFeatureSettings?: string;
|
private readonly _fontFeatureSettings?: string;
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export class LigaturesAddon implements ITerminalAddon , ILigaturesApi {
|
|||||||
|
|
||||||
constructor(options?: Partial<ILigatureOptions>) {
|
constructor(options?: Partial<ILigatureOptions>) {
|
||||||
// Source: calt set from https://github.com/be5invis/Iosevka?tab=readme-ov-file#ligations
|
// Source: calt set from https://github.com/be5invis/Iosevka?tab=readme-ov-file#ligations
|
||||||
this._fallbackLigatures = (options?.fallbackLigatures || [
|
this._fallbackLigatures = (options?.fallbackLigatures ?? [
|
||||||
'<--', '<---', '<<-', '<-', '->', '->>', '-->', '--->',
|
'<--', '<---', '<<-', '<-', '->', '->>', '-->', '--->',
|
||||||
'<==', '<===', '<<=', '<=', '=>', '=>>', '==>', '===>', '>=', '>>=',
|
'<==', '<===', '<<=', '<=', '=>', '=>>', '==>', '===>', '>=', '>>=',
|
||||||
'<->', '<-->', '<--->', '<---->', '<=>', '<==>', '<===>', '<====>', '::', ':::',
|
'<->', '<-->', '<--->', '<---->', '<=>', '<==>', '<===>', '<====>', '::', ':::',
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
|
|||||||
if (status && status.state !== 'granted') {
|
if (status && status.state !== 'granted') {
|
||||||
throw new Error('Permission to access local fonts not granted.');
|
throw new Error('Permission to access local fonts not granted.');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
// A `TypeError` indicates the 'local-fonts'
|
// A `TypeError` indicates the 'local-fonts'
|
||||||
// permission is not yet implemented, so
|
// permission is not yet implemented, so
|
||||||
// only `throw` if this is _not_ the problem.
|
// only `throw` if this is _not_ the problem.
|
||||||
if (err.name !== 'TypeError') {
|
if (err instanceof Error && err.name !== 'TypeError') {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,8 +60,10 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
|
|||||||
fonts[metadata.family].push(metadata);
|
fonts[metadata.family].push(metadata);
|
||||||
}
|
}
|
||||||
fontsPromise = Promise.resolve(fonts);
|
fontsPromise = Promise.resolve(fonts);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error(err.name, err.message);
|
if (err instanceof Error) {
|
||||||
|
console.error(err.name, err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Latest proposal https://bugs.chromium.org/p/chromium/issues/detail?id=1312603
|
// Latest proposal https://bugs.chromium.org/p/chromium/issues/detail?id=1312603
|
||||||
@@ -76,13 +78,13 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
|
|||||||
fonts[metadata.family].push(metadata);
|
fonts[metadata.family].push(metadata);
|
||||||
}
|
}
|
||||||
fontsPromise = Promise.resolve(fonts);
|
fontsPromise = Promise.resolve(fonts);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error(err.name, err.message);
|
if (err instanceof Error) {
|
||||||
|
console.error(err.name, err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!fontsPromise) {
|
fontsPromise ??= Promise.resolve({});
|
||||||
fontsPromise = Promise.resolve({});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fonts = await fontsPromise;
|
const fonts = await fontsPromise;
|
||||||
|
|||||||
@@ -52,12 +52,10 @@ export function processLookaheadPosition(
|
|||||||
}
|
}
|
||||||
processedEntries.add(currentEntry.entry);
|
processedEntries.add(currentEntry.entry);
|
||||||
|
|
||||||
if (!currentEntry.entry.forward) {
|
currentEntry.entry.forward ??= {
|
||||||
currentEntry.entry.forward = {
|
individual: {},
|
||||||
individual: {},
|
range: []
|
||||||
range: []
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// All glyphs at this position share ONE entry - lookahead just needs to match,
|
// All glyphs at this position share ONE entry - lookahead just needs to match,
|
||||||
// all paths lead to the same result
|
// all paths lead to the same result
|
||||||
@@ -97,12 +95,10 @@ export function processBacktrackPosition(
|
|||||||
}
|
}
|
||||||
processedEntries.add(currentEntry.entry);
|
processedEntries.add(currentEntry.entry);
|
||||||
|
|
||||||
if (!currentEntry.entry.reverse) {
|
currentEntry.entry.reverse ??= {
|
||||||
currentEntry.entry.reverse = {
|
individual: {},
|
||||||
individual: {},
|
range: []
|
||||||
range: []
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// All glyphs at this position share ONE entry - backtrack just needs to match,
|
// All glyphs at this position share ONE entry - backtrack just needs to match,
|
||||||
// all paths lead to the same result
|
// all paths lead to the same result
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ export function getIndividualSubstitutionGlyph(table: SubstitutionTable, glyphId
|
|||||||
return (glyphId + table.deltaGlyphId) % (2 ** 16);
|
return (glyphId + table.deltaGlyphId) % (2 ** 16);
|
||||||
// https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#12-single-substitution-format-2
|
// https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#12-single-substitution-format-2
|
||||||
case 2:
|
case 2:
|
||||||
// eslint-disable-next-line eqeqeq
|
return table.substitute[coverageIndex] ?? null;
|
||||||
return table.substitute[coverageIndex] != null
|
|
||||||
? table.substitute[coverageIndex]
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function enableLigatures(term: Terminal, fallbackLigatures: string[] = []
|
|||||||
let currentFontName: string | undefined = undefined;
|
let currentFontName: string | undefined = undefined;
|
||||||
let font: Font | undefined = undefined;
|
let font: Font | undefined = undefined;
|
||||||
let loadingState: LoadingState = LoadingState.UNLOADED;
|
let loadingState: LoadingState = LoadingState.UNLOADED;
|
||||||
let loadError: any | undefined = undefined;
|
let loadError: unknown = undefined;
|
||||||
|
|
||||||
return term.registerCharacterJoiner((text: string): [number, number][] => {
|
return term.registerCharacterJoiner((text: string): [number, number][] => {
|
||||||
// If the font hasn't been loaded yet, load it and return an empty result
|
// If the font hasn't been loaded yet, load it and return an empty result
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
"outDir": "../out-esbuild-test",
|
"outDir": "../out-esbuild-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"types": [
|
"types": [
|
||||||
"../../../node_modules/@types/mocha",
|
"../../../node_modules/@types/mocha",
|
||||||
"../../../node_modules/@types/node"
|
"../../../node_modules/@types/node"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "../../node_modules/.bin/tsc -p .",
|
"build": "../../node_modules/.bin/tsgo -p .",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
|
import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
|
||||||
import type { ProgressAddon as IProgressApi, IProgressState } from '@xterm/addon-progress';
|
import type { ProgressAddon as IProgressApi, IProgressState } from '@xterm/addon-progress';
|
||||||
import type { Emitter, Event } from 'vs/base/common/event';
|
import type { Emitter, IEvent } from 'common/Event';
|
||||||
|
|
||||||
|
|
||||||
const enum ProgressType {
|
const enum ProgressType {
|
||||||
@@ -39,7 +39,7 @@ export class ProgressAddon implements ITerminalAddon, IProgressApi {
|
|||||||
private _pr = 0;
|
private _pr = 0;
|
||||||
// HACK: This uses ! to align with the API, this should be fixed when 5283 is resolved
|
// HACK: This uses ! to align with the API, this should be fixed when 5283 is resolved
|
||||||
private _onChange!: Emitter<IProgressState>;
|
private _onChange!: Emitter<IProgressState>;
|
||||||
public onChange!: Event<IProgressState>;
|
public onChange!: IEvent<IProgressState>;
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this._seqHandler?.dispose();
|
this._seqHandler?.dispose();
|
||||||
|
|||||||
@@ -15,12 +15,12 @@
|
|||||||
"../../../node_modules/@types/mocha"
|
"../../../node_modules/@types/mocha"
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"common/*": [
|
||||||
|
"../../../src/common/*"
|
||||||
|
],
|
||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
],
|
],
|
||||||
"vs/*": [
|
|
||||||
"../../../src/vs/*"
|
|
||||||
],
|
|
||||||
"@xterm/addon-progress": [
|
"@xterm/addon-progress": [
|
||||||
"../typings/addon-progress.d.ts"
|
"../typings/addon-progress.d.ts"
|
||||||
]
|
]
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"path": "../../../src/browser"
|
"path": "../../../src/browser"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../../src/vs"
|
"path": "../../../src/common"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
],
|
],
|
||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
lib
|
lib
|
||||||
node_modules
|
node_modules
|
||||||
|
out-benchmark
|
||||||
@@ -20,4 +20,13 @@ terminal.loadAddon(searchAddon);
|
|||||||
searchAddon.findNext('foo');
|
searchAddon.findNext('foo');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Search options
|
||||||
|
|
||||||
|
- `decorations` (`ISearchDecorationOptions`): Enables match highlighting and result tracking; pass a decoration options object (colors may be omitted).
|
||||||
|
- `highlightLimit` (constructor option): Caps how many matches are tracked/highlighted (default `1000`).
|
||||||
|
- `incremental`: Expands the current selection when the term still matches.
|
||||||
|
- `wholeWord`, `regex`, `caseSensitive`: Control match semantics.
|
||||||
|
|
||||||
|
`onDidChangeResults` fires after `findNext`/`findPrevious` when `decorations` is set. `clearDecorations()` clears highlights and tracked results but does not emit this event.
|
||||||
|
|
||||||
See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-search/typings/addon-search.d.ts) for more advanced usage.
|
See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-search/typings/addon-search.d.ts) for more advanced usage.
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { perfContext, before, RuntimeCase } from 'xterm-benchmark';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { Terminal } from 'browser/public/Terminal';
|
||||||
|
import { SearchAddon } from 'SearchAddon';
|
||||||
|
import type { ISearchOptions } from '@xterm/addon-search';
|
||||||
|
|
||||||
|
const enum Constants {
|
||||||
|
COLS = 120,
|
||||||
|
ROWS = 40,
|
||||||
|
SCROLLBACK = 8000,
|
||||||
|
NAVIGATION_ITERATIONS = 250,
|
||||||
|
COLD_CACHE_ITERATIONS = 75,
|
||||||
|
INCREMENTAL_ITERATIONS = 75,
|
||||||
|
DECORATION_REFRESH_ITERATIONS = 25
|
||||||
|
}
|
||||||
|
|
||||||
|
const DECORATION_OPTIONS: ISearchOptions = {
|
||||||
|
decorations: {
|
||||||
|
activeMatchBackground: '#0000ff',
|
||||||
|
activeMatchColorOverviewRuler: '#ff0000',
|
||||||
|
matchBackground: '#ffff00',
|
||||||
|
matchBorder: '#000000',
|
||||||
|
matchOverviewRuler: '#00ffff'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class TestTerminal extends Terminal {
|
||||||
|
public writeSync(data: string): void {
|
||||||
|
(this as any)._core.writeSync(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override select(_column: number, _row: number, _length: number): void {
|
||||||
|
// SearchAddon always selects matches; skip selection internals in headless benchmark runs.
|
||||||
|
}
|
||||||
|
|
||||||
|
public override clearSelection(): void {
|
||||||
|
// SearchAddon clears the previous selection before selecting another match.
|
||||||
|
}
|
||||||
|
|
||||||
|
public override scrollLines(_amount: number): void {
|
||||||
|
// No viewport scrolling is needed for API runtime measurement.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRealWorldBuffer(): string {
|
||||||
|
const fixture = readFileSync(resolve(__dirname, '../../fixtures/issue-2444'), 'utf8')
|
||||||
|
.replace(/\r?\n/g, '\r\n');
|
||||||
|
const lines: string[] = [fixture];
|
||||||
|
for (let i = 0; i < 2500; i++) {
|
||||||
|
const minute = (i % 60).toString().padStart(2, '0');
|
||||||
|
const second = ((i * 7) % 60).toString().padStart(2, '0');
|
||||||
|
lines.push(`2026-05-25T16:${minute}:${second}Z INFO [api-${i % 8}] requestId=req-${i.toString(16).padStart(6, '0')} path=/v1/projects/${i % 120}/search?q=opencv status=200 durationMs=${10 + (i % 90)}`);
|
||||||
|
if (i % 5 === 0) {
|
||||||
|
lines.push(`2026-05-25T16:${minute}:${second}Z WARN [worker-${i % 4}] slow query detected query="SELECT * FROM runs WHERE status='pending'" elapsedMs=${500 + (i % 1200)}`);
|
||||||
|
}
|
||||||
|
if (i % 11 === 0) {
|
||||||
|
lines.push(`2026-05-25T16:${minute}:${second}Z ERROR [worker-${i % 4}] command failed: package.json parse error at column ${10 + (i % 70)}; retrying in ${2 + (i % 7)}s`);
|
||||||
|
}
|
||||||
|
if (i % 17 === 0) {
|
||||||
|
lines.push(`https://example.internal/run/${i}/details?source=terminal-search&state=error`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
perfContext('SearchAddon API on real-world terminal content', () => {
|
||||||
|
const bufferContent = buildRealWorldBuffer();
|
||||||
|
|
||||||
|
perfContext('findNext/plain navigation', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon();
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||||
|
if (search.findNext('opencv')) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||||
|
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
perfContext('findNext/no match full scan', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon();
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||||
|
if (search.findNext('zzzznotfoundzzzz')) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCount !== 0) {
|
||||||
|
throw new Error(`Expected 0 matches, got ${foundCount}`);
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
perfContext('findNext/case insensitive navigation', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon();
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||||
|
if (search.findNext('OPENCV', { caseSensitive: false })) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||||
|
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
perfContext('findNext/regex navigation', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon();
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||||
|
if (search.findNext('https://example\\.internal/run/\\d+/details\\?source=terminal-search', { regex: true })) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||||
|
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
perfContext('findPrevious/incremental typing flow', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
const terms = ['e', 'er', 'err', 'erro', 'error'];
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon();
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.INCREMENTAL_ITERATIONS; i++) {
|
||||||
|
for (const term of terms) {
|
||||||
|
if (search.findPrevious(term, { incremental: true })) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.INCREMENTAL_ITERATIONS * terms.length, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
perfContext('findNext/cold cache', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon();
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.COLD_CACHE_ITERATIONS; i++) {
|
||||||
|
// Force a fresh logical-line reconstruction and match scan each search.
|
||||||
|
(search as any)._clearMatchCache();
|
||||||
|
if (search.findNext('opencv')) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCount !== Constants.COLD_CACHE_ITERATIONS) {
|
||||||
|
throw new Error(`Expected ${Constants.COLD_CACHE_ITERATIONS} matches, got ${foundCount}`);
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.COLD_CACHE_ITERATIONS, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
perfContext('findNext/wholeWord dense-punctuation', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon();
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||||
|
if (search.findNext('error', { wholeWord: true })) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||||
|
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
perfContext('findPrevious/regex wholeWord reverse', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon();
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.NAVIGATION_ITERATIONS; i++) {
|
||||||
|
if (search.findPrevious('error', { regex: true, wholeWord: true })) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCount !== Constants.NAVIGATION_ITERATIONS) {
|
||||||
|
throw new Error(`Expected ${Constants.NAVIGATION_ITERATIONS} matches, got ${foundCount}`);
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.NAVIGATION_ITERATIONS, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
perfContext('findNext/decorations refresh', () => {
|
||||||
|
let terminal: TestTerminal;
|
||||||
|
let search: SearchAddon;
|
||||||
|
before(() => {
|
||||||
|
terminal = new TestTerminal({ cols: Constants.COLS, rows: Constants.ROWS, scrollback: Constants.SCROLLBACK });
|
||||||
|
search = new SearchAddon({ highlightLimit: 1500 });
|
||||||
|
search.activate(terminal);
|
||||||
|
terminal.writeSync(bufferContent);
|
||||||
|
});
|
||||||
|
new RuntimeCase('', () => {
|
||||||
|
let foundCount = 0;
|
||||||
|
for (let i = 0; i < Constants.DECORATION_REFRESH_ITERATIONS; i++) {
|
||||||
|
const term = i % 2 === 0 ? 'WARN' : 'ERROR';
|
||||||
|
if (search.findNext(term, DECORATION_OPTIONS)) {
|
||||||
|
foundCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundCount !== Constants.DECORATION_REFRESH_ITERATIONS) {
|
||||||
|
throw new Error(`Expected ${Constants.DECORATION_REFRESH_ITERATIONS} matches, got ${foundCount}`);
|
||||||
|
}
|
||||||
|
return { payloadSize: Constants.DECORATION_REFRESH_ITERATIONS, foundCount };
|
||||||
|
}, { fork: false }).showAverageRuntime();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"APP_PATH": ".benchmark",
|
||||||
|
"evalConfig": {
|
||||||
|
"tolerance": {
|
||||||
|
"*": [0.9, 1.2],
|
||||||
|
"SearchAddon.benchmark.js.*.averageRuntime.mean": [0.93, 1.12]
|
||||||
|
},
|
||||||
|
"skip": [
|
||||||
|
"*.median",
|
||||||
|
"*.runs"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"es2021"
|
||||||
|
],
|
||||||
|
"rootDir": "..",
|
||||||
|
"outDir": "../out-benchmark",
|
||||||
|
"types": ["../../../node_modules/@types/node"],
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": false,
|
||||||
|
"target": "es2021",
|
||||||
|
"module": "commonjs",
|
||||||
|
"paths": {
|
||||||
|
"common/*": ["../../../src/common/*"],
|
||||||
|
"browser/*": ["../../../src/browser/*"],
|
||||||
|
"SearchAddon": ["../src/SearchAddon"],
|
||||||
|
"@xterm/addon-search": [
|
||||||
|
"../typings/addon-search.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["../**/*", "../../../typings/xterm.d.ts"],
|
||||||
|
"exclude": ["../../../**/*test.ts", "../../**/*api.ts"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../../../src/common" },
|
||||||
|
{ "path": "../../../src/browser" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -16,9 +16,12 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepackage": "../../node_modules/.bin/tsc -p .",
|
"prepackage": "../../node_modules/.bin/tsgo -p .",
|
||||||
"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-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
||||||
|
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 10 -c benchmark/benchmark.json --eval -F out-benchmark/benchmark/*benchmark.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
|
||||||
* @license MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
|
|
||||||
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
|
||||||
import { dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle';
|
|
||||||
import type { ISearchResult } from './SearchEngine';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for managing a highlight decoration.
|
|
||||||
*/
|
|
||||||
interface IHighlight extends IDisposable {
|
|
||||||
decoration: IDecoration;
|
|
||||||
match: ISearchResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for managing multiple decorations for a single match.
|
|
||||||
*/
|
|
||||||
interface IMultiHighlight extends IDisposable {
|
|
||||||
decorations: IDecoration[];
|
|
||||||
match: ISearchResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages visual decorations for search results including highlighting and active selection
|
|
||||||
* indicators. This class handles the creation, styling, and disposal of search-related decorations.
|
|
||||||
*/
|
|
||||||
export class DecorationManager extends Disposable {
|
|
||||||
private _highlightDecorations: IHighlight[] = [];
|
|
||||||
private _highlightedLines: Set<number> = new Set();
|
|
||||||
|
|
||||||
constructor(private readonly _terminal: Terminal) {
|
|
||||||
super();
|
|
||||||
this._register(toDisposable(() => this.clearHighlightDecorations()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates decorations for all provided search results.
|
|
||||||
* @param results The search results to create decorations for.
|
|
||||||
* @param options The decoration options.
|
|
||||||
*/
|
|
||||||
public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {
|
|
||||||
this.clearHighlightDecorations();
|
|
||||||
|
|
||||||
for (const match of results) {
|
|
||||||
const decorations = this._createResultDecorations(match, options, false);
|
|
||||||
if (decorations) {
|
|
||||||
for (const decoration of decorations) {
|
|
||||||
this._storeDecoration(decoration, match);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates decorations for the currently active search result.
|
|
||||||
* @param result The active search result.
|
|
||||||
* @param options The decoration options.
|
|
||||||
* @returns The multi-highlight decoration or undefined if creation failed.
|
|
||||||
*/
|
|
||||||
public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {
|
|
||||||
const decorations = this._createResultDecorations(result, options, true);
|
|
||||||
if (decorations) {
|
|
||||||
return { decorations, match: result, dispose() { dispose(decorations); } };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all highlight decorations.
|
|
||||||
*/
|
|
||||||
public clearHighlightDecorations(): void {
|
|
||||||
dispose(this._highlightDecorations);
|
|
||||||
this._highlightDecorations = [];
|
|
||||||
this._highlightedLines.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stores a decoration and tracks it for management.
|
|
||||||
* @param decoration The decoration to store.
|
|
||||||
* @param match The search result this decoration represents.
|
|
||||||
*/
|
|
||||||
private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {
|
|
||||||
this._highlightedLines.add(decoration.marker.line);
|
|
||||||
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies styles to the decoration when it is rendered.
|
|
||||||
* @param element The decoration's element.
|
|
||||||
* @param borderColor The border color to apply.
|
|
||||||
* @param isActiveResult Whether the element is part of the active search result.
|
|
||||||
*/
|
|
||||||
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
|
|
||||||
if (!element.classList.contains('xterm-find-result-decoration')) {
|
|
||||||
element.classList.add('xterm-find-result-decoration');
|
|
||||||
if (borderColor) {
|
|
||||||
element.style.outline = `1px solid ${borderColor}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isActiveResult) {
|
|
||||||
element.classList.add('xterm-find-active-result-decoration');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a decoration for the result and applies styles
|
|
||||||
* @param result the search result for which to create the decoration
|
|
||||||
* @param options the options for the decoration
|
|
||||||
* @param isActiveResult whether this is the currently active result
|
|
||||||
* @returns the decorations or undefined if the marker has already been disposed of
|
|
||||||
*/
|
|
||||||
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
|
|
||||||
// Gather decoration ranges for this match as it could wrap
|
|
||||||
const decorationRanges: [number, number, number][] = [];
|
|
||||||
let currentCol = result.col;
|
|
||||||
let remainingSize = result.size;
|
|
||||||
let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;
|
|
||||||
while (remainingSize > 0) {
|
|
||||||
const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);
|
|
||||||
decorationRanges.push([markerOffset, currentCol, amountThisRow]);
|
|
||||||
currentCol = 0;
|
|
||||||
remainingSize -= amountThisRow;
|
|
||||||
markerOffset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the decorations
|
|
||||||
const decorations: IDecoration[] = [];
|
|
||||||
for (const range of decorationRanges) {
|
|
||||||
const marker = this._terminal.registerMarker(range[0]);
|
|
||||||
const decoration = this._terminal.registerDecoration({
|
|
||||||
marker,
|
|
||||||
x: range[1],
|
|
||||||
width: range[2],
|
|
||||||
layer: isActiveResult ? 'top' : 'bottom',
|
|
||||||
backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,
|
|
||||||
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
|
|
||||||
color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,
|
|
||||||
position: 'center'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (decoration) {
|
|
||||||
const disposables: IDisposable[] = [];
|
|
||||||
disposables.push(marker);
|
|
||||||
disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));
|
|
||||||
disposables.push(decoration.onDispose(() => dispose(disposables)));
|
|
||||||
decorations.push(decoration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return decorations.length === 0 ? undefined : decorations;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
+1207
-208
File diff suppressed because it is too large
Load Diff
@@ -1,708 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
|
||||||
* @license MIT
|
|
||||||
*/
|
|
||||||
import { assert } from 'chai';
|
|
||||||
import { SearchEngine } from './SearchEngine';
|
|
||||||
import { SearchLineCache } from './SearchLineCache';
|
|
||||||
import { Terminal } from 'browser/public/Terminal';
|
|
||||||
import type { ISearchOptions } from '@xterm/addon-search';
|
|
||||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
|
||||||
|
|
||||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
|
||||||
return new Promise(r => terminal.write(data, r));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SearchEngine', () => {
|
|
||||||
let store: DisposableStore;
|
|
||||||
let terminal: Terminal;
|
|
||||||
let lineCache: SearchLineCache;
|
|
||||||
let searchEngine: SearchEngine;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new DisposableStore();
|
|
||||||
terminal = store.add(new Terminal({ cols: 80, rows: 24 }));
|
|
||||||
lineCache = store.add(new SearchLineCache(terminal));
|
|
||||||
searchEngine = new SearchEngine(terminal, lineCache);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
store.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('find', () => {
|
|
||||||
it('should return undefined for empty search term', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.find('', 0, 0), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find basic text in terminal content', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
|
||||||
term: 'World',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find text starting from specified position', async () => {
|
|
||||||
await writeP(terminal, 'Hello Hello Hello');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 7), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 12,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should search across multiple rows', async () => {
|
|
||||||
await writeP(terminal, 'Line 1\r\nLine 2 target\r\nLine 3');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
|
||||||
term: 'target',
|
|
||||||
col: 7,
|
|
||||||
row: 1,
|
|
||||||
size: 6
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined when text is not found', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.find('NotFound', 0, 0), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for invalid column position', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.throws(() => {
|
|
||||||
searchEngine.find('Hello', 0, 100);
|
|
||||||
}, /Invalid col: 100 to search in terminal of 80 cols/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle search starting from last column', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.find('Hello', 0, 79), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle search from middle of match', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.find('llo', 0, 3), undefined); // Should not find partial match that starts before search position
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('search options', () => {
|
|
||||||
describe('caseSensitive', () => {
|
|
||||||
it('should find text with case-insensitive search (default)', async () => {
|
|
||||||
await writeP(terminal, 'Hello WORLD');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0), {
|
|
||||||
term: 'world',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find text with case-sensitive search when enabled', async () => {
|
|
||||||
await writeP(terminal, 'Hello WORLD');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { caseSensitive: true }), {
|
|
||||||
term: 'WORLD',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not find text with case-sensitive search when case differs', async () => {
|
|
||||||
await writeP(terminal, 'Hello WORLD');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.find('world', 0, 0, { caseSensitive: true }), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('wholeWord', () => {
|
|
||||||
it('should find whole word when enabled', async () => {
|
|
||||||
await writeP(terminal, 'Hello world wonderful');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
|
||||||
term: 'world',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not find partial word when wholeWord is enabled', async () => {
|
|
||||||
await writeP(terminal, 'Hello wonderful');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find word at beginning of line with wholeWord', async () => {
|
|
||||||
await writeP(terminal, 'world is great');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
|
||||||
term: 'world',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find word at end of line with wholeWord', async () => {
|
|
||||||
await writeP(terminal, 'hello world');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
|
||||||
term: 'world',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle word boundaries with punctuation', async () => {
|
|
||||||
await writeP(terminal, 'hello,world!test');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
|
||||||
term: 'world',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not match when not whole word', async () => {
|
|
||||||
await writeP(terminal, 'helloworld');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('regex', () => {
|
|
||||||
it('should find text using simple regex pattern', async () => {
|
|
||||||
await writeP(terminal, 'Hello 123 World');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('[0-9]+', 0, 0, { regex: true }), {
|
|
||||||
term: '123',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 3
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find text using regex with case-insensitive flag', async () => {
|
|
||||||
await writeP(terminal, 'Hello WORLD');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { regex: true, caseSensitive: false }), {
|
|
||||||
term: 'WORLD',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find text using regex with case-sensitive flag', async () => {
|
|
||||||
await writeP(terminal, 'Hello WORLD world');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { regex: true, caseSensitive: true }), {
|
|
||||||
term: 'WORLD',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex regex patterns', async () => {
|
|
||||||
await writeP(terminal, 'Email: test@example.com and another@domain.org');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 0, 0, { regex: true }), {
|
|
||||||
term: 'test@example.com',
|
|
||||||
col: 7,
|
|
||||||
row: 0,
|
|
||||||
size: 16
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined for invalid regex pattern', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
// Invalid regex should be handled gracefully
|
|
||||||
assert.throws(() => {
|
|
||||||
searchEngine.find('[invalid', 0, 0, { regex: true });
|
|
||||||
}, /Invalid regular expression/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty regex matches', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.find('.*?', 0, 0, { regex: true }), undefined); // Empty matches should be ignored
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('combined options', () => {
|
|
||||||
it('should handle regex + caseSensitive combination', async () => {
|
|
||||||
await writeP(terminal, 'Hello WORLD world');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('[A-Z]+', 0, 0, { regex: true, caseSensitive: true }), {
|
|
||||||
term: 'H',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 1
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle wholeWord + caseSensitive combination', async () => {
|
|
||||||
await writeP(terminal, 'Hello WORLD wonderful');
|
|
||||||
|
|
||||||
const result1 = searchEngine.find('WORLD', 0, 0, { wholeWord: true, caseSensitive: true });
|
|
||||||
assert.deepStrictEqual(result1, {
|
|
||||||
term: 'WORLD',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
|
|
||||||
const result2 = searchEngine.find('world', 0, 0, { wholeWord: true, caseSensitive: true });
|
|
||||||
assert.strictEqual(result2, undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findNextWithSelection', () => {
|
|
||||||
it('should return undefined for empty search term', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.findNextWithSelection(''), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find first occurrence when no selection exists', async () => {
|
|
||||||
await writeP(terminal, 'Hello World Hello');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find next occurrence after current selection', async () => {
|
|
||||||
await writeP(terminal, 'Hello World Hello Again');
|
|
||||||
|
|
||||||
// Mock the getSelectionPosition to return a selection at first "Hello"
|
|
||||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 12,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should wrap around to beginning when reaching end', async () => {
|
|
||||||
await writeP(terminal, 'Hello World Hello');
|
|
||||||
|
|
||||||
// Mock selection at the end
|
|
||||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
}); // Should wrap to first occurrence
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should wrap across multiple rows', async () => {
|
|
||||||
await writeP(terminal, 'Line 1 test\r\nLine 2\r\nLine 3 test');
|
|
||||||
|
|
||||||
// Mock selection at first "test"
|
|
||||||
terminal.getSelectionPosition = () => ({ start: { x: 7, y: 0 }, end: { x: 11, y: 0 } });
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('test', undefined, 'test'), {
|
|
||||||
term: 'test',
|
|
||||||
col: 7,
|
|
||||||
row: 2,
|
|
||||||
size: 4
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return same selection if only one match exists', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
// Mock selection at "Hello"
|
|
||||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear selection and return undefined when term not found', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.findNextWithSelection('NotFound'), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findPreviousWithSelection', () => {
|
|
||||||
it('should return undefined for empty search term', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.findPreviousWithSelection(''), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find last occurrence when no selection exists', async () => {
|
|
||||||
await writeP(terminal, 'Hello World Hello');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.findPreviousWithSelection('Hello'), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 12,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find previous occurrence before current selection', async () => {
|
|
||||||
await writeP(terminal, 'Hello World Hello Again');
|
|
||||||
|
|
||||||
// Mock selection at second "Hello"
|
|
||||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
|
||||||
|
|
||||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
|
||||||
assert.notStrictEqual(result, undefined);
|
|
||||||
// It may find the same selection first due to expansion attempt
|
|
||||||
assert.strictEqual(typeof result!.col, 'number');
|
|
||||||
assert.strictEqual(result!.row, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should wrap around to end when reaching beginning', async () => {
|
|
||||||
await writeP(terminal, 'Hello World Hello');
|
|
||||||
|
|
||||||
// Mock selection at first "Hello"
|
|
||||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
|
||||||
|
|
||||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
|
||||||
assert.notStrictEqual(result, undefined);
|
|
||||||
// Due to the expansion attempt, it may find the same Hello first
|
|
||||||
assert.strictEqual(typeof result!.col, 'number');
|
|
||||||
assert.strictEqual(result!.row, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work across multiple rows in reverse', async () => {
|
|
||||||
await writeP(terminal, 'test Line 1\r\nLine 2\r\ntest Line 3');
|
|
||||||
|
|
||||||
// Mock selection at last "test"
|
|
||||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 2 }, end: { x: 4, y: 2 } });
|
|
||||||
|
|
||||||
const result = searchEngine.findPreviousWithSelection('test');
|
|
||||||
assert.notStrictEqual(result, undefined);
|
|
||||||
// The algorithm will find the current selection first due to expansion attempt
|
|
||||||
assert.strictEqual(typeof result!.row, 'number');
|
|
||||||
assert.strictEqual(typeof result!.col, 'number');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle selection expansion correctly', async () => {
|
|
||||||
await writeP(terminal, 'Hello World Hello');
|
|
||||||
|
|
||||||
// Mock selection at first "Hello"
|
|
||||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
|
||||||
|
|
||||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
|
||||||
assert.notStrictEqual(result, undefined);
|
|
||||||
// The algorithm tries expansion first, so it may find the same Hello
|
|
||||||
assert.strictEqual(typeof result!.col, 'number');
|
|
||||||
assert.strictEqual(result!.row, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear selection and return undefined when term not found', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.strictEqual(searchEngine.findPreviousWithSelection('NotFound'), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases and error handling', () => {
|
|
||||||
describe('unicode and special characters', () => {
|
|
||||||
it('should handle unicode characters correctly', async () => {
|
|
||||||
await writeP(terminal, 'Hello 世界 World');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('世界', 0, 0), {
|
|
||||||
term: '世界',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 4
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it('should handle wide characters', async () => {
|
|
||||||
await writeP(terminal, '中文测试');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('测试', 0, 0), {
|
|
||||||
term: '测试',
|
|
||||||
col: 4,
|
|
||||||
row: 0,
|
|
||||||
size: 4
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('wrapped lines', () => {
|
|
||||||
it('should handle search across wrapped lines', async () => {
|
|
||||||
const longText = 'A'.repeat(100) + 'target' + 'B'.repeat(50);
|
|
||||||
await writeP(terminal, longText);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
|
||||||
term: 'target',
|
|
||||||
col: 20,
|
|
||||||
row: 1,
|
|
||||||
size: 6
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle wrapped lines with unicode', async () => {
|
|
||||||
const longText = '中'.repeat(50) + 'target' + '文'.repeat(30);
|
|
||||||
await writeP(terminal, longText);
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
|
||||||
term: 'target',
|
|
||||||
col: 20,
|
|
||||||
row: 1,
|
|
||||||
size: 6
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should skip wrapped lines correctly in findInLine', async () => {
|
|
||||||
const longText = 'A'.repeat(200);
|
|
||||||
await writeP(terminal, longText + '\r\nNext line with target');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
|
||||||
term: 'target',
|
|
||||||
col: 15,
|
|
||||||
row: 3,
|
|
||||||
size: 6
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buffer boundaries', () => {
|
|
||||||
|
|
||||||
|
|
||||||
it('should handle empty buffer gracefully', () => {
|
|
||||||
assert.strictEqual(searchEngine.find('anything', 0, 0), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle search beyond buffer size', () => {
|
|
||||||
assert.strictEqual(searchEngine.find('test', 1000, 0), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('invalid inputs', () => {
|
|
||||||
it('should handle undefined search options gracefully', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, undefined), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle negative start positions', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('Hello', -1, -1), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle search options with undefined properties', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
const options: ISearchOptions = {
|
|
||||||
caseSensitive: undefined,
|
|
||||||
regex: undefined,
|
|
||||||
wholeWord: undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, options), {
|
|
||||||
term: 'Hello',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('private method behaviors (tested indirectly)', () => {
|
|
||||||
describe('_isWholeWord behavior', () => {
|
|
||||||
it('should recognize word boundaries with various punctuation', async () => {
|
|
||||||
await writeP(terminal, 'word1 word2,word3(word4)word5[word6]word7{word8}');
|
|
||||||
|
|
||||||
const tests = [
|
|
||||||
{ term: 'word1', expected: true },
|
|
||||||
{ term: 'word2', expected: true },
|
|
||||||
{ term: 'word3', expected: true },
|
|
||||||
{ term: 'word4', expected: true },
|
|
||||||
{ term: 'word5', expected: true },
|
|
||||||
{ term: 'word6', expected: true },
|
|
||||||
{ term: 'word7', expected: true },
|
|
||||||
{ term: 'word8', expected: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const test of tests) {
|
|
||||||
const result = searchEngine.find(test.term, 0, 0, { wholeWord: true });
|
|
||||||
if (test.expected) {
|
|
||||||
assert.notStrictEqual(result, undefined, `Should find whole word: ${test.term}`);
|
|
||||||
} else {
|
|
||||||
assert.strictEqual(result, undefined, `Should not find non-whole word: ${test.term}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle word boundaries at line start and end', async () => {
|
|
||||||
await writeP(terminal, 'start middle end');
|
|
||||||
|
|
||||||
const startResult = searchEngine.find('start', 0, 0, { wholeWord: true });
|
|
||||||
assert.deepStrictEqual(startResult, {
|
|
||||||
term: 'start',
|
|
||||||
col: 0,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
|
|
||||||
const endResult = searchEngine.find('end', 0, 0, { wholeWord: true });
|
|
||||||
assert.deepStrictEqual(endResult, {
|
|
||||||
term: 'end',
|
|
||||||
col: 13,
|
|
||||||
row: 0,
|
|
||||||
size: 3
|
|
||||||
});
|
|
||||||
|
|
||||||
const middleResult = searchEngine.find('middle', 0, 0, { wholeWord: true });
|
|
||||||
assert.deepStrictEqual(middleResult, {
|
|
||||||
term: 'middle',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 6
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buffer offset calculations', () => {
|
|
||||||
it('should handle wide character offset calculations', async () => {
|
|
||||||
await writeP(terminal, '中文 test 测试');
|
|
||||||
|
|
||||||
const result = searchEngine.find('test', 0, 0);
|
|
||||||
assert.notStrictEqual(result, undefined);
|
|
||||||
assert.strictEqual(result!.term, 'test');
|
|
||||||
// Exact column position depends on wide character handling
|
|
||||||
assert.strictEqual(typeof result!.col, 'number');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('string to buffer size conversions', () => {
|
|
||||||
it('should correctly calculate size for simple text', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
|
||||||
term: 'World',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly calculate size for unicode text', async () => {
|
|
||||||
await writeP(terminal, 'Hello 世界');
|
|
||||||
|
|
||||||
const result = searchEngine.find('世界', 0, 0);
|
|
||||||
assert.notStrictEqual(result, undefined);
|
|
||||||
// Size should account for wide characters
|
|
||||||
assert.strictEqual(typeof result!.size, 'number');
|
|
||||||
assert.strictEqual(result!.size >= 2, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle size calculation across wrapped lines', async () => {
|
|
||||||
const longMatch = 'A'.repeat(100);
|
|
||||||
await writeP(terminal, longMatch);
|
|
||||||
|
|
||||||
const result = searchEngine.find(longMatch, 0, 0);
|
|
||||||
assert.notStrictEqual(result, undefined);
|
|
||||||
assert.strictEqual(result!.size >= 100, true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('integration with SearchLineCache', () => {
|
|
||||||
it('should use cache for line translation', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
// Initialize cache
|
|
||||||
lineCache.initLinesCache();
|
|
||||||
|
|
||||||
const result1 = searchEngine.find('World', 0, 0);
|
|
||||||
const result2 = searchEngine.find('World', 0, 0);
|
|
||||||
|
|
||||||
assert.notStrictEqual(result1, undefined);
|
|
||||||
assert.notStrictEqual(result2, undefined);
|
|
||||||
assert.deepStrictEqual(result1, result2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle cache misses gracefully', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
|
|
||||||
// Don't initialize cache
|
|
||||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
|
||||||
term: 'World',
|
|
||||||
col: 6,
|
|
||||||
row: 0,
|
|
||||||
size: 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work correctly with cache invalidation', async () => {
|
|
||||||
await writeP(terminal, 'Initial text');
|
|
||||||
lineCache.initLinesCache();
|
|
||||||
|
|
||||||
const result1 = searchEngine.find('Initial', 0, 0);
|
|
||||||
assert.notStrictEqual(result1, undefined);
|
|
||||||
|
|
||||||
// Change terminal content which should invalidate cache
|
|
||||||
await writeP(terminal, '\r\nNew line');
|
|
||||||
|
|
||||||
const result2 = searchEngine.find('New', 0, 0);
|
|
||||||
assert.deepStrictEqual(result2, {
|
|
||||||
term: 'New',
|
|
||||||
col: 0,
|
|
||||||
row: 1,
|
|
||||||
size: 3
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
|
||||||
* @license MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Terminal } from '@xterm/xterm';
|
|
||||||
import type { ISearchOptions } from '@xterm/addon-search';
|
|
||||||
import type { SearchLineCache } from './SearchLineCache';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the position to start a search from.
|
|
||||||
*/
|
|
||||||
interface ISearchPosition {
|
|
||||||
startCol: number;
|
|
||||||
startRow: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a search result with its position and content.
|
|
||||||
*/
|
|
||||||
export interface ISearchResult {
|
|
||||||
term: string;
|
|
||||||
col: number;
|
|
||||||
row: number;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration constants for the search engine functionality.
|
|
||||||
*/
|
|
||||||
const enum Constants {
|
|
||||||
/**
|
|
||||||
* Characters that are considered non-word characters for search boundary detection. These
|
|
||||||
* characters are used to determine word boundaries when performing whole-word searches. Includes
|
|
||||||
* common punctuation, symbols, and whitespace characters.
|
|
||||||
*/
|
|
||||||
NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Core search engine that handles finding text within terminal content.
|
|
||||||
* This class is responsible for the actual search algorithms and position calculations.
|
|
||||||
*/
|
|
||||||
export class SearchEngine {
|
|
||||||
constructor(
|
|
||||||
private readonly _terminal: Terminal,
|
|
||||||
private readonly _lineCache: SearchLineCache
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the first occurrence of a term starting from a specific position.
|
|
||||||
* @param term The search term.
|
|
||||||
* @param startRow The row to start searching from.
|
|
||||||
* @param startCol The column to start searching from.
|
|
||||||
* @param searchOptions Search options.
|
|
||||||
* @returns The search result if found, undefined otherwise.
|
|
||||||
*/
|
|
||||||
public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
|
|
||||||
if (!term || term.length === 0) {
|
|
||||||
this._terminal.clearSelection();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (startCol > this._terminal.cols) {
|
|
||||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._lineCache.initLinesCache();
|
|
||||||
|
|
||||||
const searchPosition: ISearchPosition = {
|
|
||||||
startRow,
|
|
||||||
startCol
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search startRow
|
|
||||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
|
||||||
// Search from startRow + 1 to end
|
|
||||||
if (!result) {
|
|
||||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
|
||||||
searchPosition.startRow = y;
|
|
||||||
searchPosition.startCol = 0;
|
|
||||||
result = this._findInLine(term, searchPosition, searchOptions);
|
|
||||||
if (result) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the next occurrence of a term with wrapping and selection management.
|
|
||||||
* @param term The search term.
|
|
||||||
* @param searchOptions Search options.
|
|
||||||
* @param cachedSearchTerm The cached search term to determine incremental behavior.
|
|
||||||
* @returns The search result if found, undefined otherwise.
|
|
||||||
*/
|
|
||||||
public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
|
||||||
if (!term || term.length === 0) {
|
|
||||||
this._terminal.clearSelection();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
|
||||||
this._terminal.clearSelection();
|
|
||||||
|
|
||||||
let startCol = 0;
|
|
||||||
let startRow = 0;
|
|
||||||
if (prevSelectedPos) {
|
|
||||||
if (cachedSearchTerm === term) {
|
|
||||||
startCol = prevSelectedPos.end.x;
|
|
||||||
startRow = prevSelectedPos.end.y;
|
|
||||||
} else {
|
|
||||||
startCol = prevSelectedPos.start.x;
|
|
||||||
startRow = prevSelectedPos.start.y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._lineCache.initLinesCache();
|
|
||||||
|
|
||||||
const searchPosition: ISearchPosition = {
|
|
||||||
startRow,
|
|
||||||
startCol
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search startRow
|
|
||||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
|
||||||
// Search from startRow + 1 to end
|
|
||||||
if (!result) {
|
|
||||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
|
||||||
searchPosition.startRow = y;
|
|
||||||
searchPosition.startCol = 0;
|
|
||||||
result = this._findInLine(term, searchPosition, searchOptions);
|
|
||||||
if (result) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If we hit the bottom and didn't search from the very top wrap back up
|
|
||||||
if (!result && startRow !== 0) {
|
|
||||||
for (let y = 0; y < startRow; y++) {
|
|
||||||
searchPosition.startRow = y;
|
|
||||||
searchPosition.startCol = 0;
|
|
||||||
result = this._findInLine(term, searchPosition, searchOptions);
|
|
||||||
if (result) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If there is only one result, wrap back and return selection if it exists.
|
|
||||||
if (!result && prevSelectedPos) {
|
|
||||||
searchPosition.startRow = prevSelectedPos.start.y;
|
|
||||||
searchPosition.startCol = 0;
|
|
||||||
result = this._findInLine(term, searchPosition, searchOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the previous occurrence of a term with wrapping and selection management.
|
|
||||||
* @param term The search term.
|
|
||||||
* @param searchOptions Search options.
|
|
||||||
* @param cachedSearchTerm The cached search term to determine if expansion should occur.
|
|
||||||
* @returns The search result if found, undefined otherwise.
|
|
||||||
*/
|
|
||||||
public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
|
||||||
if (!term || term.length === 0) {
|
|
||||||
this._terminal.clearSelection();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
|
||||||
this._terminal.clearSelection();
|
|
||||||
|
|
||||||
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
|
||||||
let startCol = this._terminal.cols;
|
|
||||||
const isReverseSearch = true;
|
|
||||||
|
|
||||||
this._lineCache.initLinesCache();
|
|
||||||
const searchPosition: ISearchPosition = {
|
|
||||||
startRow,
|
|
||||||
startCol
|
|
||||||
};
|
|
||||||
|
|
||||||
let result: ISearchResult | undefined;
|
|
||||||
if (prevSelectedPos) {
|
|
||||||
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
|
||||||
searchPosition.startCol = startCol = prevSelectedPos.start.x;
|
|
||||||
if (cachedSearchTerm !== term) {
|
|
||||||
// Try to expand selection to right first.
|
|
||||||
result = this._findInLine(term, searchPosition, searchOptions, false);
|
|
||||||
if (!result) {
|
|
||||||
// If selection was not able to be expanded to the right, then try reverse search
|
|
||||||
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
|
||||||
searchPosition.startCol = startCol = prevSelectedPos.end.x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search from startRow - 1 to top
|
|
||||||
if (!result) {
|
|
||||||
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
|
|
||||||
for (let y = startRow - 1; y >= 0; y--) {
|
|
||||||
searchPosition.startRow = y;
|
|
||||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
|
||||||
if (result) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If we hit the top and didn't search from the very bottom wrap back down
|
|
||||||
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
|
|
||||||
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
|
|
||||||
searchPosition.startRow = y;
|
|
||||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
|
||||||
if (result) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A found substring is a whole word if it doesn't have an alphanumeric character directly
|
|
||||||
* adjacent to it.
|
|
||||||
* @param searchIndex starting index of the potential whole word substring
|
|
||||||
* @param line entire string in which the potential whole word was found
|
|
||||||
* @param term the substring that starts at searchIndex
|
|
||||||
*/
|
|
||||||
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
|
|
||||||
return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
|
|
||||||
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
|
|
||||||
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
|
|
||||||
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
|
|
||||||
* be properly searched when the terminal line that the text starts on is searched.
|
|
||||||
* @param term The search term.
|
|
||||||
* @param searchPosition The position to start the search.
|
|
||||||
* @param searchOptions Search options.
|
|
||||||
* @param isReverseSearch Whether the search should start from the right side of the terminal and
|
|
||||||
* search to the left.
|
|
||||||
* @returns The search result if it was found.
|
|
||||||
*/
|
|
||||||
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
|
|
||||||
const row = searchPosition.startRow;
|
|
||||||
const col = searchPosition.startCol;
|
|
||||||
|
|
||||||
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
|
|
||||||
const firstLine = this._terminal.buffer.active.getLine(row);
|
|
||||||
if (firstLine?.isWrapped) {
|
|
||||||
if (isReverseSearch) {
|
|
||||||
searchPosition.startCol += this._terminal.cols;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This will iterate until we find the line start.
|
|
||||||
// When we find it, we will search using the calculated start column.
|
|
||||||
searchPosition.startRow--;
|
|
||||||
searchPosition.startCol += this._terminal.cols;
|
|
||||||
return this._findInLine(term, searchPosition, searchOptions);
|
|
||||||
}
|
|
||||||
let cache = this._lineCache.getLineFromCache(row);
|
|
||||||
if (!cache) {
|
|
||||||
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
|
|
||||||
this._lineCache.setLineInCache(row, cache);
|
|
||||||
}
|
|
||||||
const [stringLine, offsets] = cache;
|
|
||||||
|
|
||||||
const offset = this._bufferColsToStringOffset(row, col);
|
|
||||||
let searchTerm = term;
|
|
||||||
let searchStringLine = stringLine;
|
|
||||||
if (!searchOptions.regex) {
|
|
||||||
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
|
|
||||||
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
let resultIndex = -1;
|
|
||||||
if (searchOptions.regex) {
|
|
||||||
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
|
|
||||||
let foundTerm: RegExpExecArray | null;
|
|
||||||
if (isReverseSearch) {
|
|
||||||
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
|
|
||||||
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
|
|
||||||
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
|
|
||||||
term = foundTerm[0];
|
|
||||||
searchRegex.lastIndex -= (term.length - 1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
|
|
||||||
if (foundTerm && foundTerm[0].length > 0) {
|
|
||||||
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
|
|
||||||
term = foundTerm[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isReverseSearch) {
|
|
||||||
if (offset - searchTerm.length >= 0) {
|
|
||||||
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resultIndex = searchStringLine.indexOf(searchTerm, offset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resultIndex >= 0) {
|
|
||||||
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
|
||||||
// rows
|
|
||||||
let startRowOffset = 0;
|
|
||||||
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
|
|
||||||
startRowOffset++;
|
|
||||||
}
|
|
||||||
let endRowOffset = startRowOffset;
|
|
||||||
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
|
|
||||||
endRowOffset++;
|
|
||||||
}
|
|
||||||
const startColOffset = resultIndex - offsets[startRowOffset];
|
|
||||||
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
|
|
||||||
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
|
|
||||||
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
|
|
||||||
const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);
|
|
||||||
|
|
||||||
return {
|
|
||||||
term,
|
|
||||||
col: startColIndex,
|
|
||||||
row: row + startRowOffset,
|
|
||||||
size
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _stringLengthToBufferSize(row: number, offset: number): number {
|
|
||||||
const line = this._terminal.buffer.active.getLine(row);
|
|
||||||
if (!line) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < offset; i++) {
|
|
||||||
const cell = line.getCell(i);
|
|
||||||
if (!cell) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Adjust the searchIndex to normalize emoji into single chars
|
|
||||||
const char = cell.getChars();
|
|
||||||
if (char.length > 1) {
|
|
||||||
offset -= char.length - 1;
|
|
||||||
}
|
|
||||||
// Adjust the searchIndex for empty characters following wide unicode
|
|
||||||
// chars (eg. CJK)
|
|
||||||
const nextCell = line.getCell(i + 1);
|
|
||||||
if (nextCell && nextCell.getWidth() === 0) {
|
|
||||||
offset++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _bufferColsToStringOffset(startRow: number, cols: number): number {
|
|
||||||
let lineIndex = startRow;
|
|
||||||
let offset = 0;
|
|
||||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
|
||||||
while (cols > 0 && line) {
|
|
||||||
for (let i = 0; i < cols && i < this._terminal.cols; i++) {
|
|
||||||
const cell = line.getCell(i);
|
|
||||||
if (!cell) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (cell.getWidth()) {
|
|
||||||
// Treat null characters as whitespace to align with the translateToString API
|
|
||||||
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lineIndex++;
|
|
||||||
line = this._terminal.buffer.active.getLine(lineIndex);
|
|
||||||
if (line && !line.isWrapped) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cols -= this._terminal.cols;
|
|
||||||
}
|
|
||||||
return offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
|
||||||
* @license MIT
|
|
||||||
*/
|
|
||||||
import { assert } from 'chai';
|
|
||||||
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
|
|
||||||
import { Terminal } from 'browser/public/Terminal';
|
|
||||||
import { timeout } from 'vs/base/common/async';
|
|
||||||
|
|
||||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
|
||||||
return new Promise(r => terminal.write(data, r));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SearchLineCache', () => {
|
|
||||||
let terminal: Terminal;
|
|
||||||
let cache: SearchLineCache;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
terminal = new Terminal({ cols: 80, rows: 24 });
|
|
||||||
cache = new SearchLineCache(terminal);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cache.dispose();
|
|
||||||
terminal.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('constructor', () => {
|
|
||||||
it('should create a SearchLineCache instance', () => {
|
|
||||||
assert.instanceOf(cache, SearchLineCache);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should start with no cache initialized', () => {
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initLinesCache', () => {
|
|
||||||
it('should initialize the lines cache array', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be initialized but empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not reinitialize if cache already exists', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.setLineInCache(0, ['test', [0]]);
|
|
||||||
|
|
||||||
cache.initLinesCache();
|
|
||||||
|
|
||||||
const entry = cache.getLineFromCache(0);
|
|
||||||
assert.deepEqual(entry, ['test', [0]], 'cache should still contain the previously set entry');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set up TTL timeout', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.setLineInCache(0, ['test', [0]]);
|
|
||||||
|
|
||||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]], 'entry should exist after initialization');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getLineFromCache', () => {
|
|
||||||
it('should return undefined when cache is not initialized', () => {
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined);
|
|
||||||
assert.equal(cache.getLineFromCache(10), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined for unset entries when cache is initialized', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined);
|
|
||||||
assert.equal(cache.getLineFromCache(50), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return cached entries', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
const entry: LineCacheEntry = ['test content', [0]];
|
|
||||||
cache.setLineInCache(5, entry);
|
|
||||||
|
|
||||||
assert.deepEqual(cache.getLineFromCache(5), entry);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setLineInCache', () => {
|
|
||||||
it('should not set entries when cache is not initialized', () => {
|
|
||||||
const entry: LineCacheEntry = ['test content', [0]];
|
|
||||||
cache.setLineInCache(0, entry);
|
|
||||||
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set entries when cache is initialized', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
const entry: LineCacheEntry = ['test content', [0]];
|
|
||||||
cache.setLineInCache(10, entry);
|
|
||||||
|
|
||||||
assert.deepEqual(cache.getLineFromCache(10), entry);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should overwrite existing entries', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
const entry1: LineCacheEntry = ['first content', [0]];
|
|
||||||
const entry2: LineCacheEntry = ['second content', [0]];
|
|
||||||
|
|
||||||
cache.setLineInCache(0, entry1);
|
|
||||||
assert.deepEqual(cache.getLineFromCache(0), entry1);
|
|
||||||
|
|
||||||
cache.setLineInCache(0, entry2);
|
|
||||||
assert.deepEqual(cache.getLineFromCache(0), entry2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('translateBufferLineToStringWithWrap', () => {
|
|
||||||
it('should translate a single line without wrapping', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
assert.equal(result[0], 'Hello World');
|
|
||||||
assert.deepEqual(result[1], [0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle trimRight parameter', async () => {
|
|
||||||
await writeP(terminal, 'Hello World ');
|
|
||||||
const resultTrimmed = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
const resultNotTrimmed = cache.translateBufferLineToStringWithWrap(0, false);
|
|
||||||
|
|
||||||
assert.equal(resultTrimmed[0].trimEnd(), 'Hello World');
|
|
||||||
assert.isTrue(resultNotTrimmed[0].startsWith('Hello World '));
|
|
||||||
assert.isTrue(resultNotTrimmed[0].length > resultTrimmed[0].length, 'non-trimmed result should be longer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle wrapped lines', async () => {
|
|
||||||
const longText = 'A'.repeat(200);
|
|
||||||
await writeP(terminal, longText);
|
|
||||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
assert.equal(result[0], longText);
|
|
||||||
assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping');
|
|
||||||
assert.equal(result[1][0], 0, 'first offset should be 0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle wide characters', async () => {
|
|
||||||
await writeP(terminal, 'Hello 世界');
|
|
||||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
assert.equal(result[0], 'Hello 世界');
|
|
||||||
assert.deepEqual(result[1], [0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty lines', () => {
|
|
||||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
assert.equal(result[0], '');
|
|
||||||
assert.deepEqual(result[1], [0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle lines beyond buffer', () => {
|
|
||||||
const result = cache.translateBufferLineToStringWithWrap(1000, true);
|
|
||||||
assert.equal(result[0], '');
|
|
||||||
assert.deepEqual(result[1], [0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex wrapped content', async () => {
|
|
||||||
await writeP(terminal, 'Line 1\r\n');
|
|
||||||
await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n');
|
|
||||||
await writeP(terminal, 'Line 3');
|
|
||||||
|
|
||||||
const result1 = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
const result2 = cache.translateBufferLineToStringWithWrap(1, true);
|
|
||||||
const result3 = cache.translateBufferLineToStringWithWrap(2, true);
|
|
||||||
|
|
||||||
assert.equal(result1[0], 'Line 1');
|
|
||||||
assert.equal(result2[0], 'Line 2 with some longer content that might wrap');
|
|
||||||
assert.equal(result3[0], 'Line 3');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cache invalidation', () => {
|
|
||||||
it('should invalidate cache on line feed', async () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.setLineInCache(0, ['test', [0]]);
|
|
||||||
|
|
||||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
|
||||||
|
|
||||||
terminal.write('test\r\n');
|
|
||||||
|
|
||||||
await timeout(10);
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should invalidate cache on cursor move', async () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.setLineInCache(0, ['test', [0]]);
|
|
||||||
|
|
||||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
|
||||||
|
|
||||||
await writeP(terminal, 'some text');
|
|
||||||
await timeout(10);
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should invalidate cache on resize', async () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.setLineInCache(0, ['test', [0]]);
|
|
||||||
|
|
||||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
|
||||||
|
|
||||||
terminal.resize(100, 30);
|
|
||||||
|
|
||||||
await timeout(10);
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('disposal', () => {
|
|
||||||
it('should clean up resources on dispose', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.setLineInCache(0, ['test', [0]]);
|
|
||||||
|
|
||||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
|
||||||
|
|
||||||
cache.dispose();
|
|
||||||
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be destroyed after disposal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be safe to dispose multiple times', () => {
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.dispose();
|
|
||||||
cache.dispose();
|
|
||||||
|
|
||||||
assert.equal(cache.getLineFromCache(0), undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('LineCacheEntry type', () => {
|
|
||||||
it('should handle complex line offsets', () => {
|
|
||||||
const entry: LineCacheEntry = [
|
|
||||||
'A very long line that wraps multiple times across several terminal lines',
|
|
||||||
[0, 20, 40, 60]
|
|
||||||
];
|
|
||||||
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.setLineInCache(0, entry);
|
|
||||||
|
|
||||||
const retrieved = cache.getLineFromCache(0);
|
|
||||||
assert.deepEqual(retrieved, entry);
|
|
||||||
assert.equal(retrieved![0].length, 72);
|
|
||||||
assert.equal(retrieved![1].length, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unicode characters in cache entries', () => {
|
|
||||||
const entry: LineCacheEntry = [
|
|
||||||
'Hello 世界 🌍 测试',
|
|
||||||
[0]
|
|
||||||
];
|
|
||||||
|
|
||||||
cache.initLinesCache();
|
|
||||||
cache.setLineInCache(0, entry);
|
|
||||||
|
|
||||||
const retrieved = cache.getLineFromCache(0);
|
|
||||||
assert.deepEqual(retrieved, entry);
|
|
||||||
assert.equal(retrieved![0], 'Hello 世界 🌍 测试');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('integration with real terminal content', () => {
|
|
||||||
it('should correctly translate real buffer content', async () => {
|
|
||||||
await writeP(terminal, 'Hello World');
|
|
||||||
const cached = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || '';
|
|
||||||
|
|
||||||
assert.equal(cached[0], directTranslation);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle real wrapped content correctly', async () => {
|
|
||||||
const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache';
|
|
||||||
await writeP(terminal, longContent);
|
|
||||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
assert.equal(result[0], longContent);
|
|
||||||
assert.isTrue(result[1].length > 1, 'should have wrapped');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work with real escape sequences', async () => {
|
|
||||||
await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter');
|
|
||||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
|
||||||
assert.include(result[0], 'Before');
|
|
||||||
assert.include(result[0], 'Red Text');
|
|
||||||
assert.include(result[0], 'After');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
|
||||||
* @license MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Terminal } from '@xterm/xterm';
|
|
||||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
|
||||||
import { disposableTimeout } from 'vs/base/common/async';
|
|
||||||
|
|
||||||
export type LineCacheEntry = [
|
|
||||||
/**
|
|
||||||
* The string representation of a line (as opposed to the buffer cell representation).
|
|
||||||
*/
|
|
||||||
lineAsString: string,
|
|
||||||
/**
|
|
||||||
* The offsets where each line starts when the entry describes a wrapped line.
|
|
||||||
*/
|
|
||||||
lineOffsets: number[]
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration constants for the search line cache functionality.
|
|
||||||
*/
|
|
||||||
const enum Constants {
|
|
||||||
/**
|
|
||||||
* Time-to-live for cached search results in milliseconds. After this duration, cached search
|
|
||||||
* results will be invalidated to ensure they remain consistent with terminal content changes.
|
|
||||||
*/
|
|
||||||
LINES_CACHE_TIME_TO_LIVE = 15000
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SearchLineCache extends Disposable {
|
|
||||||
/**
|
|
||||||
* translateBufferLineToStringWithWrap is a fairly expensive call.
|
|
||||||
* We memoize the calls into an array that has a time based ttl.
|
|
||||||
* _linesCache is also invalidated when the terminal cursor moves.
|
|
||||||
*/
|
|
||||||
private _linesCache: LineCacheEntry[] | undefined;
|
|
||||||
private _linesCacheTimeout = this._register(new MutableDisposable());
|
|
||||||
private _linesCacheDisposables = this._register(new MutableDisposable());
|
|
||||||
// Track access to avoid recreating a timeout on every init call which occurs once per search
|
|
||||||
// result (findNext/findPrevious -> _highlightAllMatches -> find loop).
|
|
||||||
private _lastAccessTimestamp = 0;
|
|
||||||
|
|
||||||
constructor(private readonly _terminal: Terminal) {
|
|
||||||
super();
|
|
||||||
this._register(toDisposable(() => this._destroyLinesCache()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up a line cache with a ttl
|
|
||||||
*/
|
|
||||||
public initLinesCache(): void {
|
|
||||||
if (!this._linesCache) {
|
|
||||||
this._linesCache = new Array(this._terminal.buffer.active.length);
|
|
||||||
this._linesCacheDisposables.value = combinedDisposable(
|
|
||||||
this._terminal.onLineFeed(() => this._destroyLinesCache()),
|
|
||||||
this._terminal.onCursorMove(() => this._destroyLinesCache()),
|
|
||||||
this._terminal.onResize(() => this._destroyLinesCache())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._lastAccessTimestamp = Date.now();
|
|
||||||
if (!this._linesCacheTimeout.value) {
|
|
||||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _destroyLinesCache(): void {
|
|
||||||
this._linesCache = undefined;
|
|
||||||
this._lastAccessTimestamp = 0;
|
|
||||||
this._linesCacheDisposables.clear();
|
|
||||||
this._linesCacheTimeout.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _scheduleLinesCacheTimeout(delay: number): void {
|
|
||||||
this._linesCacheTimeout.value = disposableTimeout(() => {
|
|
||||||
if (!this._linesCache) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = now - this._lastAccessTimestamp;
|
|
||||||
if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {
|
|
||||||
this._destroyLinesCache();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getLineFromCache(row: number): LineCacheEntry | undefined {
|
|
||||||
return this._linesCache?.[row];
|
|
||||||
}
|
|
||||||
|
|
||||||
public setLineInCache(row: number, entry: LineCacheEntry): void {
|
|
||||||
if (this._linesCache) {
|
|
||||||
this._linesCache[row] = entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates a buffer line to a string, including subsequent lines if they are wraps.
|
|
||||||
* Wide characters will count as two columns in the resulting string. This
|
|
||||||
* function is useful for getting the actual text underneath the raw selection
|
|
||||||
* position.
|
|
||||||
* @param lineIndex The index of the line being translated.
|
|
||||||
* @param trimRight Whether to trim whitespace to the right.
|
|
||||||
*/
|
|
||||||
public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
|
||||||
const strings = [];
|
|
||||||
const lineOffsets = [0];
|
|
||||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
|
||||||
while (line) {
|
|
||||||
const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);
|
|
||||||
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
|
|
||||||
let string = line.translateToString(!lineWrapsToNext && trimRight);
|
|
||||||
if (lineWrapsToNext && nextLine) {
|
|
||||||
const lastCell = line.getCell(line.length - 1);
|
|
||||||
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
|
|
||||||
// a wide character wrapped to the next line
|
|
||||||
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
|
|
||||||
string = string.slice(0, -1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
strings.push(string);
|
|
||||||
if (lineWrapsToNext) {
|
|
||||||
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lineIndex++;
|
|
||||||
line = nextLine;
|
|
||||||
}
|
|
||||||
return [strings.join(''), lineOffsets];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
|
||||||
* @license MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
|
|
||||||
import type { IDisposable } from '@xterm/xterm';
|
|
||||||
import { Emitter, Event } from 'vs/base/common/event';
|
|
||||||
import { Disposable } from 'vs/base/common/lifecycle';
|
|
||||||
import type { ISearchResult } from './SearchEngine';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for managing a currently selected decoration.
|
|
||||||
*/
|
|
||||||
interface ISelectedDecoration extends IDisposable {
|
|
||||||
match: ISearchResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracks search results, manages result indexing, and fires events when results change.
|
|
||||||
* This class provides centralized management of search result state and notifications.
|
|
||||||
*/
|
|
||||||
export class SearchResultTracker extends Disposable {
|
|
||||||
private _searchResults: ISearchResult[] = [];
|
|
||||||
private _selectedDecoration: ISelectedDecoration | undefined;
|
|
||||||
|
|
||||||
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
|
||||||
public get onDidChangeResults(): Event<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current search results.
|
|
||||||
*/
|
|
||||||
public get searchResults(): ReadonlyArray<ISearchResult> {
|
|
||||||
return this._searchResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the currently selected decoration.
|
|
||||||
*/
|
|
||||||
public get selectedDecoration(): ISelectedDecoration | undefined {
|
|
||||||
return this._selectedDecoration;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the currently selected decoration.
|
|
||||||
*/
|
|
||||||
public set selectedDecoration(decoration: ISelectedDecoration | undefined) {
|
|
||||||
this._selectedDecoration = decoration;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the search results with a new set of results.
|
|
||||||
* @param results The new search results.
|
|
||||||
* @param maxResults The maximum number of results to track.
|
|
||||||
*/
|
|
||||||
public updateResults(results: ISearchResult[], maxResults: number): void {
|
|
||||||
this._searchResults = results.slice(0, maxResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears all search results.
|
|
||||||
*/
|
|
||||||
public clearResults(): void {
|
|
||||||
this._searchResults = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the selected decoration.
|
|
||||||
*/
|
|
||||||
public clearSelectedDecoration(): void {
|
|
||||||
if (this._selectedDecoration) {
|
|
||||||
this._selectedDecoration.dispose();
|
|
||||||
this._selectedDecoration = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the index of a result in the current results array.
|
|
||||||
* @param result The result to find.
|
|
||||||
* @returns The index of the result, or -1 if not found.
|
|
||||||
*/
|
|
||||||
public findResultIndex(result: ISearchResult): number {
|
|
||||||
for (let i = 0; i < this._searchResults.length; i++) {
|
|
||||||
const match = this._searchResults[i];
|
|
||||||
if (match.row === result.row && match.col === result.col && match.size === result.size) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fires a result change event with the current state.
|
|
||||||
* @param hasDecorations Whether decorations are enabled.
|
|
||||||
*/
|
|
||||||
public fireResultsChanged(hasDecorations: boolean): void {
|
|
||||||
if (!hasDecorations) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resultIndex = -1;
|
|
||||||
if (this._selectedDecoration) {
|
|
||||||
resultIndex = this.findResultIndex(this._selectedDecoration.match);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._onDidChangeResults.fire({
|
|
||||||
resultIndex,
|
|
||||||
resultCount: this._searchResults.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets all state.
|
|
||||||
*/
|
|
||||||
public reset(): void {
|
|
||||||
this.clearSelectedDecoration();
|
|
||||||
this.clearResults();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
|
||||||
* @license MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ISearchOptions } from '@xterm/addon-search';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages search state including cached search terms, options tracking, and validation.
|
|
||||||
* This class provides a centralized way to handle search state consistency and option changes.
|
|
||||||
*/
|
|
||||||
export class SearchState {
|
|
||||||
private _cachedSearchTerm: string | undefined;
|
|
||||||
private _lastSearchOptions: ISearchOptions | undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the currently cached search term.
|
|
||||||
*/
|
|
||||||
public get cachedSearchTerm(): string | undefined {
|
|
||||||
return this._cachedSearchTerm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the cached search term.
|
|
||||||
*/
|
|
||||||
public set cachedSearchTerm(term: string | undefined) {
|
|
||||||
this._cachedSearchTerm = term;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the last search options used.
|
|
||||||
*/
|
|
||||||
public get lastSearchOptions(): ISearchOptions | undefined {
|
|
||||||
return this._lastSearchOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the last search options used.
|
|
||||||
*/
|
|
||||||
public set lastSearchOptions(options: ISearchOptions | undefined) {
|
|
||||||
this._lastSearchOptions = options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a search term to ensure it's not empty or invalid.
|
|
||||||
* @param term The search term to validate.
|
|
||||||
* @returns true if the term is valid for searching.
|
|
||||||
*/
|
|
||||||
public isValidSearchTerm(term: string): boolean {
|
|
||||||
return !!(term && term.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if search options have changed compared to the last search.
|
|
||||||
* @param newOptions The new search options to compare.
|
|
||||||
* @returns true if the options have changed.
|
|
||||||
*/
|
|
||||||
public didOptionsChange(newOptions?: ISearchOptions): boolean {
|
|
||||||
if (!this._lastSearchOptions) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!newOptions) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (this._lastSearchOptions.regex !== newOptions.regex) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if a new search should trigger highlighting updates.
|
|
||||||
* @param term The search term.
|
|
||||||
* @param options The search options.
|
|
||||||
* @returns true if highlighting should be updated.
|
|
||||||
*/
|
|
||||||
public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {
|
|
||||||
if (!options?.decorations) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return this._cachedSearchTerm === undefined ||
|
|
||||||
term !== this._cachedSearchTerm ||
|
|
||||||
this.didOptionsChange(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the cached search term.
|
|
||||||
*/
|
|
||||||
public clearCachedTerm(): void {
|
|
||||||
this._cachedSearchTerm = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets all state.
|
|
||||||
*/
|
|
||||||
public reset(): void {
|
|
||||||
this._cachedSearchTerm = undefined;
|
|
||||||
this._lastSearchOptions = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,9 +21,6 @@
|
|||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
],
|
],
|
||||||
"vs/*": [
|
|
||||||
"../../../src/vs/*"
|
|
||||||
],
|
|
||||||
"@xterm/addon-search" : [
|
"@xterm/addon-search" : [
|
||||||
"../typings/addon-search.d.ts"
|
"../typings/addon-search.d.ts"
|
||||||
]
|
]
|
||||||
@@ -39,9 +36,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "../../../src/browser"
|
"path": "../../../src/browser"
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "../../../src/vs"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,45 @@ test.describe('Search Tests', () => {
|
|||||||
deepStrictEqual(await ctx.page.evaluate(`window.search.findNext('$^1_3{}test$#')`), true);
|
deepStrictEqual(await ctx.page.evaluate(`window.search.findNext('$^1_3{}test$#')`), true);
|
||||||
deepStrictEqual(await ctx.proxy.getSelection(), '$^1_3{}test$#');
|
deepStrictEqual(await ctx.proxy.getSelection(), '$^1_3{}test$#');
|
||||||
});
|
});
|
||||||
|
test('Search should not scroll when match is already in viewport', async () => {
|
||||||
|
let dataString = '';
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (i === 40) {
|
||||||
|
dataString += `line ${i} needle-in-view`;
|
||||||
|
} else {
|
||||||
|
dataString += `line ${i}`;
|
||||||
|
}
|
||||||
|
dataString += '\n\r';
|
||||||
|
}
|
||||||
|
await ctx.proxy.write(dataString);
|
||||||
|
await ctx.page.evaluate('window.term.scrollToLine(35)');
|
||||||
|
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35);
|
||||||
|
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-in-view')`), true);
|
||||||
|
deepStrictEqual(await ctx.proxy.getSelection(), 'needle-in-view');
|
||||||
|
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 35);
|
||||||
|
});
|
||||||
|
test('Search should center the match when it is outside the viewport', async () => {
|
||||||
|
let dataString = '';
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (i === 60) {
|
||||||
|
dataString += `line ${i} needle-center`;
|
||||||
|
} else {
|
||||||
|
dataString += `line ${i}`;
|
||||||
|
}
|
||||||
|
dataString += '\n\r';
|
||||||
|
}
|
||||||
|
await ctx.proxy.write(dataString);
|
||||||
|
await ctx.page.evaluate('window.term.scrollToLine(0)');
|
||||||
|
strictEqual(await ctx.page.evaluate('window.term.buffer.active.viewportY'), 0);
|
||||||
|
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.search.findNext('needle-center')`), true);
|
||||||
|
deepStrictEqual(await ctx.proxy.getSelection(), 'needle-center');
|
||||||
|
deepStrictEqual(await ctx.page.evaluate('(() => { const viewportY = window.term.buffer.active.viewportY; const rows = window.term.rows; const selection = window.term.getSelectionPosition(); return { viewportY: viewportY, expectedViewportY: selection.start.y - Math.floor(rows / 2) }; })()'), {
|
||||||
|
viewportY: 48,
|
||||||
|
expectedViewportY: 48
|
||||||
|
});
|
||||||
|
});
|
||||||
test('Incremental Find Previous', async () => {
|
test('Incremental Find Previous', async () => {
|
||||||
await ctx.proxy.writeln(`package.jsonc\n`);
|
await ctx.proxy.writeln(`package.jsonc\n`);
|
||||||
await ctx.proxy.write('package.json pack package.lock');
|
await ctx.proxy.write('package.json pack package.lock');
|
||||||
@@ -277,8 +316,7 @@ test.describe('Search Tests', () => {
|
|||||||
{ resultCount: 1, resultIndex: 0 },
|
{ resultCount: 1, resultIndex: 0 },
|
||||||
{ resultCount: 2, resultIndex: 1 }
|
{ resultCount: 2, resultIndex: 1 }
|
||||||
]);
|
]);
|
||||||
await timeout(2000);
|
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false);
|
||||||
strictEqual(await ctx.page.evaluate(`debugger; window.search.findPrevious('d', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), false);
|
|
||||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||||
{ resultCount: 1, resultIndex: 0 },
|
{ resultCount: 1, resultIndex: 0 },
|
||||||
{ resultCount: 2, resultIndex: 1 },
|
{ resultCount: 2, resultIndex: 1 },
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
],
|
],
|
||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"include": [],
|
"include": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./src" },
|
{ "path": "./src" },
|
||||||
{ "path": "./test" }
|
{ "path": "./test" },
|
||||||
|
{ "path": "./benchmark" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-12
@@ -17,7 +17,8 @@ declare module '@xterm/addon-search' {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to search for a whole word, the result is only valid if it's
|
* Whether to search for a whole word, the result is only valid if it's
|
||||||
* surrounded in "non-word" characters such as `_`, `(`, `)` or space.
|
* surrounded by start/end of line or non-word separators such as whitespace
|
||||||
|
* and punctuation (for example `(`, `)`, or space).
|
||||||
*/
|
*/
|
||||||
wholeWord?: boolean;
|
wholeWord?: boolean;
|
||||||
|
|
||||||
@@ -28,8 +29,7 @@ declare module '@xterm/addon-search' {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to do an incremental search, this will expand the selection if it
|
* Whether to do an incremental search, this will expand the selection if it
|
||||||
* still matches the term the user typed. Note that this only affects
|
* still matches the term the user typed.
|
||||||
* `findNext`, not `findPrevious`.
|
|
||||||
*/
|
*/
|
||||||
incremental?: boolean;
|
incremental?: boolean;
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ declare module '@xterm/addon-search' {
|
|||||||
/**
|
/**
|
||||||
* Options for showing decorations when searching.
|
* Options for showing decorations when searching.
|
||||||
*/
|
*/
|
||||||
interface ISearchDecorationOptions {
|
export interface ISearchDecorationOptions {
|
||||||
/**
|
/**
|
||||||
* The background color of a match, this must use #RRGGBB format.
|
* The background color of a match, this must use #RRGGBB format.
|
||||||
*/
|
*/
|
||||||
@@ -57,7 +57,7 @@ declare module '@xterm/addon-search' {
|
|||||||
/**
|
/**
|
||||||
* The overview ruler color of a match.
|
* The overview ruler color of a match.
|
||||||
*/
|
*/
|
||||||
matchOverviewRuler: string;
|
matchOverviewRuler?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The background color for the currently active match, this must use #RRGGBB format.
|
* The background color for the currently active match, this must use #RRGGBB format.
|
||||||
@@ -72,7 +72,7 @@ declare module '@xterm/addon-search' {
|
|||||||
/**
|
/**
|
||||||
* The overview ruler color of the currently active match.
|
* The overview ruler color of the currently active match.
|
||||||
*/
|
*/
|
||||||
activeMatchColorOverviewRuler: string;
|
activeMatchColorOverviewRuler?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,12 +80,13 @@ declare module '@xterm/addon-search' {
|
|||||||
*/
|
*/
|
||||||
export interface ISearchResultChangeEvent {
|
export interface ISearchResultChangeEvent {
|
||||||
/**
|
/**
|
||||||
* The index of the currently active result, -1 when the threshold of matches is exceeded.
|
* The index of the currently active result within tracked highlights, or -1 when
|
||||||
|
* the active match is not in the tracked result set.
|
||||||
*/
|
*/
|
||||||
resultIndex: number;
|
resultIndex: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The total number of search results found.
|
* The number of tracked search results (capped by `highlightLimit` when decorations are enabled).
|
||||||
*/
|
*/
|
||||||
resultCount: number;
|
resultCount: number;
|
||||||
}
|
}
|
||||||
@@ -96,9 +97,9 @@ declare module '@xterm/addon-search' {
|
|||||||
export interface ISearchAddonOptions {
|
export interface ISearchAddonOptions {
|
||||||
/**
|
/**
|
||||||
* Max number of matches highlighted when decorations are enabled.
|
* Max number of matches highlighted when decorations are enabled.
|
||||||
* Defaults to 1000 highlighted matches
|
* Defaults to 1000. Invalid values fall back to the default.
|
||||||
*/
|
*/
|
||||||
highlightLimit: number
|
highlightLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,7 +141,7 @@ declare module '@xterm/addon-search' {
|
|||||||
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean;
|
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the decorations and selection
|
* Clears highlight decorations and tracked results.
|
||||||
*/
|
*/
|
||||||
public clearDecorations(): void;
|
public clearDecorations(): void;
|
||||||
|
|
||||||
@@ -162,7 +163,8 @@ declare module '@xterm/addon-search' {
|
|||||||
readonly onBeforeSearch: IEvent<void>;
|
readonly onBeforeSearch: IEvent<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When decorations are enabled, fires when the search results change.
|
* When decorations are enabled, fires after `findNext`/`findPrevious` when tracked
|
||||||
|
* search results change. Does not fire from `clearDecorations()`.
|
||||||
*/
|
*/
|
||||||
readonly onDidChangeResults: IEvent<ISearchResultChangeEvent>;
|
readonly onDidChangeResults: IEvent<ISearchResultChangeEvent>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
"dom",
|
"dom",
|
||||||
"es2021"
|
"es2021"
|
||||||
],
|
],
|
||||||
|
"rootDir": "..",
|
||||||
"outDir": "../out-benchmark",
|
"outDir": "../out-benchmark",
|
||||||
"types": ["../../../node_modules/@types/node"],
|
"types": ["../../../node_modules/@types/node"],
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"target": "es2021",
|
"target": "es2021",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": ["../../../src/common/*"],
|
"common/*": ["../../../src/common/*"],
|
||||||
"browser/*": ["../../../src/browser/*"],
|
"browser/*": ["../../../src/browser/*"],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "../../node_modules/.bin/tsc -p .",
|
"build": "../../node_modules/.bin/tsgo -p .",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe('SerializeAddon', () => {
|
|||||||
describe('underline styles', () => {
|
describe('underline styles', () => {
|
||||||
it('should serialize single underline with style', async () => {
|
it('should serialize single underline with style', async () => {
|
||||||
await writeP(terminal, sgr('4:1') + 'test' + sgr('24'));
|
await writeP(terminal, sgr('4:1') + 'test' + sgr('24'));
|
||||||
assert.equal(serializeAddon.serialize(), '\u001b[4:1mtest\u001b[0m');
|
assert.equal(serializeAddon.serialize(), '\u001b[4mtest\u001b[0m');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serialize double underline', async () => {
|
it('should serialize double underline', async () => {
|
||||||
@@ -228,7 +228,7 @@ describe('SerializeAddon', () => {
|
|||||||
it('empty terminal with selection turned off', () => {
|
it('empty terminal with selection turned off', () => {
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.notEqual(output, '');
|
assert.notEqual(output, '');
|
||||||
assert.equal((output.match(/<div><span> {10}<\/span><\/div>/g) || []).length, 2);
|
assert.equal((output.match(/<div><span> {10}<\/span><\/div>/g) ?? []).length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty terminal with no selection', () => {
|
it('empty terminal with no selection', () => {
|
||||||
@@ -245,7 +245,7 @@ describe('SerializeAddon', () => {
|
|||||||
const output = serializeAddon.serializeAsHTML({
|
const output = serializeAddon.serializeAsHTML({
|
||||||
onlySelection: true
|
onlySelection: true
|
||||||
});
|
});
|
||||||
assert.equal((output.match(/<div><span>terminal<\/span><\/div>/g) || []).length, 1, output);
|
assert.equal((output.match(/<div><span>terminal<\/span><\/div>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('basic terminal with html unsafe chars', async () => {
|
it('basic terminal with html unsafe chars', async () => {
|
||||||
@@ -255,7 +255,7 @@ describe('SerializeAddon', () => {
|
|||||||
const output = serializeAddon.serializeAsHTML({
|
const output = serializeAddon.serializeAsHTML({
|
||||||
onlySelection: true
|
onlySelection: true
|
||||||
});
|
});
|
||||||
assert.equal((output.match(/<div><span><a>&pi;<\/span><\/div>/g) || []).length, 1, output);
|
assert.equal((output.match(/<div><span><a>&pi;<\/span><\/div>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes rows within a provided range', async () => {
|
it('serializes rows within a provided range', async () => {
|
||||||
@@ -267,7 +267,7 @@ describe('SerializeAddon', () => {
|
|||||||
startCol: 4
|
startCol: 4
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const rowMatches = output.match(/<div><span>.*?<\/span><\/div>/g) || [];
|
const rowMatches = output.match(/<div><span>.*?<\/span><\/div>/g) ?? [];
|
||||||
assert.equal(rowMatches.length, 1, output);
|
assert.equal(rowMatches.length, 1, output);
|
||||||
assert.ok(rowMatches[0]?.includes('hello'));
|
assert.ok(rowMatches[0]?.includes('hello'));
|
||||||
assert.ok(!output.includes('bye'));
|
assert.ok(!output.includes('bye'));
|
||||||
@@ -278,56 +278,56 @@ describe('SerializeAddon', () => {
|
|||||||
await writeP(terminal, ' ' + sgr('1') + 'terminal' + sgr('22') + ' ');
|
await writeP(terminal, ' ' + sgr('1') + 'terminal' + sgr('22') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='font-weight: bold;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='font-weight: bold;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with italic styling', async () => {
|
it('cells with italic styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('3') + 'terminal' + sgr('23') + ' ');
|
await writeP(terminal, ' ' + sgr('3') + 'terminal' + sgr('23') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='font-style: italic;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='font-style: italic;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with inverse styling', async () => {
|
it('cells with inverse styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('7') + 'terminal' + sgr('27') + ' ');
|
await writeP(terminal, ' ' + sgr('7') + 'terminal' + sgr('27') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='color: #000000; background-color: #BFBFBF;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='color: #000000; background-color: #BFBFBF;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with underline styling', async () => {
|
it('cells with underline styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('4') + 'terminal' + sgr('24') + ' ');
|
await writeP(terminal, ' ' + sgr('4') + 'terminal' + sgr('24') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='text-decoration: underline;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='text-decoration: underline;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with double underline styling', async () => {
|
it('cells with double underline styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('4:2') + 'terminal' + sgr('24') + ' ');
|
await writeP(terminal, ' ' + sgr('4:2') + 'terminal' + sgr('24') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='text-decoration: underline double;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='text-decoration: underline double;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with curly underline styling', async () => {
|
it('cells with curly underline styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('4:3') + 'terminal' + sgr('24') + ' ');
|
await writeP(terminal, ' ' + sgr('4:3') + 'terminal' + sgr('24') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='text-decoration: underline wavy;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='text-decoration: underline wavy;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with dotted underline styling', async () => {
|
it('cells with dotted underline styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('4:4') + 'terminal' + sgr('24') + ' ');
|
await writeP(terminal, ' ' + sgr('4:4') + 'terminal' + sgr('24') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='text-decoration: underline dotted;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='text-decoration: underline dotted;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with dashed underline styling', async () => {
|
it('cells with dashed underline styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('4:5') + 'terminal' + sgr('24') + ' ');
|
await writeP(terminal, ' ' + sgr('4:5') + 'terminal' + sgr('24') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='text-decoration: underline dashed;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='text-decoration: underline dashed;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with underline color (palette)', async () => {
|
it('cells with underline color (palette)', async () => {
|
||||||
@@ -350,49 +350,49 @@ describe('SerializeAddon', () => {
|
|||||||
await writeP(terminal, ' ' + sgr('8') + 'terminal' + sgr('28') + ' ');
|
await writeP(terminal, ' ' + sgr('8') + 'terminal' + sgr('28') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='visibility: hidden;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='visibility: hidden;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with dim styling', async () => {
|
it('cells with dim styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('2') + 'terminal' + sgr('22') + ' ');
|
await writeP(terminal, ' ' + sgr('2') + 'terminal' + sgr('22') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='opacity: 0.5;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='opacity: 0.5;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with strikethrough styling', async () => {
|
it('cells with strikethrough styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('9') + 'terminal' + sgr('29') + ' ');
|
await writeP(terminal, ' ' + sgr('9') + 'terminal' + sgr('29') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='text-decoration: line-through;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='text-decoration: line-through;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with combined styling', async () => {
|
it('cells with combined styling', async () => {
|
||||||
await writeP(terminal, sgr('1') + ' ' + sgr('9') + 'termi' + sgr('22') + 'nal' + sgr('29') + ' ');
|
await writeP(terminal, sgr('1') + ' ' + sgr('9') + 'termi' + sgr('22') + 'nal' + sgr('29') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='font-weight: bold;'> <\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='font-weight: bold;'> <\/span>/g) ?? []).length, 1, output);
|
||||||
assert.equal((output.match(/<span style='font-weight: bold; text-decoration: line-through;'>termi<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='font-weight: bold; text-decoration: line-through;'>termi<\/span>/g) ?? []).length, 1, output);
|
||||||
assert.equal((output.match(/<span style='text-decoration: line-through;'>nal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='text-decoration: line-through;'>nal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with color styling', async () => {
|
it('cells with color styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' ');
|
await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with background styling', async () => {
|
it('cells with background styling', async () => {
|
||||||
await writeP(terminal, ' ' + sgr('48;5;46') + 'terminal' + sgr('49') + ' ');
|
await writeP(terminal, ' ' + sgr('48;5;46') + 'terminal' + sgr('49') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='background-color: #00ff00;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='background-color: #00ff00;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty terminal with default options', async () => {
|
it('empty terminal with default options', async () => {
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/color: #000000; background-color: #ffffff; font-family: monospace; font-size: 15px;/g) || []).length, 1, output);
|
assert.equal((output.match(/color: #000000; background-color: #ffffff; font-family: monospace; font-size: 15px;/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty terminal with custom options', async () => {
|
it('empty terminal with custom options', async () => {
|
||||||
@@ -405,14 +405,14 @@ describe('SerializeAddon', () => {
|
|||||||
const output = serializeAddon.serializeAsHTML({
|
const output = serializeAddon.serializeAsHTML({
|
||||||
includeGlobalBackground: true
|
includeGlobalBackground: true
|
||||||
});
|
});
|
||||||
assert.equal((output.match(/color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;/g) || []).length, 1, output);
|
assert.equal((output.match(/color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty terminal with background included', async () => {
|
it('empty terminal with background included', async () => {
|
||||||
const output = serializeAddon.serializeAsHTML({
|
const output = serializeAddon.serializeAsHTML({
|
||||||
includeGlobalBackground: true
|
includeGlobalBackground: true
|
||||||
});
|
});
|
||||||
assert.equal((output.match(/color: #ffffff; background-color: #000000; font-family: monospace; font-size: 15px;/g) || []).length, 1, output);
|
assert.equal((output.match(/color: #ffffff; background-color: #000000; font-family: monospace; font-size: 15px;/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with custom color styling', async () => {
|
it('cells with custom color styling', async () => {
|
||||||
@@ -422,7 +422,7 @@ describe('SerializeAddon', () => {
|
|||||||
await writeP(terminal, ' ' + sgr('38;5;0') + 'terminal' + sgr('39') + ' ');
|
await writeP(terminal, ' ' + sgr('38;5;0') + 'terminal' + sgr('39') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='color: #ffa500;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='color: #ffa500;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cells with color styling - xterm headless', async () => {
|
it('cells with color styling - xterm headless', async () => {
|
||||||
@@ -432,7 +432,7 @@ describe('SerializeAddon', () => {
|
|||||||
await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' ');
|
await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' ');
|
||||||
|
|
||||||
const output = serializeAddon.serializeAsHTML();
|
const output = serializeAddon.serializeAsHTML();
|
||||||
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) || []).length, 1, output);
|
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) ?? []).length, 1, output);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,11 +89,19 @@ function equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell)
|
|||||||
if (!cell1.isUnderline() && !cell2.isUnderline()) {
|
if (!cell1.isUnderline() && !cell2.isUnderline()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const cell1Data = cell1 as unknown as IAttributeData;
|
if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {
|
||||||
const cell2Data = cell2 as unknown as IAttributeData;
|
return false;
|
||||||
return cell1Data.getUnderlineStyle() === cell2Data.getUnderlineStyle()
|
}
|
||||||
&& cell1Data.getUnderlineColor() === cell2Data.getUnderlineColor()
|
const cell1Default = cell1.isUnderlineColorDefault();
|
||||||
&& cell1Data.getUnderlineColorMode() === cell2Data.getUnderlineColorMode();
|
const cell2Default = cell2.isUnderlineColorDefault();
|
||||||
|
if (cell1Default && cell2Default) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (cell1Default !== cell2Default) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return cell1.getUnderlineColor() === cell2.getUnderlineColor()
|
||||||
|
&& cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
||||||
@@ -109,6 +117,16 @@ function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): bo
|
|||||||
&& cell1.isStrikethrough() === cell2.isStrikethrough();
|
&& cell1.isStrikethrough() === cell2.isStrikethrough();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
||||||
|
const cell1AsBufferCell = cell1 as IBufferCell;
|
||||||
|
if (typeof cell1AsBufferCell.attributesEquals === 'function') {
|
||||||
|
return cell1AsBufferCell.attributesEquals(cell2);
|
||||||
|
}
|
||||||
|
return equalFg(cell1, cell2)
|
||||||
|
&& equalBg(cell1, cell2)
|
||||||
|
&& equalFlags(cell1, cell2);
|
||||||
|
}
|
||||||
|
|
||||||
class StringSerializeHandler extends BaseSerializeHandler {
|
class StringSerializeHandler extends BaseSerializeHandler {
|
||||||
private _rowIndex: number = 0;
|
private _rowIndex: number = 0;
|
||||||
private _allRows: string[] = new Array<string>();
|
private _allRows: string[] = new Array<string>();
|
||||||
@@ -258,6 +276,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
|
|
||||||
private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {
|
private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {
|
||||||
const sgrSeq: number[] = [];
|
const sgrSeq: number[] = [];
|
||||||
|
if (attributesEquals(cell, oldCell)) {
|
||||||
|
return sgrSeq;
|
||||||
|
}
|
||||||
const fgChanged = !equalFg(cell, oldCell);
|
const fgChanged = !equalFg(cell, oldCell);
|
||||||
const bgChanged = !equalBg(cell, oldCell);
|
const bgChanged = !equalBg(cell, oldCell);
|
||||||
const flagsChanged = !equalFlags(cell, oldCell);
|
const flagsChanged = !equalFlags(cell, oldCell);
|
||||||
@@ -290,17 +311,18 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
|
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
|
||||||
if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
|
if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
|
||||||
if (!equalUnderline(cell, oldCell)) {
|
if (!equalUnderline(cell, oldCell)) {
|
||||||
const cellData = cell as unknown as IAttributeData;
|
const style = cell.getUnderlineStyle();
|
||||||
const style = cellData.getUnderlineStyle();
|
|
||||||
if (style === UnderlineStyle.NONE) {
|
if (style === UnderlineStyle.NONE) {
|
||||||
sgrSeq.push(24);
|
sgrSeq.push(24);
|
||||||
|
} else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {
|
||||||
|
sgrSeq.push(4);
|
||||||
} else {
|
} else {
|
||||||
// Use SGR 4:X format for underline styles
|
// Use SGR 4:X format for underline styles
|
||||||
sgrSeq.push('4:' + style as unknown as number);
|
sgrSeq.push('4:' + style as unknown as number);
|
||||||
// Handle underline color
|
// Handle underline color
|
||||||
if (!cellData.isUnderlineColorDefault()) {
|
if (!cell.isUnderlineColorDefault()) {
|
||||||
const color = cellData.getUnderlineColor();
|
const color = cell.getUnderlineColor();
|
||||||
if (cellData.isUnderlineColorRGB()) {
|
if (cell.isUnderlineColorRGB()) {
|
||||||
sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);
|
sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);
|
||||||
} else {
|
} else {
|
||||||
sgrSeq.push('58:5:' + color as unknown as number);
|
sgrSeq.push('58:5:' + color as unknown as number);
|
||||||
@@ -457,7 +479,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SerializeAddon implements ITerminalAddon , ISerializeApi {
|
export class SerializeAddon implements ITerminalAddon, ISerializeApi {
|
||||||
private _terminal: Terminal | undefined;
|
private _terminal: Terminal | undefined;
|
||||||
|
|
||||||
public activate(terminal: Terminal): void {
|
public activate(terminal: Terminal): void {
|
||||||
@@ -601,7 +623,7 @@ export class SerializeAddon implements ITerminalAddon , ISerializeApi {
|
|||||||
throw new Error('Cannot use addon until it has been loaded');
|
throw new Error('Cannot use addon until it has been loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this._serializeBufferAsHTML(this._terminal, options || {});
|
return this._serializeBufferAsHTML(this._terminal, options ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void { }
|
public dispose(): void { }
|
||||||
@@ -630,20 +652,6 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _padStart(target: string, targetLength: number, padString: string): string {
|
|
||||||
targetLength = targetLength >> 0;
|
|
||||||
padString = padString ?? ' ';
|
|
||||||
if (target.length > targetLength) {
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
targetLength -= target.length;
|
|
||||||
if (targetLength > padString.length) {
|
|
||||||
padString += padString.repeat(targetLength / padString.length);
|
|
||||||
}
|
|
||||||
return padString.slice(0, targetLength) + target;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _beforeSerialize(rows: number, start: number, end: number): void {
|
protected _beforeSerialize(rows: number, start: number, end: number): void {
|
||||||
this._htmlContent += '<html><body><!--StartFragment--><pre>';
|
this._htmlContent += '<html><body><!--StartFragment--><pre>';
|
||||||
|
|
||||||
@@ -680,7 +688,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
|
|||||||
(color >> 8) & 255,
|
(color >> 8) & 255,
|
||||||
(color ) & 255
|
(color ) & 255
|
||||||
];
|
];
|
||||||
return '#' + rgb.map(x => this._padStart(x.toString(16), 2, '0')).join('');
|
return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
|
if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
|
||||||
return this._ansiColors[color].css;
|
return this._ansiColors[color].css;
|
||||||
@@ -689,26 +697,24 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _getUnderlineColor(cell: IBufferCell): string | undefined {
|
private _getUnderlineColor(cell: IBufferCell): string | undefined {
|
||||||
const cellData = cell as unknown as IAttributeData;
|
if (cell.isUnderlineColorDefault()) {
|
||||||
if (cellData.isUnderlineColorDefault()) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const color = cellData.getUnderlineColor();
|
const color = cell.getUnderlineColor();
|
||||||
if (cellData.isUnderlineColorRGB()) {
|
if (cell.isUnderlineColorRGB()) {
|
||||||
const rgb = [
|
const rgb = [
|
||||||
(color >> 16) & 255,
|
(color >> 16) & 255,
|
||||||
(color >> 8) & 255,
|
(color >> 8) & 255,
|
||||||
(color ) & 255
|
(color ) & 255
|
||||||
];
|
];
|
||||||
return '#' + rgb.map(x => this._padStart(x.toString(16), 2, '0')).join('');
|
return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
// Palette color
|
// Palette color
|
||||||
return this._ansiColors[color].css;
|
return this._ansiColors[color].css;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getUnderlineStyle(cell: IBufferCell): string {
|
private _getUnderlineStyle(cell: IBufferCell): string {
|
||||||
const cellData = cell as unknown as IAttributeData;
|
switch (cell.getUnderlineStyle()) {
|
||||||
switch (cellData.getUnderlineStyle()) {
|
|
||||||
case UnderlineStyle.SINGLE:
|
case UnderlineStyle.SINGLE:
|
||||||
return 'underline';
|
return 'underline';
|
||||||
case UnderlineStyle.DOUBLE:
|
case UnderlineStyle.DOUBLE:
|
||||||
@@ -727,6 +733,10 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
|
|||||||
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {
|
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {
|
||||||
const content: string[] = [];
|
const content: string[] = [];
|
||||||
|
|
||||||
|
if (attributesEquals(cell, oldCell)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const fgChanged = !equalFg(cell, oldCell);
|
const fgChanged = !equalFg(cell, oldCell);
|
||||||
const bgChanged = !equalBg(cell, oldCell);
|
const bgChanged = !equalBg(cell, oldCell);
|
||||||
const flagsChanged = !equalFlags(cell, oldCell);
|
const flagsChanged = !equalFlags(cell, oldCell);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"outDir": "../out",
|
"outDir": "../out",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
@@ -23,6 +22,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"types": [
|
"types": [
|
||||||
"../../../node_modules/@types/mocha"
|
"../../../node_modules/@types/mocha"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -203,6 +210,46 @@ test.describe('SerializeAddon', () => {
|
|||||||
strictEqual(await ctx.page.evaluate(`window.serialize.serialize();`), lines.join('\r\n'));
|
strictEqual(await ctx.page.evaluate(`window.serialize.serialize();`), lines.join('\r\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buffer cell attributesEquals compares underline style and color', async () => {
|
||||||
|
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
|
||||||
|
const sameAttributes = await ctx.page.evaluate(`(() => {
|
||||||
|
const line = window.term.buffer.active.getLine(0);
|
||||||
|
const cellA = line?.getCell(0);
|
||||||
|
const cellB = line?.getCell(1);
|
||||||
|
if (!cellA || !cellB) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cellA.attributesEquals(cellB);
|
||||||
|
})()`);
|
||||||
|
strictEqual(sameAttributes, true);
|
||||||
|
|
||||||
|
await ctx.page.evaluate(`window.term.reset()`);
|
||||||
|
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_GREEN)}B${sgr(NORMAL)}`);
|
||||||
|
const differentColor = await ctx.page.evaluate(`(() => {
|
||||||
|
const line = window.term.buffer.active.getLine(0);
|
||||||
|
const cellA = line?.getCell(0);
|
||||||
|
const cellB = line?.getCell(1);
|
||||||
|
if (!cellA || !cellB) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cellA.attributesEquals(cellB);
|
||||||
|
})()`);
|
||||||
|
strictEqual(differentColor, false);
|
||||||
|
|
||||||
|
await ctx.page.evaluate(`window.term.reset()`);
|
||||||
|
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINED, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
|
||||||
|
const differentStyle = await ctx.page.evaluate(`(() => {
|
||||||
|
const line = window.term.buffer.active.getLine(0);
|
||||||
|
const cellA = line?.getCell(0);
|
||||||
|
const cellB = line?.getCell(1);
|
||||||
|
if (!cellA || !cellB) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return cellA.attributesEquals(cellB);
|
||||||
|
})()`);
|
||||||
|
strictEqual(differentStyle, false);
|
||||||
|
});
|
||||||
|
|
||||||
test('serialize all rows of content with color256', async function(): Promise<any> {
|
test('serialize all rows of content with color256', async function(): Promise<any> {
|
||||||
const rows = 32;
|
const rows = 32;
|
||||||
const cols = 10;
|
const cols = 10;
|
||||||
@@ -602,6 +649,9 @@ const BOLD = '1';
|
|||||||
const DIM = '2';
|
const DIM = '2';
|
||||||
const ITALIC = '3';
|
const ITALIC = '3';
|
||||||
const UNDERLINED = '4';
|
const UNDERLINED = '4';
|
||||||
|
const UNDERLINE_DOUBLE = '4:2';
|
||||||
|
const UNDERLINE_COLOR_RED = '58;5;196';
|
||||||
|
const UNDERLINE_COLOR_GREEN = '58;5;46';
|
||||||
const BLINK = '5';
|
const BLINK = '5';
|
||||||
const INVERSE = '7';
|
const INVERSE = '7';
|
||||||
const INVISIBLE = '8';
|
const INVISIBLE = '8';
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "es6"],
|
"lib": ["dom", "es6"],
|
||||||
|
"rootDir": "..",
|
||||||
"outDir": "../out-benchmark",
|
"outDir": "../out-benchmark",
|
||||||
"types": ["../../../node_modules/@types/node"],
|
"types": ["../../../node_modules/@types/node"],
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"target": "es2015",
|
"target": "es2015",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": ["../../../src/common/*"],
|
"common/*": ["../../../src/common/*"],
|
||||||
"browser/*": ["../../../src/browser/*"],
|
"browser/*": ["../../../src/browser/*"],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "../../node_modules/.bin/tsc -p .",
|
"build": "../../node_modules/.bin/tsgo -p .",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
|
|||||||
@@ -9,19 +9,15 @@ import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm';
|
|||||||
import type { UnicodeGraphemesAddon as IUnicodeGraphemesApi } from '@xterm/addon-unicode-graphemes';
|
import type { UnicodeGraphemesAddon as IUnicodeGraphemesApi } from '@xterm/addon-unicode-graphemes';
|
||||||
import { UnicodeGraphemeProvider } from './UnicodeGraphemeProvider';
|
import { UnicodeGraphemeProvider } from './UnicodeGraphemeProvider';
|
||||||
|
|
||||||
export class UnicodeGraphemesAddon implements ITerminalAddon , IUnicodeGraphemesApi {
|
export class UnicodeGraphemesAddon implements ITerminalAddon, IUnicodeGraphemesApi {
|
||||||
private _provider15Graphemes?: UnicodeGraphemeProvider;
|
private _provider15Graphemes?: UnicodeGraphemeProvider;
|
||||||
private _provider15?: UnicodeGraphemeProvider;
|
private _provider15?: UnicodeGraphemeProvider;
|
||||||
private _unicode?: IUnicodeHandling;
|
private _unicode?: IUnicodeHandling;
|
||||||
private _oldVersion: string = '';
|
private _oldVersion: string = '';
|
||||||
|
|
||||||
public activate(terminal: Terminal): void {
|
public activate(terminal: Terminal): void {
|
||||||
if (! this._provider15) {
|
this._provider15 ??= new UnicodeGraphemeProvider(false);
|
||||||
this._provider15 = new UnicodeGraphemeProvider(false);
|
this._provider15Graphemes ??= new UnicodeGraphemeProvider(true);
|
||||||
}
|
|
||||||
if (! this._provider15Graphemes) {
|
|
||||||
this._provider15Graphemes = new UnicodeGraphemeProvider(true);
|
|
||||||
}
|
|
||||||
const unicode = terminal.unicode;
|
const unicode = terminal.unicode;
|
||||||
this._unicode = unicode;
|
this._unicode = unicode;
|
||||||
unicode.register(this._provider15);
|
unicode.register(this._provider15);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import UnicodeTrie from './unicode-trie';
|
import UnicodeTrie from './unicode-trie';
|
||||||
const trieRaw = "AAARAAAAAABwxwAAAb4LQfTtmw+sVmUdx58LL/ffe/kjzNBV80gW1F3yR+6CvbJiypoZa0paWmAWSluErSBbFtYkkuZykq6QamGJ4WRqo2kFGy6dYWtEq6G1MFAJbRbOVTQr+x7f5+x97q/n/3me87wXzm/3s+f/7/d7/p7znnvOlvGMbQM7wIPgEbAPHABPgcPgefAS+BfYwuv/F/Q2OulBxKcK6TMRPxu8FcwFbwcjYCFYDC4Cl4ArwNXgGvBJsA58UdBDwy+jbBO4La8DtoEd4H7wkNBuN+KPgn3gADgIngaHwFHwF/AyeAWMm4C+TGi3LdiJ/EnIex04A2RgFpgD5oKFYDG4CLwHXAo+IKSvAqt4/evA9bz9jWA6+Cq3dyvCP8HWNwX93wF38/ROcD94SCjP2+1B+BiPP4HwgOD/7xD/I08fRniMx48jPAFeBeuF+n29jE0G08FZvaPHYWZvh9mcEfAOjlhXx/qGfd2QvLO3zccmtMnzliC9lPt+GenD1nyMiK/LNf1cycs+gfAzPJ6vtxe4jhuQtx5sBLeA28G3eb3v8/Beif4HkPewxu5G6N/rMP4qfgEdvwZPgj+AZ8Cx3nYfxiE8Dk6AV0FfH/YEOB28AbwJDIPzQAtcAC4Gl/Z19F+J+NVCehWPr0b46b7RvixvdPg8yr7U10l/BfFN4La8DdgGdoAHwU/AI2AfOACeAofB8+AlcAKwfvyBKeCM/o7NrF9PXmdWv9/Ynot2I7ztIg8dF5I2a8i63CjZU+9Fm2Wcy4U4ZQVYyeOrwVoev57UuxHcJKRvFuJXgnU8/nUebtbYrKmpCUOx31P7UVNTU1NTU1NTU1OGLTz8Xr/77+W7+9vP0or0MxPMbXaizY8FW3sQ3wseB/t5/kGEh8DR/vbzwL8i/Af4Dy8fP8BYE0weaKenI/wV/DhrQG97JspngzlgLpgHzgPzwUhdVpfVZXVZXRa87HxwAVgQ4Pn5WEd85l5TUzOasvezFw/E3b/LoP9D4CpwrcTWWsGXNQOj748/G9k3G56d1KYxmbELwQbwKFiJvBM8nDWlHa5E+AOwCzwLzjkNeeB28NvTeB1OYyr0gQ1g99R23nGE50xj7MPgc+A+8K5Bxj4FHgB/G2z/T9XEzCZjd/S0WYX4Pc3/r/Nn5I0f6qQXIP5x8ENwBMyYyNhHJ3b0pOCuLrBvM941NTU1JyNHEp+BrC8dMyalt1/m3uWfhmeULzRGp9d3wf0WZSN8+prCr60Wz09tuNmx35sl9Y825HXvRN39KNveaL8flb9f913kbec67kHeTsR3gYcH2uV7ED4m2HhCYi/X9ZuBzvuXv0f8iKIfx5B/XCg7gTgbVPdvAsomCuWnD45eK28UyvL3Jt+s0fU2TVnOXJQvJHUWIb0ELAWXgCt4+UcMumSsEtpch/g6ouMGpG/ieZsc9N/q4YsLd3D9WyPbsWEbfNgO7hN82TWY/n8xKbmsC3xQsYKf+7sjrx2TH+u4H3vhx+OO6+X9hmtXN7C/4r15EPaeBs9J7L7YBeeED/k7wn8fbIf/Rji+yVizmd4vW6bB19cb/PU9w7MxMA60bzPHgM8+zG623+OnzOf55yNc3Gw/k303wveBy3nZcoTXgNVgLfiCRNcG5N3SbIebwZ08fhe4l8d/BH7K4yI/4+HPwS/BAfBks+PzIaHuc3x+ivSL4GUyZ68I6fwZYRNMG2qnz+Th2QjfMtTx/1zE5w61nyN+Q7C3aKgdin1dgrylYBn4INdhGn/Z2FfFiqH01/SUXMvnPD+jC+j85N/RqRhR/DYaS6T+P09K1mD+vzW+5zVqqeVUl0wTz2lK8odJHRGXfBufdGLSoSo3+ZFJ6sl0qvJVNmhI4z4i06mrZ6uT1le1z5h5HE3tMiHPtQ5javu+ItMXUr/MXpmwmyRL3D6U7UwIMyYfczGu0qdqb2pbhcw4xQkhWQBMerrZ/liXrGTbsQwTwrEu4zSczKLrd7fCSKiKn+zSo8BWXMe8myXWOivrUxWi60OPoQ7VIasbQ0S/Ukk3rZVullNhHEL1rYoxUF0PTfm6elWJzq54ZsU4z11ohOy0oxT2izFqCNj4TesXcWZo6+Jfqr1O+1O1beqDagypj2J9F1u2daucj3Eknmq/6PaHrK7Mb1o35DiW1a/a76LuhlDXZX25SOz11S33ErKxDb2/fc/bFKI6axskn+4/W90u9mOtbRf7smsoTdvOfwoRz0t6DaP9k81v6P7Re5aUQudTd303rX+bZzBl97/KR7E+Xbux9lLI+aNr1PfaYLpPDiW2/vrYTX1drMIeXbMye6HXlw8292Jl7ZXxLxRlxXbcaH9drjFlxfa3Qozx8NWRi834lPVZbD+SmN7EJPzc9TVCSVXXDps9L+513b2J7fMu176V2YOhx1A3JrJ8KrLxUumpcu5j/lYT+2tzLRVDZmhjO442a1Clu0ox9VPVXzE/lcS4V0k1D6LI1pJsz8fct9SGbO5l/rmKzTlvsxdj3IvRtC2uv0t1fotltvd2VaCy5Sp5m0EhnZG4CCNxXZrWp/VUIrOjapfnNw11ZNI0V/GWzKNuxtzGKKTEtJeR0NVmpojbtBuW5On0u0is9ZMxvU8ZM+8vEyadtu10oqtP9Q4rcJEm85+Two/QkpGwjI6YkgkhtUfzZOW6fFVexuRri+qj9TJJHZkdmW5abiu0rs6uj2TMfmx06bISUj9tZ9Lja8dVQtox6WpxTJKfW3M4MSTmvU4sWy1CU6BF4jIfdNeDjHWuO1lCWIm2Jr2ixNZvklD2fP0Q6+vsmO4hqN1hJvfDtV5G8mTlsvau4qPP1a64L1skT6QYEzEtq0PzGZOfCbSdSmcKTP7Qs86Ej/1hEpelaV6IMdT5ayu2+nT9tmnnO746XbLxE8t0qOrYtJWhmk9bvaLfsrotRVw1PnR+bcafSUKZ6Mps7smobybJLH2R6WqRkJa1DHV0UmbfUcksiSF0HExSpp+uY0zbTklMaCm7blzEtg8h1rNMXNaYi05ZXsbC75sQ/4+aUxFV2jL50Q3jE0rK2rVtN09By8OHoo1vH2LPSdE323mr2sdu0pUZiDkWLRKWnfeQY6taKzHF9n/GPv8jd/0/egiRvYMR24fU79iY3s9Qva9RlYR8n8HHtq9fMcT1HRWfdZXiHd9YInt/iI4PTaf+BimXKvdXYU+3hlRpHzs2dVK/cxhDn+xs0I2jzxjL5kpXz1VU72aLtkK/97sALKyQqu25SshvG6h08/cLrlKswRklKXvvXfa+pZt+y8nah5YUv2Oo/ap/X2URdRfico9K69hcp6r6XaCz5Wo/hs/iNTGF6N6tV92/9ZS0Wba9SlT3pKF/e6W674+x9ly+VRL73cPU8ygb31D3eSqfVd+iqET0y3YMYojoO11XqrTt2nPxmeq1HYeqxkmUMt8DiesjpoTSr+qDrD+qPZDiOZxMdH0pRPX8MFUfQtv0Xbs+a1a1NnRryNZ/2+tsaPG5ZoX0RXZei88yZGdo4UMPj/cwv/kMJboxLISuQbE+1VW12Mx7FWOrW3M9Hv7Y+uxyraPSo8B2TGPuLdOeZha+hBKf8Sjsm/oR+7pmsx/oeOraFWdXleeV6oyl41zm+mgSuq9C6ox1TsU8D+m4dwMmf8v2nz7Tm+fYfj7HV1K/x1HWjquvY+2dllxM64ue87Su772zzbXIVC+WxLZTRR9MdkMTypZNH1z6G0tUvoccwxA+hfLNdV+a7MaQqscztMi+7QnxDZXvd1dldWQOyMbApb1Jd2h91Ffx+y9Xfb7tClokboOvrRhrbVpFFO8z+65t2/u4su9MUx028znH01/TGVDmHAj13W1o+1USw+eUfYtpO+b82rRNsb6oPpV+1fdBqddB6n3WDXvdJDZrJ0QfQp6bsc/kqq4BIddHWXGdN1pmWveh58F1zYUW1zmOITHXWOg1XrZvZSWUf77tq1ofqear6muaT1lIQp3bofabSafJVlnfYo9B6LGr8uzz2Xchvzfw+T9PlgiV/A8=";
|
const trieRaw = "AAARAAAAAABwxwAAAb4LQfTtmw+sVmUdx58LL/ffe/kjzNBV80gW1F3yR+6CvbJiypoZa0paWmAWSluErSBbFtYkkuZykq6QamGJ4WRqo2kFGy6dYWtEq6G1MFAJbRbOVTQr+x7f5+x97q/n/3me87wXzm/3s+f/7/d7/p7znnvOlvGMbQM7wIPgEbAPHABPgcPgefAS+BfYwuv/F/Q2OulBxKcK6TMRPxu8FcwFbwcjYCFYDC4Cl4ArwNXgGvBJsA58UdBDwy+jbBO4La8DtoEd4H7wkNBuN+KPgn3gADgIngaHwFHwF/AyeAWMm4C+TGi3LdiJ/EnIex04A2RgFpgD5oKFYDG4CLwHXAo+IKSvAqt4/evA9bz9jWA6+Cq3dyvCP8HWNwX93wF38/ROcD94SCjP2+1B+BiPP4HwgOD/7xD/I08fRniMx48jPAFeBeuF+n29jE0G08FZvaPHYWZvh9mcEfAOjlhXx/qGfd2QvLO3zccmtMnzliC9lPt+GenD1nyMiK/LNf1cycs+gfAzPJ6vtxe4jhuQtx5sBLeA28G3eb3v8/Beif4HkPewxu5G6N/rMP4qfgEdvwZPgj+AZ8Cx3nYfxiE8Dk6AV0FfH/YEOB28AbwJDIPzQAtcAC4Gl/Z19F+J+NVCehWPr0b46b7RvixvdPg8yr7U10l/BfFN4La8DdgGdoAHwU/AI2AfOACeAofB8+AlcAKwfvyBKeCM/o7NrF9PXmdWv9/Ynot2I7ztIg8dF5I2a8i63CjZU+9Fm2Wcy4U4ZQVYyeOrwVoev57UuxHcJKRvFuJXgnU8/nUebtbYrKmpCUOx31P7UVNTU1NTU1NTU1OGLTz8Xr/77+W7+9vP0or0MxPMbXaizY8FW3sQ3wseB/t5/kGEh8DR/vbzwL8i/Af4Dy8fP8BYE0weaKenI/wV/DhrQG97JspngzlgLpgHzgPzwUhdVpfVZXVZXRa87HxwAVgQ4Pn5WEd85l5TUzOasvezFw/E3b/LoP9D4CpwrcTWWsGXNQOj748/G9k3G56d1KYxmbELwQbwKFiJvBM8nDWlHa5E+AOwCzwLzjkNeeB28NvTeB1OYyr0gQ1g99R23nGE50xj7MPgc+A+8K5Bxj4FHgB/G2z/T9XEzCZjd/S0WYX4Pc3/r/Nn5I0f6qQXIP5x8ENwBMyYyNhHJ3b0pOCuLrBvM941NTU1JyNHEp+BrC8dMyalt1/m3uWfhmeULzRGp9d3wf0WZSN8+prCr60Wz09tuNmx35sl9Y825HXvRN39KNveaL8flb9f913kbec67kHeTsR3gYcH2uV7ED4m2HhCYi/X9ZuBzvuXv0f8iKIfx5B/XCg7gTgbVPdvAsomCuWnD45eK28UyvL3Jt+s0fU2TVnOXJQvJHUWIb0ELAWXgCt4+UcMumSsEtpch/g6ouMGpG/ieZsc9N/q4YsLd3D9WyPbsWEbfNgO7hN82TWY/n8xKbmsC3xQsYKf+7sjrx2TH+u4H3vhx+OO6+X9hmtXN7C/4r15EPaeBs9J7L7YBeeED/k7wn8fbIf/Rji+yVizmd4vW6bB19cb/PU9w7MxMA60bzPHgM8+zG623+OnzOf55yNc3Gw/k303wveBy3nZcoTXgNVgLfiCRNcG5N3SbIebwZ08fhe4l8d/BH7K4yI/4+HPwS/BAfBks+PzIaHuc3x+ivSL4GUyZ68I6fwZYRNMG2qnz+Th2QjfMtTx/1zE5w61nyN+Q7C3aKgdin1dgrylYBn4INdhGn/Z2FfFiqH01/SUXMvnPD+jC+j85N/RqRhR/DYaS6T+P09K1mD+vzW+5zVqqeVUl0wTz2lK8odJHRGXfBufdGLSoSo3+ZFJ6sl0qvJVNmhI4z4i06mrZ6uT1le1z5h5HE3tMiHPtQ5javu+ItMXUr/MXpmwmyRL3D6U7UwIMyYfczGu0qdqb2pbhcw4xQkhWQBMerrZ/liXrGTbsQwTwrEu4zSczKLrd7fCSKiKn+zSo8BWXMe8myXWOivrUxWi60OPoQ7VIasbQ0S/Ukk3rZVullNhHEL1rYoxUF0PTfm6elWJzq54ZsU4z11ohOy0oxT2izFqCNj4TesXcWZo6+Jfqr1O+1O1beqDagypj2J9F1u2daucj3Eknmq/6PaHrK7Mb1o35DiW1a/a76LuhlDXZX25SOz11S33ErKxDb2/fc/bFKI6axskn+4/W90u9mOtbRf7smsoTdvOfwoRz0t6DaP9k81v6P7Re5aUQudTd303rX+bZzBl97/KR7E+Xbux9lLI+aNr1PfaYLpPDiW2/vrYTX1drMIeXbMye6HXlw8292Jl7ZXxLxRlxXbcaH9drjFlxfa3Qozx8NWRi834lPVZbD+SmN7EJPzc9TVCSVXXDps9L+513b2J7fMu176V2YOhx1A3JrJ8KrLxUumpcu5j/lYT+2tzLRVDZmhjO442a1Clu0ox9VPVXzE/lcS4V0k1D6LI1pJsz8fct9SGbO5l/rmKzTlvsxdj3IvRtC2uv0t1fotltvd2VaCy5Sp5m0EhnZG4CCNxXZrWp/VUIrOjapfnNw11ZNI0V/GWzKNuxtzGKKTEtJeR0NVmpojbtBuW5On0u0is9ZMxvU8ZM+8vEyadtu10oqtP9Q4rcJEm85+Two/QkpGwjI6YkgkhtUfzZOW6fFVexuRri+qj9TJJHZkdmW5abiu0rs6uj2TMfmx06bISUj9tZ9Lja8dVQtox6WpxTJKfW3M4MSTmvU4sWy1CU6BF4jIfdNeDjHWuO1lCWIm2Jr2ixNZvklD2fP0Q6+vsmO4hqN1hJvfDtV5G8mTlsvau4qPP1a64L1skT6QYEzEtq0PzGZOfCbSdSmcKTP7Qs86Ej/1hEpelaV6IMdT5ayu2+nT9tmnnO746XbLxE8t0qOrYtJWhmk9bvaLfsrotRVw1PnR+bcafSUKZ6Mps7smobybJLH2R6WqRkJa1DHV0UmbfUcksiSF0HExSpp+uY0zbTklMaCm7blzEtg8h1rNMXNaYi05ZXsbC75sQ/4+aUxFV2jL50Q3jE0rK2rVtN09By8OHoo1vH2LPSdE323mr2sdu0pUZiDkWLRKWnfeQY6taKzHF9n/GPv8jd/0/egiRvYMR24fU79iY3s9Qva9RlYR8n8HHtq9fMcT1HRWfdZXiHd9YInt/iI4PTaf+BimXKvdXYU+3hlRpHzs2dVK/cxhDn+xs0I2jzxjL5kpXz1VU72aLtkK/97sALKyQqu25SshvG6h08/cLrlKswRklKXvvXfa+pZt+y8nah5YUv2Oo/ap/X2URdRfico9K69hcp6r6XaCz5Wo/hs/iNTGF6N6tV92/9ZS0Wba9SlT3pKF/e6W674+x9ly+VRL73cPU8ygb31D3eSqfVd+iqET0y3YMYojoO11XqrTt2nPxmeq1HYeqxkmUMt8DiesjpoTSr+qDrD+qPZDiOZxMdH0pRPX8MFUfQtv0Xbs+a1a1NnRryNZ/2+tsaPG5ZoX0RXZei88yZGdo4UMPj/cwv/kMJboxLISuQbE+1VW12Mx7FWOrW3M9Hv7Y+uxyraPSo8B2TGPuLdOeZha+hBKf8Sjsm/oR+7pmsx/oeOraFWdXleeV6oyl41zm+mgSuq9C6ox1TsU8D+m4dwMmf8v2nz7Tm+fYfj7HV1K/x1HWjquvY+2dllxM64ue87Su772zzbXIVC+WxLZTRR9MdkMTypZNH1z6G0tUvoccwxA+hfLNdV+a7MaQqscztMi+7QnxDZXvd1dldWQOyMbApb1Jd2h91Ffx+y9Xfb7tClokboOvrRhrbVpFFO8z+65t2/u4su9MUx028znH01/TGVDmHAj13W1o+1USw+eUfYtpO+b82rRNsb6oPpV+1fdBqddB6n3WDXvdJDZrJ0QfQp6bsc/kqq4BIddHWXGdN1pmWveh58F1zYUW1zmOITHXWOg1XrZvZSWUf77tq1ofqear6muaT1lIQp3bofabSafJVlnfYo9B6LGr8uzz2Xchvzfw+T9PlgiV/A8=";
|
||||||
|
|
||||||
declare const Buffer: any;
|
declare const Buffer: { from(s: string, encoding: string): Uint8Array } | undefined;
|
||||||
function _dec(s: string): Uint8Array {
|
function _dec(s: string): Uint8Array {
|
||||||
if (typeof Buffer !== 'undefined') return Buffer.from(s, 'base64');
|
if (typeof Buffer !== 'undefined') return Buffer.from(s, 'base64');
|
||||||
const bs = atob(s);
|
const bs = atob(s);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "../../node_modules/.bin/tsc -p .",
|
"build": "../../node_modules/.bin/tsgo -p .",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { Terminal, ITerminalAddon } from '@xterm/xterm';
|
|||||||
import type { Unicode11Addon as IUnicode11Api } from '@xterm/addon-unicode11';
|
import type { Unicode11Addon as IUnicode11Api } from '@xterm/addon-unicode11';
|
||||||
import { UnicodeV11 } from './UnicodeV11';
|
import { UnicodeV11 } from './UnicodeV11';
|
||||||
|
|
||||||
export class Unicode11Addon implements ITerminalAddon , IUnicode11Api {
|
export class Unicode11Addon implements ITerminalAddon, IUnicode11Api {
|
||||||
public activate(terminal: Terminal): void {
|
public activate(terminal: Terminal): void {
|
||||||
terminal.unicode.register(new UnicodeV11());
|
terminal.unicode.register(new UnicodeV11());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,15 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
],
|
],
|
||||||
"@xterm/addon-unicode11": [
|
"@xterm/addon-unicode11": [
|
||||||
"../typings/addon-unicode11.d.ts"
|
"../typings/addon-unicode11.d.ts"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"types": [
|
"types": [
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'ChromeStable',
|
name: 'Chromium',
|
||||||
use: {
|
use: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium'
|
||||||
channel: 'chrome'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
"outDir": "../out-test",
|
"outDir": "../out-test",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": [
|
"common/*": [
|
||||||
"../../../src/common/*"
|
"../../../src/common/*"
|
||||||
],
|
],
|
||||||
"browser/*": [
|
"browser/*": [
|
||||||
"../../../src/browser/*"
|
"../../../src/browser/*"
|
||||||
|
],
|
||||||
|
"*": [
|
||||||
|
"./*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"xterm.js"
|
"xterm.js"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "../../node_modules/.bin/tsc -p .",
|
"build": "../../node_modules/.bin/tsgo -p .",
|
||||||
"prepackage": "npm run build",
|
"prepackage": "npm run build",
|
||||||
"package": "../../node_modules/.bin/webpack",
|
"package": "../../node_modules/.bin/webpack",
|
||||||
"prepublishOnly": "npm run package",
|
"prepublishOnly": "npm run package",
|
||||||
|
|||||||
@@ -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,7 +76,7 @@ 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' }]);
|
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||||
|
|
||||||
@@ -88,7 +88,11 @@ test.describe('WebFontsAddon', () => {
|
|||||||
|
|
||||||
// safari loads the font, firefox & chrome dont
|
// safari loads the font, firefox & chrome dont
|
||||||
if (browser.browserType().name() === 'webkit') {
|
if (browser.browserType().name() === 'webkit') {
|
||||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
try {
|
||||||
|
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]);
|
||||||
|
} catch (e) {
|
||||||
|
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||||
}
|
}
|
||||||
@@ -103,7 +107,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(`
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user