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

|

|
||||||
@@ -25,14 +26,14 @@ const customSettings: IImageAddonOptions = {
|
|||||||
pixelLimit: 16777216, // max. pixel size of a single image
|
pixelLimit: 16777216, // max. pixel size of a single image
|
||||||
sixelSupport: true, // enable sixel support
|
sixelSupport: true, // enable sixel support
|
||||||
sixelScrolling: true, // whether to scroll on image output
|
sixelScrolling: true, // whether to scroll on image output
|
||||||
sixelPaletteLimit: 256, // initial sixel palette size
|
sixelPaletteLimit: 4096, // initial sixel palette size
|
||||||
sixelSizeLimit: 25000000, // size limit of a single sixel sequence
|
sixelSizeLimit: 33554432, // size limit of a single sixel sequence
|
||||||
storageLimit: 128, // FIFO storage limit in MB
|
storageLimit: 128, // FIFO storage limit in MB
|
||||||
showPlaceholder: true, // whether to show a placeholder for evicted images
|
showPlaceholder: true, // whether to show a placeholder for evicted images
|
||||||
iipSupport: true, // enable iTerm IIP support
|
iipSupport: true, // enable iTerm IIP support
|
||||||
iipSizeLimit: 20000000, // size limit of a single IIP sequence
|
iipSizeLimit: 33554432, // size limit of a single IIP sequence
|
||||||
kittySupport: true, // enable Kitty graphics support
|
kittySupport: true, // enable Kitty graphics support
|
||||||
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
|
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialization
|
// initialization
|
||||||
@@ -43,9 +44,6 @@ terminal.loadAddon(imageAddon);
|
|||||||
|
|
||||||
### General Notes
|
### General Notes
|
||||||
|
|
||||||
- *IMPORTANT:* The worker approach as done in previous releases got removed.
|
|
||||||
The addon contructor no longer expects a worker path as first argument.
|
|
||||||
|
|
||||||
- By default the addon will activate these `windowOptions` reports on the terminal:
|
- By default the addon will activate these `windowOptions` reports on the terminal:
|
||||||
- getWinSizePixels (CSI 14 t)
|
- getWinSizePixels (CSI 14 t)
|
||||||
- getCellSizePixels (CSI 16 t)
|
- getCellSizePixels (CSI 16 t)
|
||||||
@@ -73,7 +71,7 @@ terminal.loadAddon(imageAddon);
|
|||||||
|
|
||||||
- **Cursor Positioning**
|
- **Cursor Positioning**
|
||||||
If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode).
|
If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode).
|
||||||
Other cursor positioning modes as used by xterm or mintty are not supported.
|
Other cursor positioning modes as used by xterm or mintty are not supported for sixel.
|
||||||
|
|
||||||
- **SIXEL Palette Handling**
|
- **SIXEL Palette Handling**
|
||||||
By default the addon limits the palette size to 256 registers (as demanded by the DEC specification).
|
By default the addon limits the palette size to 256 registers (as demanded by the DEC specification).
|
||||||
@@ -92,7 +90,7 @@ terminal.loadAddon(imageAddon);
|
|||||||
therefore palette animations cannot be used.
|
therefore palette animations cannot be used.
|
||||||
|
|
||||||
- **SIXEL Raster Attributes Handling**
|
- **SIXEL Raster Attributes Handling**
|
||||||
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extend. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extend. Both is discouraged by the >30 years old specification.
|
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extent. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extent. Both is discouraged by the >30 years old specification.
|
||||||
|
|
||||||
Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel.
|
Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel.
|
||||||
|
|
||||||
@@ -100,17 +98,31 @@ terminal.loadAddon(imageAddon);
|
|||||||
Set by default, change it with `{iipSupport: true}`.
|
Set by default, change it with `{iipSupport: true}`.
|
||||||
|
|
||||||
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
|
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
|
||||||
- Supported formats: PNG, JPEG and GIF
|
- Supported formats: PNG, JPEG, GIF, QOI, WEBP and AVIF
|
||||||
- No animation support.
|
- No animation support.
|
||||||
- Image type hinting is not supported (always deducted from data header).
|
- Image type hinting is not supported (always deducted from data header).
|
||||||
- File download is not supported.
|
- File download is not supported.
|
||||||
- Filename gets parsed but not used.
|
- Filename gets parsed but not used.
|
||||||
- Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed).
|
- Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed).
|
||||||
- Payload size may not exceed CEIL(sizeParameter * 4 / 3).
|
|
||||||
- Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`).
|
- Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`).
|
||||||
- Image pixel size is restricted by `pixelLimit` (pre- and post resizing).
|
- Image pixel size is restricted by `pixelLimit` (pre- and post resizing).
|
||||||
- Size parameter is restricted by `iipSizeLimit`.
|
- Playload size is restricted by `iipSizeLimit`.
|
||||||
- Cursor positioning behaves the same as for sixel (see above).
|
- size parameter is not mandatory anymore.
|
||||||
|
- Cursor gets positioned at the next cell of the last image line.
|
||||||
|
- ReportCellSize sequence is supported.
|
||||||
|
- Hi-res display support is currently limited (image gets blurred).
|
||||||
|
|
||||||
|
- **Note on IIP image format support**
|
||||||
|
For all image formats except QOI the addon only does the dimension extraction and leaves
|
||||||
|
the image decoding to the underlying browser engine. Note that some formats are implemented
|
||||||
|
and enabled with different constraints by browser vendors, especially newer formats
|
||||||
|
(e.g. under Firefox you might need to change the setting _image.avif.compliance_strictness_ to 0
|
||||||
|
to get broader AVIF support).
|
||||||
|
|
||||||
|
- **Kitty Graphics Support (TGP)**
|
||||||
|
Set by default, change it with `{kittySupport: true}`.
|
||||||
|
|
||||||
|
Note that the kitty graphics support is still WIP.
|
||||||
|
|
||||||
|
|
||||||
### Storage and Drawing Settings
|
### Storage and Drawing Settings
|
||||||
@@ -127,6 +139,7 @@ The addon exposes two properties to interact with the storage limits at runtime:
|
|||||||
By default the addon will show a placeholder pattern for evicted images that are still part
|
By default the addon will show a placeholder pattern for evicted images that are still part
|
||||||
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
|
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
|
||||||
|
|
||||||
|
|
||||||
### Image Data Retrieval
|
### Image Data Retrieval
|
||||||
|
|
||||||
The addon provides the following API endpoints to retrieve raw image data as canvas:
|
The addon provides the following API endpoints to retrieve raw image data as canvas:
|
||||||
@@ -140,11 +153,12 @@ The addon provides the following API endpoints to retrieve raw image data as can
|
|||||||
The buffer position is the 0-based absolute index (including scrollback at top).
|
The buffer position is the 0-based absolute index (including scrollback at top).
|
||||||
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
|
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
|
||||||
|
|
||||||
|
|
||||||
### Memory Usage
|
### Memory Usage
|
||||||
|
|
||||||
The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps:
|
The addon does most image processing in JavaScript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, let's look at the data flow and processing steps:
|
||||||
- Incomming image data chunk at `term.write` (terminal)
|
- Incoming image data chunk at `term.write` (terminal)
|
||||||
`term.write` might stock up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
|
`term.write` might stack up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
|
||||||
- Sequence Parser (terminal)
|
- Sequence Parser (terminal)
|
||||||
The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB).
|
The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB).
|
||||||
- Sequence Handler - Chunk Decoding (addon)
|
- Sequence Handler - Chunk Decoding (addon)
|
||||||
@@ -170,18 +184,18 @@ const totalActive = storageBytes + decodingBytes;
|
|||||||
```
|
```
|
||||||
|
|
||||||
Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions.
|
Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions.
|
||||||
Further note that the formulas above do not respect the Javascript object's overhead. Compared to the raw buffer needs the book keeping by these objects is rather small (<<5%).
|
Further note that the formulas above do not respect the JavaScript object's overhead. Compared to the raw buffer needs the bookkeeping by these objects is rather small (<<5%).
|
||||||
|
|
||||||
_Why should I care about memory usage at all?_
|
_Why should I care about memory usage at all?_
|
||||||
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the Javascript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
|
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the JavaScript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
|
||||||
|
|
||||||
_How can I adjust the memory usage?_
|
_How can I adjust the memory usage?_
|
||||||
- `pixelLimit`
|
- `pixelLimit`
|
||||||
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page.
|
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage only peaks temporarily, a rather high value should work even with multiple terminals in one page.
|
||||||
- `storageLimit`
|
- `storageLimit`
|
||||||
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for memory peaking for decoding.
|
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for peak memory use during decoding.
|
||||||
- `sixelSizeLimit`
|
- `sixelSizeLimit | iipSizeLimit | kittySizeLimit`
|
||||||
A constructor setting. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken SIXEL sequences at an earlier phase, thus may stop the decoder from entering its memory intensive task for potentially invalid data.
|
Constructor settings. This has only a small impact on peak memory during decoding. It is meant to avoid processing of overly big or broken image sequences at an earlier phase, thus may stop the invoked decoders from entering memory intensive tasks for potentially invalid data.
|
||||||
|
|
||||||
|
|
||||||
### Terminal Interaction
|
### Terminal Interaction
|
||||||
@@ -211,23 +225,5 @@ _How can I adjust the memory usage?_
|
|||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
- Sixel support and image handling in xterm.js is considered beta quality.
|
- Sixel and IIP support and image handling in xterm.js is considered beta quality.
|
||||||
- IIP support is in alpha stage. Please file a bug for any awkwardities.
|
- Kitty support is in alpha stage. Please file a bug for any awkwardities or missing features.
|
||||||
|
|
||||||
|
|
||||||
### Changelog
|
|
||||||
|
|
||||||
- 0.5.0 integrate with xtermjs base repo (at v0.4.3)
|
|
||||||
- 0.4.3 defer canvas creation
|
|
||||||
- 0.4.2 fix image canvas resize
|
|
||||||
- 0.4.1 compat release for xterm.js 5.2.0
|
|
||||||
- 0.4.0 IIP support
|
|
||||||
- 0.3.1 compat release for xterm.js 5.1.0
|
|
||||||
- 0.3.0 important change: worker removed from addon
|
|
||||||
- 0.2.0 compat release for xterm.js 5.0.0
|
|
||||||
- 0.1.3 bugfix: avoid striping
|
|
||||||
- 0.1.2 bugfix: reset clear flag
|
|
||||||
- 0.1.1 bugfixes:
|
|
||||||
- clear sticky image tiles on render
|
|
||||||
- create folder from bootstrap.sh
|
|
||||||
- fix peer dependency in package.json
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -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.3.0"
|
"xterm-wasm-parts": "^0.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,20 +4,26 @@
|
|||||||
*/
|
*/
|
||||||
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 Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||||
|
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||||
|
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
|
||||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||||
|
|
||||||
// limit hold memory in base64 decoder (encoded bytes)
|
// Local const enum mirror - esbuild can't inline const enums from external packages
|
||||||
const KEEP_DATA = 4194304;
|
const enum DecoderConst {
|
||||||
const INITIAL_DATA = 1048576;
|
// Limit held memory in base64 decoder (encoded bytes).
|
||||||
|
KEEP_DATA = 4194304,
|
||||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
// Initial buffer allocation for the decoder.
|
||||||
const DECODER_OK: DecodeStatus.OK = 0;
|
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 = {
|
||||||
|
type: SequenceType.INVALID,
|
||||||
name: 'Unnamed file',
|
name: 'Unnamed file',
|
||||||
size: 0,
|
size: 0,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
@@ -32,25 +38,30 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
private _hp = new HeaderParser();
|
private _hp = new HeaderParser();
|
||||||
private _header: IHeaderFields = DEFAULT_HEADER;
|
private _header: IHeaderFields = DEFAULT_HEADER;
|
||||||
private _dec: Base64Decoder;
|
private _dec: Base64Decoder;
|
||||||
private _metrics = UNSUPPORTED_TYPE;
|
private _qoiDec: QoiDecoder;
|
||||||
|
private _isMultipart = false;
|
||||||
|
private _abortMulti = false;
|
||||||
|
|
||||||
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 maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
||||||
const initialBytes = Math.min(INITIAL_DATA, maxEncodedBytes);
|
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
||||||
this._dec = new Base64Decoder(KEEP_DATA, maxEncodedBytes, initialBytes);
|
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
|
||||||
|
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {}
|
public reset(): void {
|
||||||
|
this._hp.reset();
|
||||||
|
this._dec.release();
|
||||||
|
this._qoiDec.release();
|
||||||
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
this._aborted = false;
|
this._aborted = false;
|
||||||
this._header = DEFAULT_HEADER;
|
|
||||||
this._metrics = UNSUPPORTED_TYPE;
|
|
||||||
this._hp.reset();
|
this._hp.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +69,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.subarray(start, end)) !== DECODER_OK) {
|
if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {
|
||||||
this._dec.release();
|
this._dec.release();
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
}
|
}
|
||||||
@@ -69,15 +80,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dataPos > 0) {
|
if (dataPos > 0) {
|
||||||
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
const seqType = this._hp.fields.type;
|
||||||
if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) {
|
if (seqType === SequenceType.FILE) {
|
||||||
|
if (this._isMultipart) {
|
||||||
|
this._isMultipart = false;
|
||||||
|
this._abortMulti = false;
|
||||||
|
this._dec.release();
|
||||||
|
}
|
||||||
|
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||||
|
if (!this._header.inline) {
|
||||||
|
this._aborted = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._dec.init();
|
||||||
|
} else if (this._abortMulti) {
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._dec.init();
|
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {
|
||||||
if (this._dec.put(data.subarray(dataPos, end)) !== DECODER_OK) {
|
|
||||||
this._dec.release();
|
this._dec.release();
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
|
if (this._isMultipart) this._abortMulti = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,24 +109,67 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
public end(success: boolean): boolean | Promise<boolean> {
|
public end(success: boolean): boolean | Promise<boolean> {
|
||||||
if (this._aborted) return true;
|
if (this._aborted) return true;
|
||||||
|
|
||||||
|
if (this._hp.state !== HeaderState.END) {
|
||||||
|
if (this._hp.end()) return true;
|
||||||
|
}
|
||||||
|
const seqType = this._hp.fields.type;
|
||||||
|
|
||||||
|
if (seqType === SequenceType.FILEPART) return true;
|
||||||
|
|
||||||
|
if (seqType === SequenceType.REPORTCELLSIZE) {
|
||||||
|
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
|
||||||
|
let w = CELL_SIZE_DEFAULT.width;
|
||||||
|
let h = CELL_SIZE_DEFAULT.height;
|
||||||
|
if (this._renderer.dimensions) {
|
||||||
|
w = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
|
||||||
|
h = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
|
||||||
|
}
|
||||||
|
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
|
||||||
|
const report = `\x1b]1337;ReportCellSize=${h.toFixed(3)};${w.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
|
||||||
|
this._coreTerminal.input(report, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seqType === SequenceType.MULTIPARTFILE) {
|
||||||
|
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||||
|
this._isMultipart = true;
|
||||||
|
this._abortMulti = false;
|
||||||
|
this._dec.release();
|
||||||
|
this._dec.init();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seqType === SequenceType.FILEEND) {
|
||||||
|
if (!this._isMultipart) return true;
|
||||||
|
this._isMultipart = false;
|
||||||
|
if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
|
||||||
|
|
||||||
let w = 0;
|
let w = 0;
|
||||||
let h = 0;
|
let h = 0;
|
||||||
|
|
||||||
// early exit condition chain
|
// early exit condition chain
|
||||||
let cond: number | boolean = true;
|
let cond: number | boolean;
|
||||||
|
let metrics = UNSUPPORTED_TYPE;
|
||||||
if (cond = success) {
|
if (cond = success) {
|
||||||
if (cond = !this._dec.end()) {
|
if (cond = !this._dec.end()) {
|
||||||
if (cond = this._dec.data8.length === this._header.size) {
|
metrics = imageType(this._dec.data8);
|
||||||
this._metrics = imageType(this._dec.data8);
|
if (cond = metrics.mime !== 'unsupported') {
|
||||||
if (cond = this._metrics.mime !== 'unsupported') {
|
w = metrics.width;
|
||||||
w = this._metrics.width;
|
h = metrics.height;
|
||||||
h = this._metrics.height;
|
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
[w, h] = this._resize(w, h).map(Math.floor);
|
||||||
[w, h] = this._resize(w, h).map(Math.floor);
|
cond = w && h && w * h < this._opts.pixelLimit;
|
||||||
cond = w && h && w * h < this._opts.pixelLimit;
|
} else {
|
||||||
}
|
console.warn(`IIP: image dimension issue ${metrics.width}x${metrics.height}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('IIP: unsupported image type');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('IIP: error during BASE64 decoding');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cond) {
|
if (!cond) {
|
||||||
@@ -111,31 +177,35 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it
|
let blob: Blob | ImageData;
|
||||||
const blob = new Blob([this._dec.data8 as Uint8Array<ArrayBuffer>], { type: this._metrics.mime });
|
if (metrics.mime === 'image/qoi') {
|
||||||
this._dec.release();
|
const data = this._qoiDec.decode(this._dec.data8);
|
||||||
|
blob = new ImageData(
|
||||||
if (!window.createImageBitmap) {
|
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
||||||
const url = URL.createObjectURL(blob);
|
this._qoiDec.width,
|
||||||
const img = new Image();
|
this._qoiDec.height
|
||||||
return new Promise<boolean>(r => {
|
);
|
||||||
img.addEventListener('load', () => {
|
this._qoiDec.release();
|
||||||
URL.revokeObjectURL(url);
|
if (w === this._qoiDec.width && h === this._qoiDec.height) {
|
||||||
const canvas = ImageRenderer.createCanvas(window.document, w, h);
|
// use fast-path if we don't need to rescale
|
||||||
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
|
this._dec.release();
|
||||||
this._storage.addImage(canvas);
|
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
|
||||||
r(true);
|
canvas.getContext('2d')?.putImageData(blob, 0, 0);
|
||||||
});
|
this._storage.addImage(canvas);
|
||||||
img.src = url;
|
return true;
|
||||||
// sanity measure to avoid terminal blocking from dangling promise
|
}
|
||||||
// happens from corrupt data (onload never gets fired)
|
} else {
|
||||||
setTimeout(() => r(true), 1000);
|
blob = new Blob([this._dec.data8], { type: 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);
|
||||||
return true;
|
return true;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.warn(`IIP: decoding error ${metrics.mime} ${metrics.width}x${metrics.height}`, e);
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,8 +231,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
|||||||
|
|
||||||
private _dim(s: string, total: number, cdim: number): number {
|
private _dim(s: string, total: number, cdim: number): number {
|
||||||
if (s === 'auto') return 0;
|
if (s === 'auto') return 0;
|
||||||
if (s.endsWith('%')) return parseInt(s.slice(0, -1)) * total / 100;
|
if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;
|
||||||
if (s.endsWith('px')) return parseInt(s.slice(0, -2));
|
if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);
|
||||||
return parseInt(s) * cdim;
|
return parseInt(s, 10) * cdim;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser';
|
import { HeaderParser, HeaderState, IHeaderFields, SequenceType } from './IIPHeaderParser';
|
||||||
|
|
||||||
|
|
||||||
const CASES: [string, IHeaderFields][] = [
|
const CASES: [string, IHeaderFields][] = [
|
||||||
['File=size=123456;name=dGVzdA==:', {name: 'test', size: 123456}],
|
['File=size=123456;name=dGVzdA==:', {type: SequenceType.FILE, name: 'test', size: 123456}],
|
||||||
['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}],
|
['File=size=123456;name=dGVzdA:', {type: SequenceType.FILE, name: 'test', size: 123456}],
|
||||||
// utf-8 encoding in name
|
// utf-8 encoding in name
|
||||||
['File=size=123456;name=w7xtbMOkdXTDnw==:', {name: 'ümläutß', size: 123456}],
|
['File=size=123456;name=w7xtbMOkdXTDnw==:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
|
||||||
['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}],
|
['File=size=123456;name=w7xtbMOkdXTDnw:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
|
||||||
// full header spec
|
// full header spec
|
||||||
[
|
[
|
||||||
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||||
{
|
{
|
||||||
|
type: SequenceType.FILE,
|
||||||
inline: 1,
|
inline: 1,
|
||||||
width: '10px',
|
width: '10px',
|
||||||
height: '20%',
|
height: '20%',
|
||||||
@@ -28,6 +29,7 @@ const CASES: [string, IHeaderFields][] = [
|
|||||||
[
|
[
|
||||||
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||||
{
|
{
|
||||||
|
type: SequenceType.FILE,
|
||||||
inline: 1,
|
inline: 1,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
height: '20',
|
height: '20',
|
||||||
@@ -101,7 +103,7 @@ describe('IIPHeaderParser', () => {
|
|||||||
let res = hp.parse(inp, 0, inp.length);
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
assert.strictEqual(res, inp.length);
|
assert.strictEqual(res, inp.length);
|
||||||
assert.strictEqual(hp.state, HeaderState.END);
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
assert.deepEqual(hp.fields, {name: 'test', size: 0});
|
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: 'test', size: 0});
|
||||||
hp.reset();
|
hp.reset();
|
||||||
inp = fromBs(CASES[0][0]);
|
inp = fromBs(CASES[0][0]);
|
||||||
res = hp.parse(inp, 0, inp.length);
|
res = hp.parse(inp, 0, inp.length);
|
||||||
@@ -115,7 +117,7 @@ describe('IIPHeaderParser', () => {
|
|||||||
let res = hp.parse(inp, 0, inp.length);
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
assert.strictEqual(res, inp.length);
|
assert.strictEqual(res, inp.length);
|
||||||
assert.strictEqual(hp.state, HeaderState.END);
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
assert.deepEqual(hp.fields, {name: '', size: 123456});
|
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: '', size: 123456});
|
||||||
hp.reset();
|
hp.reset();
|
||||||
inp = fromBs(CASES[0][0]);
|
inp = fromBs(CASES[0][0]);
|
||||||
res = hp.parse(inp, 0, inp.length);
|
res = hp.parse(inp, 0, inp.length);
|
||||||
@@ -135,4 +137,124 @@ describe('IIPHeaderParser', () => {
|
|||||||
assert.strictEqual(hp.state, HeaderState.END);
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
assert.deepEqual(hp.fields, CASES[0][1]);
|
assert.deepEqual(hp.fields, CASES[0][1]);
|
||||||
});
|
});
|
||||||
|
it('FilePart sequence', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
|
||||||
|
const res = hp.parse(inp, 0, inp.length);
|
||||||
|
assert.strictEqual(res, 9);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
|
||||||
|
});
|
||||||
|
it('FilePart sequence - bytewise', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
|
||||||
|
let res = -2;
|
||||||
|
let pos = 0;
|
||||||
|
while (res === -2 && pos < inp.length) {
|
||||||
|
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||||
|
}
|
||||||
|
assert.strictEqual(res, 1);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
|
||||||
|
});
|
||||||
|
it('MultipartFile sequence', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
|
||||||
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.VALUE);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(
|
||||||
|
hp.fields,
|
||||||
|
{
|
||||||
|
type: SequenceType.MULTIPARTFILE,
|
||||||
|
inline: 1,
|
||||||
|
width: '10px',
|
||||||
|
height: '20%',
|
||||||
|
preserveAspectRatio: 1,
|
||||||
|
size: 123456,
|
||||||
|
name: 'ümläutß'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('MultipartFile sequence - bytewise', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
|
||||||
|
let res = -2;
|
||||||
|
let pos = 0;
|
||||||
|
while (res === -2 && pos < inp.length) {
|
||||||
|
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||||
|
}
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.VALUE);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(
|
||||||
|
hp.fields,
|
||||||
|
{
|
||||||
|
type: SequenceType.MULTIPARTFILE,
|
||||||
|
inline: 1,
|
||||||
|
width: '10px',
|
||||||
|
height: '20%',
|
||||||
|
preserveAspectRatio: 1,
|
||||||
|
size: 123456,
|
||||||
|
name: 'ümläutß'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('FileEnd sequence', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('FileEnd');
|
||||||
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.START);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
|
||||||
|
});
|
||||||
|
it('FileEnd sequence - bytewise', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('FileEnd');
|
||||||
|
let res = -2;
|
||||||
|
let pos = 0;
|
||||||
|
while (res === -2 && pos < inp.length) {
|
||||||
|
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||||
|
}
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.START);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
|
||||||
|
});
|
||||||
|
it('ReportCellSize sequence', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('ReportCellSize');
|
||||||
|
let res = hp.parse(inp, 0, inp.length);
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.START);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
|
||||||
|
});
|
||||||
|
it('ReportCellSize sequence - bytewise', () => {
|
||||||
|
const hp = new HeaderParser();
|
||||||
|
const inp = fromBs('ReportCellSize');
|
||||||
|
let res = -2;
|
||||||
|
let pos = 0;
|
||||||
|
while (res === -2 && pos < inp.length) {
|
||||||
|
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
|
||||||
|
}
|
||||||
|
assert.strictEqual(res, -2);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.START);
|
||||||
|
res = hp.end();
|
||||||
|
assert.strictEqual(res, 0);
|
||||||
|
assert.strictEqual(hp.state, HeaderState.END);
|
||||||
|
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,27 @@
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
declare const Buffer: any;
|
declare const Buffer: any;
|
||||||
|
|
||||||
|
export const enum HeaderState {
|
||||||
|
START = 0,
|
||||||
|
ABORT = 1,
|
||||||
|
KEY = 2,
|
||||||
|
VALUE = 3,
|
||||||
|
END = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum SequenceType {
|
||||||
|
INVALID = 0,
|
||||||
|
FILE = 1,
|
||||||
|
MULTIPARTFILE = 2,
|
||||||
|
FILEPART = 3,
|
||||||
|
FILEEND = 4,
|
||||||
|
REPORTCELLSIZE = 5
|
||||||
|
}
|
||||||
|
|
||||||
export interface IHeaderFields {
|
export interface IHeaderFields {
|
||||||
[key: string]: number | string | Uint32Array | null | undefined;
|
[key: string]: number | string | Uint32Array | null | undefined;
|
||||||
|
// sequence type
|
||||||
|
type: SequenceType;
|
||||||
// base-64 encoded filename. Defaults to "Unnamed file".
|
// base-64 encoded filename. Defaults to "Unnamed file".
|
||||||
name: string;
|
name: string;
|
||||||
// File size in bytes. The file transfer will be canceled if this size is exceeded.
|
// File size in bytes. The file transfer will be canceled if this size is exceeded.
|
||||||
@@ -29,14 +47,6 @@ export interface IHeaderFields {
|
|||||||
inline?: number;
|
inline?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum HeaderState {
|
|
||||||
START = 0,
|
|
||||||
ABORT = 1,
|
|
||||||
KEY = 2,
|
|
||||||
VALUE = 3,
|
|
||||||
END = 4
|
|
||||||
}
|
|
||||||
|
|
||||||
// field value decoders
|
// field value decoders
|
||||||
|
|
||||||
// ASCII bytes to string
|
// ASCII bytes to string
|
||||||
@@ -92,7 +102,19 @@ const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// sequence type markers
|
||||||
|
// File
|
||||||
const FILE_MARKER = [70, 105, 108, 101];
|
const FILE_MARKER = [70, 105, 108, 101];
|
||||||
|
// MultipartFile
|
||||||
|
const MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];
|
||||||
|
// FilePart
|
||||||
|
const FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];
|
||||||
|
// FileEnd
|
||||||
|
const FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];
|
||||||
|
// ReportCellSize
|
||||||
|
const REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];
|
||||||
|
|
||||||
|
// max allowed chars for sequence header
|
||||||
const MAX_FIELDCHARS = 1024;
|
const MAX_FIELDCHARS = 1024;
|
||||||
|
|
||||||
|
|
||||||
@@ -111,12 +133,43 @@ export class HeaderParser {
|
|||||||
this._key = '';
|
this._key = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public end(): number {
|
||||||
|
if (this.state === HeaderState.START) {
|
||||||
|
if (this._position === FILEEND_MARKER.length) {
|
||||||
|
for (let k = 0; k < FILEEND_MARKER.length; ++k) {
|
||||||
|
if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.FILEEND;
|
||||||
|
this.state = HeaderState.END;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (this._position === REPORTCELLSIZE_MARKER.length) {
|
||||||
|
for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {
|
||||||
|
if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.REPORTCELLSIZE;
|
||||||
|
this.state = HeaderState.END;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this._a();
|
||||||
|
}
|
||||||
|
if (this.state === HeaderState.END) return 0;
|
||||||
|
if (this.state === HeaderState.VALUE
|
||||||
|
&& this.fields.type === SequenceType.MULTIPARTFILE
|
||||||
|
) {
|
||||||
|
if (!this._storeValue(this._position)) return this._a();
|
||||||
|
this.state = HeaderState.END;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this._a();
|
||||||
|
}
|
||||||
|
|
||||||
public parse(data: Uint32Array, start: number, end: number): number {
|
public parse(data: Uint32Array, start: number, end: number): number {
|
||||||
let state = this.state;
|
let state = this.state;
|
||||||
let pos = this._position;
|
let pos = this._position;
|
||||||
const buffer = this._buffer;
|
const buffer = this._buffer;
|
||||||
if (state === HeaderState.ABORT || state === HeaderState.END) return -1;
|
if (state === HeaderState.ABORT || state === HeaderState.END) return -1;
|
||||||
if (state === HeaderState.START && pos > 6) return -1;
|
if (state === HeaderState.START && pos > 14) return -1;
|
||||||
for (let i = start; i < end; ++i) {
|
for (let i = start; i < end; ++i) {
|
||||||
const c = data[i];
|
const c = data[i];
|
||||||
switch (c) {
|
switch (c) {
|
||||||
@@ -127,8 +180,29 @@ export class HeaderParser {
|
|||||||
break;
|
break;
|
||||||
case 61: // =
|
case 61: // =
|
||||||
if (state === HeaderState.START) {
|
if (state === HeaderState.START) {
|
||||||
for (let k = 0; k < FILE_MARKER.length; ++k) {
|
if (buffer[0] === 70) {
|
||||||
if (buffer[k] !== FILE_MARKER[k]) return this._a();
|
// 'File' or 'FilePart'
|
||||||
|
let k = 0;
|
||||||
|
for (; k < FILE_MARKER.length; ++k) {
|
||||||
|
if (buffer[k] !== FILE_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.FILE;
|
||||||
|
if (pos === FILEPART_MARKER.length) {
|
||||||
|
for (; k < FILEPART_MARKER.length; ++k) {
|
||||||
|
if (buffer[k] !== FILEPART_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.FILEPART;
|
||||||
|
this.state = HeaderState.END;
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
} else if (buffer[0] === 77) {
|
||||||
|
// 'MultipartFile'
|
||||||
|
for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {
|
||||||
|
if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();
|
||||||
|
}
|
||||||
|
this.fields['type'] = SequenceType.MULTIPARTFILE;
|
||||||
|
} else {
|
||||||
|
return this._a();
|
||||||
}
|
}
|
||||||
state = HeaderState.KEY;
|
state = HeaderState.KEY;
|
||||||
pos = 0;
|
pos = 0;
|
||||||
@@ -158,6 +232,7 @@ export class HeaderParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _a(): number {
|
private _a(): number {
|
||||||
|
this.fields.type = SequenceType.INVALID;
|
||||||
this.state = HeaderState.ABORT;
|
this.state = HeaderState.ABORT;
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IAddImageOpts } from './Types';
|
||||||
|
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 {
|
||||||
|
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
|
||||||
|
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, this._addImageOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,30 @@ const TEST_IMAGES: [string, IMetrics][] = [
|
|||||||
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
|
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
|
||||||
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
|
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
|
||||||
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
|
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
|
||||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }]
|
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
|
||||||
|
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
|
||||||
|
// VP8
|
||||||
|
['1.webp', { mime: 'image/webp', width: 550, height: 368 }],
|
||||||
|
['2.webp', { mime: 'image/webp', width: 550, height: 404 }],
|
||||||
|
['3.webp', { mime: 'image/webp', width: 1280, height: 720 }],
|
||||||
|
['4.webp', { mime: 'image/webp', width: 1024, height: 772 }],
|
||||||
|
['5.webp', { mime: 'image/webp', width: 1024, height: 752 }],
|
||||||
|
// VP8X
|
||||||
|
['1_webp_a.webp', { mime: 'image/webp', width: 400, height: 301 }],
|
||||||
|
['2_webp_a.webp', { mime: 'image/webp', width: 386, height: 395 }],
|
||||||
|
['3_webp_a.webp', { mime: 'image/webp', width: 800, height: 600 }],
|
||||||
|
['4_webp_a.webp', { mime: 'image/webp', width: 421, height: 163 }],
|
||||||
|
['5_webp_a.webp', { mime: 'image/webp', width: 300, height: 300 }],
|
||||||
|
// VP8L
|
||||||
|
['1_webp_ll.webp', { mime: 'image/webp', width: 400, height: 301 }],
|
||||||
|
['2_webp_ll.webp', { mime: 'image/webp', width: 386, height: 395 }],
|
||||||
|
['3_webp_ll.webp', { mime: 'image/webp', width: 800, height: 600 }],
|
||||||
|
['4_webp_ll.webp', { mime: 'image/webp', width: 421, height: 163 }],
|
||||||
|
['5_webp_ll.webp', { mime: 'image/webp', width: 300, height: 300 }],
|
||||||
|
// some AVIF test images
|
||||||
|
['fox.profile2.12bpc.yuv444.odd-width.odd-height.avif', { mime: 'image/avif', width: 1203, height: 799 }],
|
||||||
|
['hato.profile0.8bpc.yuv420.avif', { mime: 'image/avif', width: 3082, height: 2048 }],
|
||||||
|
['kimono.crop.avif', { mime: 'image/avif', width: 722, height: 1024 }],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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' | 'image/webp' | 'image/avif' | 'unsupported' | '';
|
||||||
|
|
||||||
export interface IMetrics {
|
export interface IMetrics {
|
||||||
mime: ImageType;
|
mime: ImageType;
|
||||||
@@ -19,16 +19,16 @@ export const UNSUPPORTED_TYPE: IMetrics = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function imageType(d: Uint8Array): IMetrics {
|
export function imageType(d: Uint8Array): IMetrics {
|
||||||
if (d.length < 24) {
|
if (d.length < 32) {
|
||||||
return UNSUPPORTED_TYPE;
|
return UNSUPPORTED_TYPE;
|
||||||
}
|
}
|
||||||
const d32 = new Uint32Array(d.buffer, d.byteOffset, 6);
|
const d32 = new Uint32Array(d.buffer, d.byteOffset, 8);
|
||||||
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
|
// PNG: 89 50 4E 47 0D 0A 1A 0A (8 first bytes == magic number for PNG)
|
||||||
// + first chunk must be IHDR
|
// + first chunk must be IHDR
|
||||||
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
|
if (d32[0] === 0x474E5089 && d32[1] === 0x0A1A0A0D && d32[3] === 0x52444849) {
|
||||||
return {
|
return {
|
||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
|
width: d[16] << 24 | d[17] << 16 | d[18] << 8 | d[19],
|
||||||
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
|
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -41,13 +41,79 @@ export function imageType(d: Uint8Array): IMetrics {
|
|||||||
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
|
if (d32[0] === 0x38464947 && (d[4] === 0x37 || d[4] === 0x39) && d[5] === 0x61) {
|
||||||
return {
|
return {
|
||||||
mime: 'image/gif',
|
mime: 'image/gif',
|
||||||
width: d[7] << 8 | d[6],
|
width: d[7] << 8 | d[6],
|
||||||
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]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// WEBP: RIFF | xxxx | WEBP | VP8x
|
||||||
|
if (d32[0] === 0x46464952 && d32[2] === 0x50424557 && (d32[3] & 0xFFFFFF) === 0x385056) {
|
||||||
|
switch (d[15]) {
|
||||||
|
case 0x58: // Extended WebP VP8X --> "X"
|
||||||
|
return {
|
||||||
|
mime: 'image/webp',
|
||||||
|
width: (d[24] | d[25] << 8 | d[26] << 16) + 1,
|
||||||
|
height: (d[27] | d[28] << 8 | d[29] << 16) + 1
|
||||||
|
};
|
||||||
|
case 0x4C: // Lossless WebP VP8L --> "L"
|
||||||
|
if (d[20] !== 0x2f) return UNSUPPORTED_TYPE;
|
||||||
|
const dim = d[21] | d[22] << 8 | d[23] << 16 | d[24] << 24;
|
||||||
|
return {
|
||||||
|
mime: 'image/webp',
|
||||||
|
width: (dim & 0x3FFF) + 1,
|
||||||
|
height: (dim >>> 14 & 0x3FFF) + 1
|
||||||
|
};
|
||||||
|
case 0x20: // Lossy WebP VP8 --> " "
|
||||||
|
if (d[23] !== 0x9d || d[24] !== 0x01 || d[25] !== 0x2a) return UNSUPPORTED_TYPE;
|
||||||
|
return {
|
||||||
|
mime: 'image/webp',
|
||||||
|
width: (d[26] | d[27] << 8) & 0x3FFF,
|
||||||
|
height: (d[28] | d[29] << 8) & 0x3FFF
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return UNSUPPORTED_TYPE;
|
||||||
|
}
|
||||||
|
// AVIF: Box size | ftyp | avif/avis
|
||||||
|
if (d32[1] === 0x70797466 && (d32[2] === 0x66697661 || d32[2] === 0x73697661)) {
|
||||||
|
let pos = -1;
|
||||||
|
// search for ispe box within first 1024 bytes
|
||||||
|
const limit = Math.min(d.length - 16, 1024);
|
||||||
|
for (let i = 8; i < limit; i++) {
|
||||||
|
// scan for ispe
|
||||||
|
if (d[i] === 0x69 && d[i + 1] === 0x73 && d[i + 2] === 0x70 && d[i + 3] === 0x65) {
|
||||||
|
pos = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos !== -1) {
|
||||||
|
// dimensions are in BE at +8 (width) at +12 (height)
|
||||||
|
const width =
|
||||||
|
d[pos + 8] << 24 |
|
||||||
|
d[pos + 9] << 16 |
|
||||||
|
d[pos + 10] << 8 |
|
||||||
|
d[pos + 11];
|
||||||
|
const height =
|
||||||
|
d[pos + 12] << 24 |
|
||||||
|
d[pos + 13] << 16 |
|
||||||
|
d[pos + 14] << 8 |
|
||||||
|
d[pos + 15];
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
return { mime: 'image/avif', width, height };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UNSUPPORTED_TYPE;
|
||||||
|
}
|
||||||
return UNSUPPORTED_TYPE;
|
return UNSUPPORTED_TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function jpgSize(d: Uint8Array): [number, number] {
|
function jpgSize(d: Uint8Array): [number, number] {
|
||||||
const len = d.length;
|
const len = d.length;
|
||||||
let i = 4;
|
let i = 4;
|
||||||
|
|||||||
@@ -5,27 +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 { 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,
|
kittySupport: true,
|
||||||
kittySizeLimit: 20000000
|
kittySizeLimit: 33554432
|
||||||
};
|
};
|
||||||
|
|
||||||
// max palette size supported by the sixel lib (compile time setting)
|
// max palette size supported by the sixel lib (compile time setting)
|
||||||
@@ -59,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);
|
||||||
@@ -71,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 {
|
||||||
@@ -85,14 +133,10 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
// internal data structures
|
// internal data structures
|
||||||
this._renderer = new ImageRenderer(terminal);
|
this._renderer = new ImageRenderer(terminal);
|
||||||
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
|
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
|
||||||
|
this._storage.onImageAdded = () => this._onImageAdded.fire();
|
||||||
|
|
||||||
// enable size reports
|
// enable size reports
|
||||||
if (this._opts.enableSizeReports) {
|
if (this._opts.enableSizeReports) {
|
||||||
// const windowOptions = terminal.getOption('windowOptions');
|
|
||||||
// windowOptions.getWinSizePixels = true;
|
|
||||||
// windowOptions.getCellSizePixels = true;
|
|
||||||
// windowOptions.getWinSizeChars = true;
|
|
||||||
// terminal.setOption('windowOptions', windowOptions);
|
|
||||||
const windowOps = terminal.options.windowOptions ?? {};
|
const windowOps = terminal.options.windowOptions ?? {};
|
||||||
windowOps.getWinSizePixels = true;
|
windowOps.getWinSizePixels = true;
|
||||||
windowOps.getCellSizePixels = true;
|
windowOps.getCellSizePixels = true;
|
||||||
@@ -132,7 +176,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)
|
||||||
@@ -141,7 +186,8 @@ 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)
|
||||||
@@ -150,10 +196,13 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
|
|
||||||
// Kitty graphics handler
|
// Kitty graphics handler
|
||||||
if (this._opts.kittySupport) {
|
if (this._opts.kittySupport) {
|
||||||
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, this._storage!, terminal);
|
const kittyStorage = new KittyImageStorage(this._storage!);
|
||||||
|
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
|
||||||
this._handlers.set('kitty', kittyHandler);
|
this._handlers.set('kitty', kittyHandler);
|
||||||
this._disposeLater(
|
this._disposeLater(
|
||||||
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
|
kittyStorage,
|
||||||
|
kittyHandler,
|
||||||
|
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +255,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _report(s: string): void {
|
private _report(s: string): void {
|
||||||
this._terminal?._core.coreService.triggerDataEvent(s);
|
this._terminal?._core.input(s, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _decset(params: (number | number[])[]): boolean {
|
private _decset(params: (number | number[])[]): boolean {
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -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 + 1 - 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,12 +251,12 @@ 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
|
||||||
@@ -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,8 +5,11 @@
|
|||||||
|
|
||||||
import { IDisposable } from '@xterm/xterm';
|
import { IDisposable } from '@xterm/xterm';
|
||||||
import { ImageRenderer } from './ImageRenderer';
|
import { ImageRenderer } from './ImageRenderer';
|
||||||
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
|
import type {
|
||||||
|
ITerminalExt, IImageAddonOptions, IImageSpec, ICellSize, IAddImageOpts
|
||||||
|
} from './Types';
|
||||||
|
import type { IBufferLine } from 'common/buffer/Types';
|
||||||
|
import { CellData } from 'common/buffer/CellData';
|
||||||
|
|
||||||
// fallback default cell size
|
// fallback default cell size
|
||||||
export const CELL_SIZE_DEFAULT: ICellSize = {
|
export const CELL_SIZE_DEFAULT: ICellSize = {
|
||||||
@@ -14,93 +17,12 @@ export const CELL_SIZE_DEFAULT: ICellSize = {
|
|||||||
height: 14
|
height: 14
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
class ImageTileInfo {
|
||||||
* Extend extended attribute to also hold image tile information.
|
|
||||||
*
|
|
||||||
* Object definition is copied from base repo to fully mimick its behavior.
|
|
||||||
* Image data is added as additional public properties `imageId` and `tileId`.
|
|
||||||
*/
|
|
||||||
class ExtendedAttrsImage implements IExtendedAttrsImage {
|
|
||||||
private _ext: number = 0;
|
|
||||||
public get ext(): number {
|
|
||||||
if (this._urlId) {
|
|
||||||
return (
|
|
||||||
(this._ext & ~ExtFlags.UNDERLINE_STYLE) |
|
|
||||||
(this.underlineStyle << 26)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this._ext;
|
|
||||||
}
|
|
||||||
public set ext(value: number) { this._ext = value; }
|
|
||||||
|
|
||||||
public get underlineStyle(): UnderlineStyle {
|
|
||||||
// Always return the URL style if it has one
|
|
||||||
if (this._urlId) {
|
|
||||||
return UnderlineStyle.DASHED;
|
|
||||||
}
|
|
||||||
return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;
|
|
||||||
}
|
|
||||||
public set underlineStyle(value: UnderlineStyle) {
|
|
||||||
this._ext &= ~ExtFlags.UNDERLINE_STYLE;
|
|
||||||
this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get underlineColor(): number {
|
|
||||||
return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);
|
|
||||||
}
|
|
||||||
public set underlineColor(value: number) {
|
|
||||||
this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
|
||||||
this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get underlineVariantOffset(): number {
|
|
||||||
const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29;
|
|
||||||
if (val < 0) {
|
|
||||||
return val ^ 0xFFFFFFF8;
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
public set underlineVariantOffset(value: number) {
|
|
||||||
this._ext &= ~ExtFlags.VARIANT_OFFSET;
|
|
||||||
this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _urlId: number = 0;
|
|
||||||
public get urlId(): number {
|
|
||||||
return this._urlId;
|
|
||||||
}
|
|
||||||
public set urlId(value: number) {
|
|
||||||
this._urlId = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
ext: number = 0,
|
|
||||||
urlId: number = 0,
|
|
||||||
public imageId = -1,
|
public imageId = -1,
|
||||||
public tileId = -1
|
public tileId = -1) {
|
||||||
) {
|
|
||||||
this._ext = ext;
|
|
||||||
this._urlId = urlId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public clone(): IExtendedAttrsImage {
|
|
||||||
/**
|
|
||||||
* Technically we dont need a clone variant of ExtendedAttrsImage,
|
|
||||||
* as we never clone a cell holding image data.
|
|
||||||
* Note: Clone is only meant to be used by the InputHandler for
|
|
||||||
* sticky attributes, which is never the case for image data.
|
|
||||||
* We still provide a proper clone method to reflect the full ext attr
|
|
||||||
* state in case there are future use cases for clone.
|
|
||||||
*/
|
|
||||||
return new ExtendedAttrsImage(this._ext, this._urlId, this.imageId, this.tileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public isEmpty(): boolean {
|
|
||||||
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0 && this.imageId === -1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const EMPTY_ATTRS = new ExtendedAttrsImage();
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageStorage - extension of CoreTerminal:
|
* ImageStorage - extension of CoreTerminal:
|
||||||
@@ -122,8 +44,11 @@ export class ImageStorage implements IDisposable {
|
|||||||
private _needsFullClear = false;
|
private _needsFullClear = false;
|
||||||
// hard limit of stored pixels (fallback limit of 10 MB)
|
// hard limit of stored pixels (fallback limit of 10 MB)
|
||||||
private _pixelLimit: number = 2500000;
|
private _pixelLimit: number = 2500000;
|
||||||
|
private _workCell: CellData = new CellData();
|
||||||
|
|
||||||
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,
|
||||||
@@ -189,11 +114,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,24 +143,6 @@ export class ImageStorage implements IDisposable {
|
|||||||
this._fullyCleared = false;
|
this._fullyCleared = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an image by its internal storage ID.
|
* Delete an image by its internal storage ID.
|
||||||
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
||||||
@@ -248,9 +157,16 @@ export class ImageStorage implements IDisposable {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to add an image to the storage.
|
* Method to add an image to the storage.
|
||||||
* Returns the internal image ID assigned to the stored image.
|
* @param img - The image to add (canvas or bitmap).
|
||||||
|
* @param opts - Options for addImage:
|
||||||
|
* - scrolling: When true, cursor advances with the image.
|
||||||
|
* When false, image is placed at ORIGIN and cursor does not move.
|
||||||
|
* - layer: Which canvas layer to render on ('top' or 'bottom').
|
||||||
|
* - zIndex: Z-index for image layering within the same layer.
|
||||||
|
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
|
||||||
|
* @returns The internal image ID assigned to the stored image.
|
||||||
*/
|
*/
|
||||||
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
|
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
|
||||||
// never allow storage to exceed memory limit
|
// never allow storage to exceed memory limit
|
||||||
this._evictOldest(img.width * img.height);
|
this._evictOldest(img.width * img.height);
|
||||||
|
|
||||||
@@ -272,7 +188,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
let offset = originX;
|
let offset = originX;
|
||||||
let tileCount = 0;
|
let tileCount = 0;
|
||||||
|
|
||||||
if (!this._opts.sixelScrolling) {
|
if (!opts.scrolling) {
|
||||||
buffer.x = 0;
|
buffer.x = 0;
|
||||||
buffer.y = 0;
|
buffer.y = 0;
|
||||||
offset = 0;
|
offset = 0;
|
||||||
@@ -280,13 +196,13 @@ export class ImageStorage implements IDisposable {
|
|||||||
|
|
||||||
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
||||||
for (let row = 0; row < rows; ++row) {
|
for (let row = 0; row < rows; ++row) {
|
||||||
const line = buffer.lines.get(buffer.y + buffer.ybase);
|
const line = buffer.lines.get(buffer.y + buffer.ybase)!;
|
||||||
for (let col = 0; col < cols; ++col) {
|
for (let col = 0; col < cols; ++col) {
|
||||||
if (offset + col >= termCols) break;
|
if (offset + col >= termCols) break;
|
||||||
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
|
this._writeToCell(line, offset + col, imageId, row * cols + col);
|
||||||
tileCount++;
|
tileCount++;
|
||||||
}
|
}
|
||||||
if (this._opts.sixelScrolling) {
|
if (opts.scrolling) {
|
||||||
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
|
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
|
||||||
} else {
|
} else {
|
||||||
if (++buffer.y >= termRows) break;
|
if (++buffer.y >= termRows) break;
|
||||||
@@ -296,8 +212,12 @@ export class ImageStorage implements IDisposable {
|
|||||||
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
||||||
|
|
||||||
// cursor positioning modes
|
// cursor positioning modes
|
||||||
if (this._opts.sixelScrolling) {
|
if (opts.scrolling) {
|
||||||
buffer.x = offset;
|
if (opts.cursorPos === 'iip') {
|
||||||
|
buffer.x = Math.min(offset + cols, termCols);
|
||||||
|
} else {
|
||||||
|
buffer.x = offset;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
buffer.x = originX;
|
buffer.x = originX;
|
||||||
buffer.y = originY;
|
buffer.y = originY;
|
||||||
@@ -339,11 +259,14 @@ 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: opts.layer,
|
||||||
|
zIndex: opts.zIndex
|
||||||
};
|
};
|
||||||
|
|
||||||
// finally add the image
|
// finally add the image
|
||||||
this._images.set(imageId, imgSpec);
|
this._images.set(imageId, imgSpec);
|
||||||
|
this.onImageAdded?.();
|
||||||
return imageId;
|
return imageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,16 +277,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) {
|
||||||
@@ -371,12 +308,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();
|
||||||
@@ -391,13 +341,17 @@ 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
|
||||||
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);
|
||||||
if (!line) return;
|
if (!line) return;
|
||||||
for (let col = 0; col < cols; ++col) {
|
for (let col = 0; col < cols; ++col) {
|
||||||
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
|
const e = line.getExtended(col)?.payload;
|
||||||
let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;
|
if (e instanceof ImageTileInfo) {
|
||||||
const imageId = e.imageId;
|
const imageId = e.imageId;
|
||||||
if (imageId === undefined || imageId === -1) {
|
if (imageId === undefined || imageId === -1) {
|
||||||
continue;
|
continue;
|
||||||
@@ -413,28 +367,39 @@ export class ImageStorage implements IDisposable {
|
|||||||
* - cell has same image id
|
* - cell has same image id
|
||||||
* - cell has consecutive tile id
|
* - cell has consecutive tile id
|
||||||
*/
|
*/
|
||||||
while (
|
while (++col < cols) {
|
||||||
++col < cols
|
const nextE = line.getExtended(col)?.payload;
|
||||||
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
|
if (!(nextE instanceof ImageTileInfo) || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||||
&& (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)
|
break;
|
||||||
&& (e.imageId === imageId)
|
}
|
||||||
&& (e.tileId === startTile + count)
|
|
||||||
) {
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
col--;
|
col--;
|
||||||
if (imgSpec) {
|
if (imgSpec) {
|
||||||
if (imgSpec.actual) {
|
if (imgSpec.actual) {
|
||||||
this._renderer.draw(imgSpec, startTile, startCol, row, count);
|
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
|
||||||
}
|
}
|
||||||
} else if (this._opts.showPlaceholder) {
|
} else if (this._opts.showPlaceholder) {
|
||||||
this._renderer.drawPlaceholder(startCol, row, count);
|
placeholderCalls.push({ col: startCol, row, count });
|
||||||
}
|
}
|
||||||
this._fullyCleared = false;
|
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 {
|
||||||
@@ -456,9 +421,9 @@ export class ImageStorage implements IDisposable {
|
|||||||
const rows = buffer.lines.length;
|
const rows = buffer.lines.length;
|
||||||
const oldCol = this._viewportMetrics.cols - 1;
|
const oldCol = this._viewportMetrics.cols - 1;
|
||||||
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)!;
|
||||||
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
|
const e = line.getExtended(oldCol)?.payload;
|
||||||
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;
|
if (e instanceof ImageTileInfo) {
|
||||||
const imageId = e.imageId;
|
const imageId = e.imageId;
|
||||||
if (imageId === undefined || imageId === -1) {
|
if (imageId === undefined || imageId === -1) {
|
||||||
continue;
|
continue;
|
||||||
@@ -475,7 +440,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
// expand only if right side is empty (nothing got wrapped from below)
|
// expand only if right side is empty (nothing got wrapped from below)
|
||||||
let hasData = false;
|
let hasData = false;
|
||||||
for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
|
for (let rightCol = oldCol + 1; rightCol > metrics.cols; ++rightCol) {
|
||||||
if (line._data[rightCol * Cell.SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) {
|
if (line.hasContent(rightCol)) {
|
||||||
hasData = true;
|
hasData = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -487,7 +452,7 @@ export class ImageStorage implements IDisposable {
|
|||||||
const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);
|
const end = Math.min(metrics.cols, tilesPerRow - (e.tileId % tilesPerRow) + oldCol);
|
||||||
let lastTile = e.tileId;
|
let lastTile = e.tileId;
|
||||||
for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {
|
for (let expandCol = oldCol + 1; expandCol < end; ++expandCol) {
|
||||||
this._writeToCell(line as IBufferLineExt, expandCol, imageId, ++lastTile);
|
this._writeToCell(line, expandCol, imageId, ++lastTile);
|
||||||
imgSpec.tileCount++;
|
imgSpec.tileCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,10 +466,10 @@ export class ImageStorage implements IDisposable {
|
|||||||
*/
|
*/
|
||||||
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
public getImageAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
||||||
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);
|
||||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
if (line) {
|
||||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
|
const e = line.getExtended(x)?.payload;
|
||||||
if (e.imageId && e.imageId !== -1) {
|
if (e instanceof ImageTileInfo && 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) {
|
||||||
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
|
const canvas = ImageRenderer.createCanvas(window.document, orig.width, orig.height);
|
||||||
@@ -521,10 +486,10 @@ export class ImageStorage implements IDisposable {
|
|||||||
*/
|
*/
|
||||||
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
public extractTileAtBufferCell(x: number, y: number): HTMLCanvasElement | undefined {
|
||||||
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);
|
||||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
if (line) {
|
||||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
|
const e = line.getExtended(x)?.payload;
|
||||||
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
|
if (e instanceof ImageTileInfo && 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) {
|
||||||
return this._renderer.extractTile(spec, e.tileId);
|
return this._renderer.extractTile(spec, e.tileId);
|
||||||
@@ -552,31 +517,31 @@ export class ImageStorage implements IDisposable {
|
|||||||
return used - current;
|
return used - current;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _writeToCell(line: IBufferLineExt, x: number, imageId: number, tileId: number): void {
|
private _writeToCell(line: IBufferLine, x: number, imageId: number, tileId: number): void {
|
||||||
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
const workCell = this._workCell;
|
||||||
const old = line._extendedAttrs[x];
|
line.loadCell(x, workCell);
|
||||||
if (old) {
|
if (this._workCell.hasExtendedAttrs()) {
|
||||||
if (old.imageId !== undefined) {
|
const old = workCell.extended.payload;
|
||||||
// found an old ExtendedAttrsImage, since we know that
|
if (old instanceof ImageTileInfo) {
|
||||||
// they are always isolated instances (single cell usage),
|
// found an old ExtendedAttrsImage, since we know that
|
||||||
// we can re-use it and just update their id entries
|
// they are always isolated instances (single cell usage),
|
||||||
const oldSpec = this._images.get(old.imageId);
|
// we can re-use it and just update their id entries
|
||||||
if (oldSpec) {
|
const oldSpec = this._images.get(old.imageId);
|
||||||
// early eviction for in-viewport overwrites
|
if (oldSpec) {
|
||||||
oldSpec.tileCount--;
|
// early eviction for in-viewport overwrites
|
||||||
}
|
oldSpec.tileCount--;
|
||||||
old.imageId = imageId;
|
|
||||||
old.tileId = tileId;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// found a plain ExtendedAttrs instance, clone it to new entry
|
old.imageId = imageId;
|
||||||
line._extendedAttrs[x] = new ExtendedAttrsImage(old.ext, old.urlId, imageId, tileId);
|
old.tileId = tileId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// fall-through: always create new ExtendedAttrsImage entry
|
// fall-through: always create new ExtendedAttrs entry
|
||||||
line._data[x * Cell.SIZE + Cell.BG] |= BgFlags.HAS_EXTENDED;
|
const extattr = workCell.extended.clone();
|
||||||
line._extendedAttrs[x] = new ExtendedAttrsImage(0, 0, imageId, tileId);
|
extattr.payload = new ImageTileInfo(imageId, tileId);
|
||||||
|
workCell.extended = extattr;
|
||||||
|
workCell.updateExtended();
|
||||||
|
line.setCell(x, workCell);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _evictOnAlternate(): void {
|
private _evictOnAlternate(): void {
|
||||||
@@ -589,18 +554,16 @@ export class ImageStorage implements IDisposable {
|
|||||||
// re-count tiles on whole buffer
|
// re-count tiles on whole buffer
|
||||||
const buffer = this._terminal._core.buffer;
|
const buffer = this._terminal._core.buffer;
|
||||||
for (let y = 0; y < this._terminal.rows; ++y) {
|
for (let y = 0; y < this._terminal.rows; ++y) {
|
||||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
const line = buffer.lines.get(y);
|
||||||
if (!line) {
|
if (!line) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (let x = 0; x < this._terminal.cols; ++x) {
|
for (let x = 0; x < this._terminal.cols; ++x) {
|
||||||
if (line._data[x * Cell.SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
const payload = line.getExtended(x)?.payload;
|
||||||
const imgId = line._extendedAttrs[x]?.imageId;
|
if (payload instanceof ImageTileInfo) {
|
||||||
if (imgId) {
|
const spec = this._images.get(payload.imageId);
|
||||||
const spec = this._images.get(imgId);
|
if (spec) {
|
||||||
if (spec) {
|
spec.tileCount++;
|
||||||
spec.tileCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
|||||||
const height = this._dec.height;
|
const height = this._dec.height;
|
||||||
|
|
||||||
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
||||||
if (!width || ! height) {
|
if (!width || !height) {
|
||||||
if (height) {
|
if (height) {
|
||||||
this._storage.advanceCursor(height);
|
this._storage.advanceCursor(height);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||||
|
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } 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 {
|
||||||
|
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };
|
||||||
|
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._addImageOpts.scrolling = this._opts.sixelScrolling;
|
||||||
|
this._storage.addImage(img, this._addImageOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
|
|||||||
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
|
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
|
||||||
import type { AttributeData } from 'common/buffer/AttributeData';
|
import type { AttributeData } from 'common/buffer/AttributeData';
|
||||||
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||||
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
|
import type { IInputHandler } from 'common/Types';
|
||||||
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||||
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||||
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||||
@@ -47,6 +47,7 @@ export interface IResetHandler {
|
|||||||
reset(): void;
|
reset(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
/**
|
/**
|
||||||
* Stub into private interfaces.
|
* Stub into private interfaces.
|
||||||
* This should be kept in line with common libs.
|
* This should be kept in line with common libs.
|
||||||
@@ -54,19 +55,6 @@ export interface IResetHandler {
|
|||||||
* have a somewhat reliable testing against code changes in the core repo.
|
* have a somewhat reliable testing against code changes in the core repo.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// overloaded IExtendedAttrs to hold image refs
|
|
||||||
export interface IExtendedAttrsImage extends IExtendedAttrs {
|
|
||||||
imageId: number;
|
|
||||||
tileId: number;
|
|
||||||
clone(): IExtendedAttrsImage;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* eslint-disable */
|
|
||||||
export interface IBufferLineExt extends IBufferLine {
|
|
||||||
_extendedAttrs: {[index: number]: IExtendedAttrsImage | undefined};
|
|
||||||
_data: Uint32Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IInputHandlerExt extends IInputHandler {
|
interface IInputHandlerExt extends IInputHandler {
|
||||||
_parser: IEscapeSequenceParser;
|
_parser: IEscapeSequenceParser;
|
||||||
_curAttrData: AttributeData;
|
_curAttrData: AttributeData;
|
||||||
@@ -77,6 +65,7 @@ interface IInputHandlerExt extends IInputHandler {
|
|||||||
};
|
};
|
||||||
onRequestReset(handler: () => void): IDisposable;
|
onRequestReset(handler: () => void): IDisposable;
|
||||||
}
|
}
|
||||||
|
/* eslint-enable */
|
||||||
|
|
||||||
export interface ICoreTerminalExt extends ITerminal {
|
export interface ICoreTerminalExt extends ITerminal {
|
||||||
_themeService: IThemeService | undefined;
|
_themeService: IThemeService | undefined;
|
||||||
@@ -88,8 +77,6 @@ export interface ICoreTerminalExt extends ITerminal {
|
|||||||
export interface ITerminalExt extends Terminal {
|
export interface ITerminalExt extends Terminal {
|
||||||
_core: ICoreTerminalExt;
|
_core: ICoreTerminalExt;
|
||||||
}
|
}
|
||||||
/* eslint-enable */
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some storage definitions.
|
* Some storage definitions.
|
||||||
@@ -99,6 +86,16 @@ export interface ICellSize {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageLayer = 'top' | 'bottom';
|
||||||
|
export type CursorPos = 'vt340' | 'iip';
|
||||||
|
|
||||||
|
export interface IAddImageOpts {
|
||||||
|
scrolling: boolean;
|
||||||
|
layer: ImageLayer;
|
||||||
|
zIndex: number;
|
||||||
|
cursorPos: CursorPos;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IImageSpec {
|
export interface IImageSpec {
|
||||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||||
origCellSize: ICellSize;
|
origCellSize: ICellSize;
|
||||||
@@ -107,4 +104,6 @@ export interface IImageSpec {
|
|||||||
marker: IMarker | undefined;
|
marker: IMarker | undefined;
|
||||||
tileCount: number;
|
tileCount: number;
|
||||||
bufferType: 'alternate' | 'normal';
|
bufferType: 'alternate' | 'normal';
|
||||||
|
layer: ImageLayer;
|
||||||
|
zIndex: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
|
import { IDisposable } from '@xterm/xterm';
|
||||||
|
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
|
||||||
import { ImageRenderer } from '../ImageRenderer';
|
import { ImageRenderer } from '../ImageRenderer';
|
||||||
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
|
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
|
||||||
|
import { KittyImageStorage } from './KittyImageStorage';
|
||||||
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||||
import {
|
import {
|
||||||
KittyAction,
|
KittyAction,
|
||||||
@@ -14,29 +16,26 @@ import {
|
|||||||
IKittyCommand,
|
IKittyCommand,
|
||||||
IPendingTransmission,
|
IPendingTransmission,
|
||||||
IKittyImageData,
|
IKittyImageData,
|
||||||
BYTES_PER_PIXEL_RGB,
|
KittyPixelConstants,
|
||||||
BYTES_PER_PIXEL_RGBA,
|
|
||||||
ALPHA_OPAQUE,
|
|
||||||
parseKittyCommand
|
parseKittyCommand
|
||||||
} from './KittyGraphicsTypes';
|
} from './KittyGraphicsTypes';
|
||||||
|
|
||||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
const enum Constants {
|
||||||
const DECODER_KEEP_DATA = 4194304;
|
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||||
const DECODER_INITIAL_DATA = 4194304; // 4MB
|
DECODER_KEEP_DATA = 4194304,
|
||||||
|
DECODER_INITIAL_DATA = 4194304, // 4MB
|
||||||
|
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||||
|
DECODER_OK = 0,
|
||||||
|
// Maximum control data size
|
||||||
|
MAX_CONTROL_DATA_SIZE = 512,
|
||||||
|
// Semicolon codepoint
|
||||||
|
SEMICOLON = 0x3B
|
||||||
|
}
|
||||||
|
|
||||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
|
||||||
const DECODER_OK: DecodeStatus.OK = 0;
|
|
||||||
|
|
||||||
// Maximum control data size
|
// Kitty graphics protocol handler with streaming base64 decoding.
|
||||||
const MAX_CONTROL_DATA_SIZE = 512;
|
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
|
||||||
|
|
||||||
// Semicolon codepoint
|
|
||||||
const SEMICOLON = 0x3B;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kitty graphics protocol handler with streaming base64 decoding.
|
|
||||||
*/
|
|
||||||
export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|
||||||
private _aborted = false;
|
private _aborted = false;
|
||||||
private _decodeError = false;
|
private _decodeError = false;
|
||||||
|
|
||||||
@@ -46,57 +45,66 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
|
|
||||||
// Streaming related states
|
// Streaming related states
|
||||||
|
|
||||||
/** True while receiving control data (before semicolon). */
|
// True while receiving control data (before semicolon).
|
||||||
private _inControlData = true;
|
private _inControlData = true;
|
||||||
|
|
||||||
/** Buffer for control data. */
|
// Buffer for control data.
|
||||||
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
|
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
|
||||||
private _controlLength = 0;
|
private _controlLength = 0;
|
||||||
|
|
||||||
/** Pre-calculated encoded size limit */
|
// Pre-calculated encoded size limit
|
||||||
private _encodedSizeLimit = 0;
|
private _encodedSizeLimit = 0;
|
||||||
private _totalEncodedSize = 0;
|
private _totalEncodedSize = 0;
|
||||||
|
|
||||||
/** Parsed command. These are the control data before semicolon. */
|
// Parsed command. These are the control data before semicolon.
|
||||||
private _parsedCommand: IKittyCommand | null = null;
|
private _parsedCommand: IKittyCommand | null = null;
|
||||||
|
|
||||||
// Storage related states
|
// Storage related states
|
||||||
|
|
||||||
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
|
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
|
||||||
private _nextImageId = 1;
|
// Tracks the pending key of the most recently started chunked upload.
|
||||||
/** Maps Kitty protocol image ID → ImageStorage internal ID for deletion/lookup. */
|
// Per spec, subsequent chunks only need m= (and optionally q=), without i=.
|
||||||
private _kittyIdToStorageId: Map<number, number> = new Map();
|
// When a chunk arrives with no i=, this key is used to find the pending upload.
|
||||||
// TODO: Eliminate double storage — raw image data lives here (as Blob) AND rendered
|
private _lastPendingKey: number | undefined;
|
||||||
// ImageBitmaps live in ImageStorage. Currently we only use ImageStorage.addImage(bitmap)
|
|
||||||
// for tiling + cursor movement + marker-based eviction.
|
|
||||||
//
|
|
||||||
|
|
||||||
// See: https://github.com/xtermjs/xterm.js/pull/5619#issuecomment-3853678815
|
|
||||||
private _images: Map<number, IKittyImageData> = new Map();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly _opts: IImageAddonOptions,
|
private readonly _opts: IImageAddonOptions,
|
||||||
private readonly _renderer: ImageRenderer,
|
private readonly _renderer: ImageRenderer,
|
||||||
private readonly _storage: ImageStorage,
|
private readonly _kittyStorage: KittyImageStorage,
|
||||||
private readonly _coreTerminal: ITerminalExt
|
private readonly _coreTerminal: ITerminalExt
|
||||||
) {
|
) {
|
||||||
// Convert decoded size limit -> max encoded bytes.
|
// Convert decoded size limit -> max encoded bytes.
|
||||||
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
||||||
// ensure we preallocate more than configured limit while using 4mb initial size.
|
// ensure we preallocate more than configured limit while using 4mb initial size.
|
||||||
this._initialEncodedBytes = Math.min(DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
for (const pending of this._pendingTransmissions.values()) {
|
this._cleanupAllPending();
|
||||||
pending.decoder.release();
|
|
||||||
}
|
|
||||||
this._pendingTransmissions.clear();
|
|
||||||
if (this._activeDecoder) {
|
if (this._activeDecoder) {
|
||||||
this._activeDecoder.release();
|
this._activeDecoder.release();
|
||||||
this._activeDecoder = null;
|
this._activeDecoder = null;
|
||||||
}
|
}
|
||||||
this._images.clear();
|
this._kittyStorage.reset();
|
||||||
this._kittyIdToStorageId.clear();
|
}
|
||||||
|
|
||||||
|
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 {
|
public start(): void {
|
||||||
@@ -120,7 +128,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
// Scan for semicolon
|
// Scan for semicolon
|
||||||
let controlEnd = end;
|
let controlEnd = end;
|
||||||
for (let i = start; i < end; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
if (data[i] === SEMICOLON) {
|
if (data[i] === Constants.SEMICOLON) {
|
||||||
this._inControlData = false;
|
this._inControlData = false;
|
||||||
controlEnd = i;
|
controlEnd = i;
|
||||||
break;
|
break;
|
||||||
@@ -129,7 +137,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
|
|
||||||
// Copy control data
|
// Copy control data
|
||||||
const copyLength = controlEnd - start;
|
const copyLength = controlEnd - start;
|
||||||
if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) {
|
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -161,15 +169,14 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Stream payload bytes into the base64 decoder.
|
||||||
* Stream payload bytes into the base64 decoder.
|
|
||||||
*/
|
|
||||||
private _streamPayload(data: Uint32Array, start: number, end: number): void {
|
private _streamPayload(data: Uint32Array, start: number, end: number): void {
|
||||||
if (this._aborted) return;
|
if (this._aborted) return;
|
||||||
|
|
||||||
// Check size limit (compare encoded bytes against pre-calculated limit)
|
// Check size limit (compare encoded bytes against pre-calculated limit)
|
||||||
// Include cumulative size from pending transmission for multi-chunk images
|
// Include cumulative size from pending transmission for multi-chunk images.
|
||||||
const pendingKey = this._parsedCommand?.id ?? 0;
|
// 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 pending = this._pendingTransmissions.get(pendingKey);
|
||||||
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
|
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
|
||||||
this._totalEncodedSize += end - start;
|
this._totalEncodedSize += end - start;
|
||||||
@@ -181,7 +188,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
}
|
}
|
||||||
this._activeDecoder = null;
|
this._activeDecoder = null;
|
||||||
if (pending) {
|
if (pending) {
|
||||||
this._pendingTransmissions.delete(pendingKey);
|
this._removePendingEntry(pendingKey);
|
||||||
}
|
}
|
||||||
this._aborted = true;
|
this._aborted = true;
|
||||||
return;
|
return;
|
||||||
@@ -193,7 +200,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
this._activeDecoder = pending.decoder;
|
this._activeDecoder = pending.decoder;
|
||||||
}
|
}
|
||||||
if (!this._activeDecoder) {
|
if (!this._activeDecoder) {
|
||||||
this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||||
this._activeDecoder.init();
|
this._activeDecoder.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +209,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
this._activeDecoder = null;
|
this._activeDecoder = null;
|
||||||
this._decodeError = true;
|
this._decodeError = true;
|
||||||
if (pending) {
|
if (pending) {
|
||||||
this._pendingTransmissions.delete(pendingKey);
|
this._removePendingEntry(pendingKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +236,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
return this._handleDelete(cmd);
|
return this._handleDelete(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingKey = cmd.id ?? 0;
|
// 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 isMoreComing = cmd.more === 1;
|
||||||
const pending = this._pendingTransmissions.get(pendingKey);
|
const pending = this._pendingTransmissions.get(pendingKey);
|
||||||
|
|
||||||
@@ -246,11 +254,17 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
decodeError: this._decodeError
|
decodeError: this._decodeError
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this._lastPendingKey = pendingKey;
|
||||||
this._activeDecoder = null;
|
this._activeDecoder = null;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final chunk received — clear the last pending key
|
||||||
|
if (pending) {
|
||||||
|
this._lastPendingKey = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
let decodeError = this._decodeError;
|
let decodeError = this._decodeError;
|
||||||
let finalCmd = cmd;
|
let finalCmd = cmd;
|
||||||
let decoder = this._activeDecoder;
|
let decoder = this._activeDecoder;
|
||||||
@@ -308,7 +322,16 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
case KittyAction.QUERY:
|
case KittyAction.QUERY:
|
||||||
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
|
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
|
||||||
return true;
|
return true;
|
||||||
|
case KittyAction.PLACEMENT:
|
||||||
|
return this._handlePlacement(cmd);
|
||||||
default:
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,8 +342,14 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
switch (action) {
|
switch (action) {
|
||||||
case KittyAction.TRANSMIT: {
|
case KittyAction.TRANSMIT: {
|
||||||
const result = this._handleTransmit(cmd, bytes, decodeError);
|
const result = this._handleTransmit(cmd, bytes, decodeError);
|
||||||
if (cmd.id !== undefined && !decodeError && bytes.length > 0) {
|
// Only send response when _handleTransmit didn't already respond
|
||||||
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
|
// (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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -328,59 +357,90 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
return this._handleTransmitDisplay(cmd, bytes, decodeError);
|
return this._handleTransmitDisplay(cmd, bytes, decodeError);
|
||||||
case KittyAction.QUERY:
|
case KittyAction.QUERY:
|
||||||
return this._handleQuery(cmd, bytes, decodeError);
|
return this._handleQuery(cmd, bytes, decodeError);
|
||||||
|
case KittyAction.PLACEMENT:
|
||||||
|
// a=p ignores any payload — image data was already transmitted
|
||||||
|
return this._handlePlacement(cmd);
|
||||||
default:
|
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;
|
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 {
|
private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
||||||
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
|
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
|
||||||
// Currently only supports direct transmission (t=d, the default).
|
// 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=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=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.
|
// - 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:
|
// 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
|
// 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
|
// 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)
|
// 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;
|
if (decodeError || bytes.length === 0) return true;
|
||||||
|
|
||||||
const id = cmd.id ?? this._nextImageId++;
|
this._kittyStorage.storeImage(cmd.id, {
|
||||||
const image: IKittyImageData = {
|
|
||||||
id,
|
|
||||||
data: new Blob([bytes as BlobPart]),
|
data: new Blob([bytes as BlobPart]),
|
||||||
width: cmd.width ?? 0,
|
width: cmd.width ?? 0,
|
||||||
height: cmd.height ?? 0,
|
height: cmd.height ?? 0,
|
||||||
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
|
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
|
||||||
compression: cmd.compression ?? ''
|
compression: cmd.compression ?? ''
|
||||||
};
|
});
|
||||||
this._images.set(id, image);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
||||||
if (decodeError) return true;
|
if (decodeError) {
|
||||||
|
if (cmd.id !== undefined) {
|
||||||
const pendingKey = cmd.id ?? 0;
|
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
this._handleTransmit(cmd, bytes, decodeError);
|
this._handleTransmit(cmd, bytes, decodeError);
|
||||||
|
|
||||||
// If still accumulating chunks, don't display yet
|
const id = cmd.id ?? this._kittyStorage.lastImageId;
|
||||||
if (this._pendingTransmissions.has(pendingKey)) return true;
|
const image = this._kittyStorage.getImage(id);
|
||||||
|
|
||||||
// Display the completed image
|
|
||||||
const id = cmd.id ?? this._nextImageId - 1;
|
|
||||||
const image = this._images.get(id);
|
|
||||||
if (image) {
|
if (image) {
|
||||||
const result = this._displayImage(image, cmd);
|
const result = this._displayImage(image, cmd);
|
||||||
if (cmd.id !== undefined) {
|
if (cmd.id !== undefined) {
|
||||||
return (result as Promise<boolean>).then(r => {
|
return result.then(success => {
|
||||||
this._sendResponse(id, 'OK', cmd.quiet ?? 0);
|
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
|
||||||
return r;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result.then(() => true);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -389,6 +449,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
const id = cmd.id ?? 0;
|
const id = cmd.id ?? 0;
|
||||||
const quiet = cmd.quiet ?? 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)
|
// Check decode error first (invalid base64)
|
||||||
if (decodeError) {
|
if (decodeError) {
|
||||||
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
|
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
|
||||||
@@ -414,7 +483,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
const expectedBytes = width * height * bytesPerPixel;
|
const expectedBytes = width * height * bytesPerPixel;
|
||||||
|
|
||||||
if (bytes.length < expectedBytes) {
|
if (bytes.length < expectedBytes) {
|
||||||
@@ -428,110 +497,194 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _handleDelete(cmd: IKittyCommand): boolean {
|
private _handleDelete(cmd: IKittyCommand): boolean {
|
||||||
const id = cmd.id;
|
// Per spec: default delete selector is 'a' (delete all visible placements)
|
||||||
|
const selector = cmd.deleteSelector ?? 'a';
|
||||||
|
|
||||||
if (id !== undefined) {
|
// TODO: Distinguish lowercase (delete placements only) from uppercase
|
||||||
// Abort in-flight chunked upload for this specific image
|
// (delete placements + free stored image data). Currently both variants
|
||||||
const pending = this._pendingTransmissions.get(id);
|
// free everything since we don't separate stored data from placements.
|
||||||
if (pending) {
|
switch (selector) {
|
||||||
pending.decoder.release();
|
case 'a':
|
||||||
this._pendingTransmissions.delete(id);
|
case 'A':
|
||||||
}
|
this._cleanupAllPending();
|
||||||
|
this._kittyStorage.deleteAll();
|
||||||
this._images.delete(id);
|
break;
|
||||||
const storageId = this._kittyIdToStorageId.get(id);
|
case 'i':
|
||||||
if (storageId !== undefined) {
|
case 'I':
|
||||||
this._storage.deleteImage(storageId);
|
// TODO: When placement id tracking is implemented (see TODO in
|
||||||
this._kittyIdToStorageId.delete(id);
|
// KittyImageStorage), d=i with p=<pid> should delete only that
|
||||||
}
|
// specific placement, while d=i without p should delete all
|
||||||
} else {
|
// placements for the image.
|
||||||
// Abort all in-flight chunked uploads
|
if (cmd.id !== undefined) {
|
||||||
for (const pending of this._pendingTransmissions.values()) {
|
const pending = this._pendingTransmissions.get(cmd.id);
|
||||||
pending.decoder.release();
|
if (pending) {
|
||||||
}
|
pending.decoder.release();
|
||||||
this._pendingTransmissions.clear();
|
}
|
||||||
|
this._removePendingEntry(cmd.id);
|
||||||
this._images.clear();
|
this._kittyStorage.deleteById(cmd.id);
|
||||||
for (const storageId of this._kittyIdToStorageId.values()) {
|
}
|
||||||
this._storage.deleteImage(storageId);
|
break;
|
||||||
}
|
default:
|
||||||
this._kittyIdToStorageId.clear();
|
// Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _sendResponse(id: number, message: string, quiet: number): void {
|
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
|
||||||
const isOk = message === 'OK';
|
const isOk = message === 'OK';
|
||||||
if (isOk && quiet === 1) return;
|
if (isOk && quiet >= 1) return;
|
||||||
if (!isOk && quiet === 2) return;
|
if (!isOk && quiet >= 2) return;
|
||||||
|
|
||||||
const response = `\x1b_Gi=${id};${message}\x1b\\`;
|
const pPart = placementId ? `,p=${placementId}` : '';
|
||||||
|
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
|
||||||
this._coreTerminal._core.coreService.triggerDataEvent(response);
|
this._coreTerminal._core.coreService.triggerDataEvent(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image display
|
// Image display
|
||||||
|
|
||||||
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): boolean | Promise<boolean> {
|
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
|
||||||
return this._decodeAndDisplay(image, cmd)
|
return this._decodeAndDisplay(image, cmd)
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => true);
|
.catch(() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||||
const bitmap = await this._createBitmap(image);
|
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
|
||||||
|
|
||||||
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
try {
|
||||||
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
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);
|
||||||
|
|
||||||
// Per spec: c/r default to image's natural cell dimensions
|
const maxCropW = Math.max(0, bitmap.width - cropX);
|
||||||
const imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw);
|
const maxCropH = Math.max(0, bitmap.height - cropY);
|
||||||
const imgRows = cmd.rows ?? Math.ceil(bitmap.height / ch);
|
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
|
||||||
|
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
|
||||||
|
|
||||||
let w = bitmap.width;
|
if (finalCropW === 0 || finalCropH === 0) {
|
||||||
let h = bitmap.height;
|
throw new Error('invalid source rectangle');
|
||||||
|
}
|
||||||
|
|
||||||
// Scale bitmap to fit placement rectangle when c/r are specified
|
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
|
||||||
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
|
||||||
w = Math.round(imgCols * cw);
|
bitmap.close();
|
||||||
h = Math.round(imgRows * ch);
|
bitmap = cropped;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (w * h > this._opts.pixelLimit) return;
|
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||||
|
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||||
|
|
||||||
// Save cursor position before addImage modifies it
|
// Per spec: c/r default to image's natural cell dimensions.
|
||||||
const buffer = this._coreTerminal._core.buffer;
|
// If only one of c/r is specified, compute the other from image aspect ratio.
|
||||||
const savedX = buffer.x;
|
let imgCols: number;
|
||||||
const savedY = buffer.y;
|
let imgRows: number;
|
||||||
const savedYbase = buffer.ybase;
|
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 storageId: number;
|
let w = bitmap.width;
|
||||||
if (w !== bitmap.width || h !== bitmap.height) {
|
let h = bitmap.height;
|
||||||
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
|
||||||
storageId = this._storage.addImage(resized);
|
|
||||||
} else {
|
|
||||||
storageId = this._storage.addImage(bitmap);
|
|
||||||
}
|
|
||||||
this._kittyIdToStorageId.set(image.id, storageId);
|
|
||||||
|
|
||||||
// Kitty cursor movement
|
// Scale bitmap to fit placement rectangle when c/r are specified
|
||||||
// Per spec: cursor placed at first column after last image column,
|
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
||||||
// on the last row of the image. C=1 means don't move cursor.
|
w = Math.round(imgCols * cw);
|
||||||
if (cmd.cursorMovement === 1) {
|
h = Math.round(imgRows * ch);
|
||||||
// C=1: restore cursor to position before image was placed
|
}
|
||||||
const scrolled = buffer.ybase - savedYbase;
|
|
||||||
buffer.x = savedX;
|
if (w * h > this._opts.pixelLimit) {
|
||||||
// Can't restore cursor to scrollback?
|
throw new Error('image exceeds pixel limit');
|
||||||
buffer.y = Math.max(savedY - scrolled, 0);
|
}
|
||||||
} else {
|
|
||||||
// Default (C=0): advance cursor horizontally past the image
|
// Save cursor position before addImage modifies it
|
||||||
// addImage already positioned cursor on the last row via lineFeeds
|
const buffer = this._coreTerminal._core.buffer;
|
||||||
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
|
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.
|
||||||
* Create ImageBitmap from already-decoded image data.
|
|
||||||
*/
|
|
||||||
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
|
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
|
||||||
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
|
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
|
||||||
|
|
||||||
@@ -551,7 +704,10 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
||||||
createImageBitmap(canvas).then(resolve).catch(reject);
|
createImageBitmap(canvas).then(resolve).catch(reject);
|
||||||
});
|
});
|
||||||
img.addEventListener('error', reject);
|
img.addEventListener('error', () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error('Failed to load image'));
|
||||||
|
});
|
||||||
img.src = url;
|
img.src = url;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -566,7 +722,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
throw new Error('Width and height required for raw pixel data');
|
throw new Error('Width and height required for raw pixel data');
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||||
const expectedBytes = width * height * bytesPerPixel;
|
const expectedBytes = width * height * bytesPerPixel;
|
||||||
|
|
||||||
if (bytes.length < expectedBytes) {
|
if (bytes.length < expectedBytes) {
|
||||||
@@ -574,18 +730,43 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pixelCount = width * height;
|
const pixelCount = width * height;
|
||||||
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
|
|
||||||
const isRgba = image.format === KittyFormat.RGBA;
|
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 srcOffset = 0;
|
||||||
let dstOffset = 0;
|
let dstOffset = 0;
|
||||||
for (let i = 0; i < pixelCount; i++) {
|
for (let i = 0; i < alignedPixels; i += 4) {
|
||||||
data[dstOffset] = bytes[srcOffset];
|
const b0 = src32[srcOffset++];
|
||||||
data[dstOffset + 1] = bytes[srcOffset + 1];
|
const b1 = src32[srcOffset++];
|
||||||
data[dstOffset + 2] = bytes[srcOffset + 2];
|
const b2 = src32[srcOffset++];
|
||||||
data[dstOffset + 3] = isRgba ? bytes[srcOffset + 3] : ALPHA_OPAQUE;
|
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
|
||||||
srcOffset += bytesPerPixel;
|
dst32[dstOffset++] = 0xFF000000 | b0;
|
||||||
dstOffset += BYTES_PER_PIXEL_RGBA;
|
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));
|
return createImageBitmap(new ImageData(data, width, height));
|
||||||
@@ -625,7 +806,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public get images(): ReadonlyMap<number, IKittyImageData> {
|
public get images(): ReadonlyMap<number, IKittyImageData> {
|
||||||
return this._images;
|
return this._kittyStorage.images;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
|
||||||
|
return this._kittyStorage.kittyIdToStorageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
|
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
@@ -86,5 +86,64 @@ describe('KittyGraphicsTypes', () => {
|
|||||||
assert.ok(isNaN(cmd.format!));
|
assert.ok(isNaN(cmd.format!));
|
||||||
assert.strictEqual(cmd.id, 5);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
* @license MIT
|
* @license MIT
|
||||||
*
|
*
|
||||||
* Kitty graphics protocol types, constants, and parsing utilities.
|
* Kitty graphics protocol types, constants, and parsing utilities.
|
||||||
@@ -7,10 +7,8 @@
|
|||||||
|
|
||||||
import type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
import type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||||
|
|
||||||
/**
|
// Kitty graphics protocol action types.
|
||||||
* Kitty graphics protocol action types.
|
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
|
||||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
|
|
||||||
*/
|
|
||||||
export const enum KittyAction {
|
export const enum KittyAction {
|
||||||
TRANSMIT = 't',
|
TRANSMIT = 't',
|
||||||
TRANSMIT_DISPLAY = 'T',
|
TRANSMIT_DISPLAY = 'T',
|
||||||
@@ -19,29 +17,23 @@ export const enum KittyAction {
|
|||||||
DELETE = 'd'
|
DELETE = 'd'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Kitty graphics protocol format types.
|
||||||
* Kitty graphics protocol format types.
|
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
|
||||||
*/
|
|
||||||
export const enum KittyFormat {
|
export const enum KittyFormat {
|
||||||
RGB = 24,
|
RGB = 24,
|
||||||
RGBA = 32,
|
RGBA = 32,
|
||||||
PNG = 100
|
PNG = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Kitty graphics protocol compression types.
|
||||||
* Kitty graphics protocol compression types.
|
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
|
||||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
|
|
||||||
*/
|
|
||||||
export const enum KittyCompression {
|
export const enum KittyCompression {
|
||||||
NONE = '',
|
NONE = '',
|
||||||
ZLIB = 'z'
|
ZLIB = 'z'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Kitty graphics protocol control data keys.
|
||||||
* Kitty graphics protocol control data keys.
|
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
|
||||||
*/
|
|
||||||
export const enum KittyKey {
|
export const enum KittyKey {
|
||||||
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
|
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
|
||||||
ACTION = 'a',
|
ACTION = 'a',
|
||||||
@@ -59,6 +51,14 @@ export const enum KittyKey {
|
|||||||
X_OFFSET = 'x',
|
X_OFFSET = 'x',
|
||||||
// The top edge (in pixels) of the image area to display
|
// The top edge (in pixels) of the image area to display
|
||||||
Y_OFFSET = 'y',
|
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
|
// Number of terminal columns to display the image over
|
||||||
COLUMNS = 'c',
|
COLUMNS = 'c',
|
||||||
// Number of terminal rows to display the image over
|
// Number of terminal rows to display the image over
|
||||||
@@ -70,17 +70,25 @@ export const enum KittyKey {
|
|||||||
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
|
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
|
||||||
QUIET = 'q',
|
QUIET = 'q',
|
||||||
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
|
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
|
||||||
CURSOR_MOVEMENT = 'C'
|
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
|
// Pixel format constants
|
||||||
export const BYTES_PER_PIXEL_RGB = 3;
|
export const enum KittyPixelConstants {
|
||||||
export const BYTES_PER_PIXEL_RGBA = 4;
|
BYTES_PER_PIXEL_RGB = 3,
|
||||||
export const ALPHA_OPAQUE = 255;
|
BYTES_PER_PIXEL_RGBA = 4,
|
||||||
|
ALPHA_OPAQUE = 255
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
// Parsed Kitty graphics command.
|
||||||
* Parsed Kitty graphics command.
|
|
||||||
*/
|
|
||||||
export interface IKittyCommand {
|
export interface IKittyCommand {
|
||||||
action?: string;
|
action?: string;
|
||||||
format?: number;
|
format?: number;
|
||||||
@@ -90,36 +98,40 @@ export interface IKittyCommand {
|
|||||||
height?: number;
|
height?: number;
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
|
sourceWidth?: number;
|
||||||
|
sourceHeight?: number;
|
||||||
|
xOffset?: number;
|
||||||
|
yOffset?: number;
|
||||||
columns?: number;
|
columns?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
more?: number;
|
more?: number;
|
||||||
quiet?: number;
|
quiet?: number;
|
||||||
cursorMovement?: number;
|
cursorMovement?: number;
|
||||||
|
zIndex?: number;
|
||||||
|
transmission?: string;
|
||||||
|
deleteSelector?: string;
|
||||||
|
placementId?: number;
|
||||||
compression?: string;
|
compression?: string;
|
||||||
payload?: string;
|
payload?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Pending chunked transmission state.
|
||||||
* Pending chunked transmission state.
|
// Stores metadata from the first chunk while accumulating decoded payload data.
|
||||||
* Stores metadata from the first chunk while accumulating decoded payload data.
|
|
||||||
*/
|
|
||||||
export interface IPendingTransmission {
|
export interface IPendingTransmission {
|
||||||
/** The parsed command from the first chunk (contains action, format, dimensions, etc.) */
|
// The parsed command from the first chunk (contains action, format, dimensions, etc.)
|
||||||
cmd: IKittyCommand;
|
cmd: IKittyCommand;
|
||||||
/** Decoder used across chunked payloads */
|
// Decoder used across chunked payloads
|
||||||
decoder: Base64Decoder;
|
decoder: Base64Decoder;
|
||||||
/** Total encoded (base64) bytes received across all chunks - for size limit enforcement */
|
// Total encoded (base64) bytes received across all chunks - for size limit enforcement
|
||||||
totalEncodedSize: number;
|
totalEncodedSize: number;
|
||||||
/** Whether any chunk has failed to decode */
|
// Whether any chunk has failed to decode
|
||||||
decodeError: boolean;
|
decodeError: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Stored Kitty image data.
|
||||||
* Stored Kitty image data.
|
|
||||||
*/
|
|
||||||
export interface IKittyImageData {
|
export interface IKittyImageData {
|
||||||
id: number;
|
id: number;
|
||||||
/** Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit */
|
// Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit
|
||||||
data: Blob;
|
data: Blob;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -127,9 +139,7 @@ export interface IKittyImageData {
|
|||||||
compression?: string;
|
compression?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Parses Kitty graphics control data into a command object.
|
||||||
* Parses Kitty graphics control data into a command object.
|
|
||||||
*/
|
|
||||||
export function parseKittyCommand(data: string): IKittyCommand {
|
export function parseKittyCommand(data: string): IKittyCommand {
|
||||||
const cmd: IKittyCommand = {};
|
const cmd: IKittyCommand = {};
|
||||||
const parts = data.split(',');
|
const parts = data.split(',');
|
||||||
@@ -150,7 +160,15 @@ export function parseKittyCommand(data: string): IKittyCommand {
|
|||||||
cmd.compression = value;
|
cmd.compression = value;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const numValue = parseInt(value);
|
if (key === KittyKey.TRANSMISSION) {
|
||||||
|
cmd.transmission = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === KittyKey.DELETE_SELECTOR) {
|
||||||
|
cmd.deleteSelector = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const numValue = parseInt(value, 10);
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case KittyKey.FORMAT: cmd.format = numValue; break;
|
case KittyKey.FORMAT: cmd.format = numValue; break;
|
||||||
case KittyKey.ID: cmd.id = numValue; break;
|
case KittyKey.ID: cmd.id = numValue; break;
|
||||||
@@ -159,11 +177,17 @@ export function parseKittyCommand(data: string): IKittyCommand {
|
|||||||
case KittyKey.HEIGHT: cmd.height = numValue; break;
|
case KittyKey.HEIGHT: cmd.height = numValue; break;
|
||||||
case KittyKey.X_OFFSET: cmd.x = numValue; break;
|
case KittyKey.X_OFFSET: cmd.x = numValue; break;
|
||||||
case KittyKey.Y_OFFSET: cmd.y = 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.COLUMNS: cmd.columns = numValue; break;
|
||||||
case KittyKey.ROWS: cmd.rows = numValue; break;
|
case KittyKey.ROWS: cmd.rows = numValue; break;
|
||||||
case KittyKey.MORE: cmd.more = numValue; break;
|
case KittyKey.MORE: cmd.more = numValue; break;
|
||||||
case KittyKey.QUIET: cmd.quiet = numValue; break;
|
case KittyKey.QUIET: cmd.quiet = numValue; break;
|
||||||
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IDisposable } from '@xterm/xterm';
|
||||||
|
import { ImageStorage } from '../ImageStorage';
|
||||||
|
import { ImageLayer, IAddImageOpts } 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
this._addImageOpts.scrolling = scrolling;
|
||||||
|
this._addImageOpts.layer = layer;
|
||||||
|
this._addImageOpts.zIndex = zIndex;
|
||||||
|
const storageId = this._storage.addImage(image, this._addImageOpts);
|
||||||
|
this._kittyIdToStorageId.set(kittyId, storageId);
|
||||||
|
this._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.
|
||||||
@@ -74,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [
|
|||||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]],
|
[readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]],
|
||||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
|
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
|
||||||
];
|
];
|
||||||
|
const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64');
|
||||||
|
const qoiEnc = new QoiEncoder(1024*1024);
|
||||||
|
const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80);
|
||||||
|
const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64');
|
||||||
|
|
||||||
let ctx: ITestContext;
|
let ctx: ITestContext;
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeAll(async ({ browser }) => {
|
||||||
@@ -85,11 +90,6 @@ test.afterAll(async () => await ctx.page.close());
|
|||||||
test.describe('ImageAddon', () => {
|
test.describe('ImageAddon', () => {
|
||||||
|
|
||||||
test.beforeEach(async ({}, testInfo) => {
|
test.beforeEach(async ({}, testInfo) => {
|
||||||
// DEBT: This test never worked on webkit
|
|
||||||
if (ctx.browser.browserType().name() === 'webkit') {
|
|
||||||
testInfo.skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.term.reset()
|
window.term.reset()
|
||||||
window.imageAddon?.dispose();
|
window.imageAddon?.dispose();
|
||||||
@@ -132,13 +132,13 @@ test.describe('ImageAddon', () => {
|
|||||||
sixelSupport: true,
|
sixelSupport: true,
|
||||||
sixelScrolling: true,
|
sixelScrolling: true,
|
||||||
sixelPaletteLimit: 512, // set to 512 to get example image working
|
sixelPaletteLimit: 512, // set to 512 to get example image working
|
||||||
sixelSizeLimit: 25000000,
|
sixelSizeLimit: 33554432,
|
||||||
storageLimit: 128,
|
storageLimit: 128,
|
||||||
showPlaceholder: true,
|
showPlaceholder: true,
|
||||||
iipSupport: true,
|
iipSupport: true,
|
||||||
iipSizeLimit: 20000000,
|
iipSizeLimit: 33554432,
|
||||||
kittySupport: true,
|
kittySupport: true,
|
||||||
kittySizeLimit: 20000000
|
kittySizeLimit: 33554432
|
||||||
};
|
};
|
||||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||||
});
|
});
|
||||||
@@ -196,6 +196,18 @@ test.describe('ImageAddon', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('image lifecycle & eviction', () => {
|
test.describe('image lifecycle & eviction', () => {
|
||||||
|
test('onImageAdded fires for each image', async () => {
|
||||||
|
await ctx.page.evaluate(`
|
||||||
|
window._imageAddedCount = 0;
|
||||||
|
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
|
||||||
|
`);
|
||||||
|
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||||
|
await pollFor(ctx.page, 'window._imageAddedCount', 1);
|
||||||
|
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||||
|
await pollFor(ctx.page, 'window._imageAddedCount', 2);
|
||||||
|
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||||
|
await pollFor(ctx.page, 'window._imageAddedCount', 3);
|
||||||
|
});
|
||||||
test('delete image once scrolled off', async () => {
|
test('delete image once scrolled off', async () => {
|
||||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||||
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
|
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
|
||||||
@@ -278,6 +290,67 @@ test.describe('ImageAddon', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('IIP support', () => {
|
||||||
|
test('size params is optional/informative', async () => {
|
||||||
|
// too small size param
|
||||||
|
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||||
|
// no size aparm at all
|
||||||
|
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(2), [640, 80]);
|
||||||
|
});
|
||||||
|
test.skip('FilePart support - bytewise', async () => {
|
||||||
|
// opener
|
||||||
|
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||||
|
// byte-wise
|
||||||
|
for (let i = 0; i < PALETTE_PNG_BASE64.length; ++i) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64[i]}\x07`);
|
||||||
|
}
|
||||||
|
// finalizer
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||||
|
});
|
||||||
|
test('FilePart support - chunks', async () => {
|
||||||
|
// opener
|
||||||
|
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||||
|
// chunks
|
||||||
|
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||||
|
}
|
||||||
|
// finalizer
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||||
|
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||||
|
});
|
||||||
|
test('FilePart support - edgecases', async () => {
|
||||||
|
// multiple opener do not harm
|
||||||
|
for (let i = 0; i < 10; ++i) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||||
|
}
|
||||||
|
// multiple finalizer do not harm
|
||||||
|
for (let i = 0; i < 5; ++i) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||||
|
}
|
||||||
|
// chunks w'o opener are ignored
|
||||||
|
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||||
|
}
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
|
||||||
|
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 0);
|
||||||
|
});
|
||||||
|
test('FilePart support - other IIP sequence cancels chunked image', async () => {
|
||||||
|
// opener
|
||||||
|
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
|
||||||
|
// chunks
|
||||||
|
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
|
||||||
|
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
|
||||||
|
}
|
||||||
|
// finalizer skipped
|
||||||
|
// write spinfox.png
|
||||||
|
await ctx.proxy.write(TESTDATA_IIP[1][0]);
|
||||||
|
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('IIP support - testimages', () => {
|
test.describe('IIP support - testimages', () => {
|
||||||
test('palette.png', async () => {
|
test('palette.png', async () => {
|
||||||
await ctx.proxy.write(TESTDATA_IIP[0][0]);
|
await ctx.proxy.write(TESTDATA_IIP[0][0]);
|
||||||
@@ -300,6 +373,77 @@ 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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('text overwrite removes tiles', () => {
|
||||||
|
test('sixel', async () => {
|
||||||
|
await assertTextClearsTiles(SIXEL_SEQ_0);
|
||||||
|
});
|
||||||
|
test('iip', async () => {
|
||||||
|
await assertTextClearsTiles(TESTDATA_IIP[0][0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,3 +477,24 @@ 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')`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasTileAtBufferCell(x: number, y: number): Promise<boolean> {
|
||||||
|
return ctx.page.evaluate(`!!window.imageAddon.extractTileAtBufferCell(${x}, ${y})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertTextClearsTiles(imageSeq: string): Promise<void> {
|
||||||
|
await ctx.proxy.write('\x1b[H' + imageSeq);
|
||||||
|
await pollFor(ctx.page, '!!window.imageAddon.extractTileAtBufferCell(5, 1)', true);
|
||||||
|
ok(await hasTileAtBufferCell(0, 1));
|
||||||
|
await ctx.proxy.write('\x1b[2;6H#######');
|
||||||
|
for (let x = 5; x < 12; x++) {
|
||||||
|
strictEqual(await hasTileAtBufferCell(x, 1), false);
|
||||||
|
}
|
||||||
|
ok(await hasTileAtBufferCell(0, 1));
|
||||||
|
ok(await hasTileAtBufferCell(13, 1));
|
||||||
|
ok(((await ctx.page.evaluate('window.term.buffer.active.getLine(1).translateToString(true)')) as string).includes('#######'));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,7 +3,7 @@
|
|||||||
* @license MIT
|
* @license MIT
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Terminal, ITerminalAddon } from '@xterm/xterm';
|
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
|
||||||
|
|
||||||
declare module '@xterm/addon-image' {
|
declare module '@xterm/addon-image' {
|
||||||
export interface IImageAddonOptions {
|
export interface IImageAddonOptions {
|
||||||
@@ -116,6 +116,11 @@ declare module '@xterm/addon-image' {
|
|||||||
*/
|
*/
|
||||||
public showPlaceholder: boolean;
|
public showPlaceholder: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired whenever a new image is added to storage.
|
||||||
|
*/
|
||||||
|
public readonly onImageAdded: IEvent<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get original image canvas at buffer position.
|
* Get original image canvas at buffer position.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -31,15 +31,11 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lru-cache": "^6.0.0",
|
"lru-cache": "^11.3.6",
|
||||||
"opentype.js": "^0.8.0"
|
"opentype.js": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lru-cache": "^5.1.0",
|
"@types/opentype.js": "^1.3.9",
|
||||||
"@types/opentype.js": "^0.7.0",
|
"font-finder": "^1.1.0"
|
||||||
"axios": "^1.6.0",
|
|
||||||
"font-finder": "^1.1.0",
|
|
||||||
"mkdirp": "0.5.5",
|
|
||||||
"yauzl": "^2.10.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as opentype from 'opentype.js';
|
import * as opentype from 'opentype.js';
|
||||||
import LRUCache = require('lru-cache');
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types';
|
import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types';
|
||||||
import mergeTrees from './merge';
|
import mergeTrees from './merge';
|
||||||
@@ -23,8 +23,8 @@ class FontImpl implements IFont {
|
|||||||
|
|
||||||
if (options.cacheSize > 0) {
|
if (options.cacheSize > 0) {
|
||||||
this._cache = new LRUCache({
|
this._cache = new LRUCache({
|
||||||
max: options.cacheSize,
|
maxSize: options.cacheSize,
|
||||||
length: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
|
sizeCalculation: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export default function mergeRange(ranges: [number, number][], newRangeStart: nu
|
|||||||
// current range
|
// current range
|
||||||
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
|
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
|
||||||
ranges.splice(i, 1);
|
ranges.splice(i, 1);
|
||||||
inRange = false;
|
|
||||||
return ranges;
|
return ranges;
|
||||||
}
|
}
|
||||||
// Case 7: New range extends from previous range past the
|
// Case 7: New range extends from previous range past the
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const CACHE_SIZE = 100000;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable ligature support for the provided Terminal instance. To function
|
* Enable ligature support for the provided Terminal instance. To function
|
||||||
* properly, this must be called after `open()` is called on the therminal. If
|
* properly, this must be called after `open()` is called on the terminal. If
|
||||||
* the font currently in use supports ligatures, the terminal will automatically
|
* the font currently in use supports ligatures, the terminal will automatically
|
||||||
* start to render them.
|
* start to render them.
|
||||||
* @param term Terminal instance from xterm.js
|
* @param term Terminal instance from xterm.js
|
||||||
|
|||||||
@@ -9,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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
|
|||||||
|
|
||||||
|
|
||||||
let ctx: ITestContext;
|
let ctx: ITestContext;
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
ctx = await createTestContext(browser);
|
|
||||||
ctx.page.setViewportSize({ width: 1024, height: 768 });
|
|
||||||
await openTerminal(ctx);
|
|
||||||
});
|
|
||||||
test.afterAll(async () => await ctx.page.close());
|
|
||||||
|
|
||||||
|
|
||||||
test.describe('ProgressAddon', () => {
|
test.describe('ProgressAddon', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
ctx = await createTestContext(browser);
|
||||||
|
await openTerminal(ctx);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await ctx?.page?.close();
|
||||||
|
});
|
||||||
|
|
||||||
test.beforeEach(async function(): Promise<any> {
|
test.beforeEach(async function(): Promise<any> {
|
||||||
await ctx.page.evaluate(`
|
await ctx.page.evaluate(`
|
||||||
window.progressStack = [];
|
window.progressStack = [];
|
||||||
|
|||||||
@@ -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": {
|
||||||
"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"
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { DecorationManager } from './DecorationManager';
|
||||||
|
import { SearchEngine } from './SearchEngine';
|
||||||
|
import { SearchLineCache } from './SearchLineCache';
|
||||||
|
import { Terminal } from 'browser/public/Terminal';
|
||||||
|
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
||||||
|
import type { IDecorationOptions } from '@xterm/xterm';
|
||||||
|
import { DisposableStore } from 'common/Lifecycle';
|
||||||
|
|
||||||
|
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||||
|
return new Promise(r => terminal.write(data, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DecorationManager', () => {
|
||||||
|
let store: DisposableStore;
|
||||||
|
let terminal: Terminal;
|
||||||
|
let decorationManager: DecorationManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new DisposableStore();
|
||||||
|
terminal = store.add(new Terminal({ cols: 10, rows: 5 }));
|
||||||
|
decorationManager = store.add(new DecorationManager(terminal));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split highlight decorations for a wrapped match', async () => {
|
||||||
|
await writeP(terminal, '0123456789abcde');
|
||||||
|
const searchEngine = new SearchEngine(terminal, store.add(new SearchLineCache(terminal)));
|
||||||
|
const match = searchEngine.find('9abc', 0, 0);
|
||||||
|
assert.ok(match);
|
||||||
|
|
||||||
|
const decorationOptions: IDecorationOptions[] = [];
|
||||||
|
const registerDecoration = terminal.registerDecoration.bind(terminal);
|
||||||
|
terminal.registerDecoration = (options: IDecorationOptions) => {
|
||||||
|
decorationOptions.push(options);
|
||||||
|
return registerDecoration(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ISearchDecorationOptions = {
|
||||||
|
matchOverviewRuler: '#ff0000',
|
||||||
|
activeMatchColorOverviewRuler: '#00ff00'
|
||||||
|
};
|
||||||
|
decorationManager.createHighlightDecorations([match], options);
|
||||||
|
|
||||||
|
assert.strictEqual(decorationOptions.length, 2);
|
||||||
|
assert.strictEqual(decorationOptions[0].x, 9);
|
||||||
|
assert.strictEqual(decorationOptions[0].width, 1);
|
||||||
|
assert.strictEqual(decorationOptions[1].x, 0);
|
||||||
|
assert.strictEqual(decorationOptions[1].width, 3);
|
||||||
|
|
||||||
|
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
|
||||||
|
assert.strictEqual(withOverviewRuler.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only add one overview ruler marker per buffer line', async () => {
|
||||||
|
await writeP(terminal, 'abcdefghij');
|
||||||
|
const decorationOptions: IDecorationOptions[] = [];
|
||||||
|
const registerDecoration = terminal.registerDecoration.bind(terminal);
|
||||||
|
terminal.registerDecoration = (options: IDecorationOptions) => {
|
||||||
|
decorationOptions.push(options);
|
||||||
|
return registerDecoration(options);
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ISearchDecorationOptions = {
|
||||||
|
matchOverviewRuler: '#ff0000',
|
||||||
|
activeMatchColorOverviewRuler: '#00ff00'
|
||||||
|
};
|
||||||
|
decorationManager.createHighlightDecorations([
|
||||||
|
{ term: 'a', col: 0, row: 0, size: 1 },
|
||||||
|
{ term: 'f', col: 5, row: 0, size: 1 }
|
||||||
|
], options);
|
||||||
|
|
||||||
|
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
|
||||||
|
assert.strictEqual(withOverviewRuler.length, 1);
|
||||||
|
assert.strictEqual(withOverviewRuler[0].x, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
|
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
|
||||||
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
||||||
import { dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle';
|
import { dispose, Disposable, toDisposable } from 'common/Lifecycle';
|
||||||
import type { ISearchResult } from './SearchEngine';
|
import type { ISearchResult } from './SearchEngine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
|
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
|
||||||
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';
|
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';
|
||||||
import { Emitter, Event } from 'vs/base/common/event';
|
import { Emitter, type IEvent } from 'common/Event';
|
||||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||||
import { disposableTimeout } from 'vs/base/common/async';
|
import { disposableTimeout } from 'common/Async';
|
||||||
import { SearchLineCache } from './SearchLineCache';
|
import { SearchLineCache } from './SearchLineCache';
|
||||||
import { SearchState } from './SearchState';
|
import { SearchState } from './SearchState';
|
||||||
import { SearchEngine, type ISearchResult } from './SearchEngine';
|
import { SearchEngine, type ISearchResult } from './SearchEngine';
|
||||||
@@ -47,7 +47,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
private readonly _onBeforeSearch = this._register(new Emitter<void>());
|
private readonly _onBeforeSearch = this._register(new Emitter<void>());
|
||||||
public readonly onBeforeSearch = this._onBeforeSearch.event;
|
public readonly onBeforeSearch = this._onBeforeSearch.event;
|
||||||
|
|
||||||
public get onDidChangeResults(): Event<ISearchResultChangeEvent> {
|
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> {
|
||||||
return this._resultTracker.onDidChangeResults;
|
return this._resultTracker.onDidChangeResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,12 +142,14 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
|||||||
}
|
}
|
||||||
prevResult = result;
|
prevResult = result;
|
||||||
results.push(prevResult);
|
results.push(prevResult);
|
||||||
result = this._engine.find(
|
const cols = this._terminal.cols;
|
||||||
term,
|
let nextCol = prevResult.col + prevResult.size;
|
||||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
|
let nextRow = prevResult.row;
|
||||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
|
if (nextCol >= cols) {
|
||||||
searchOptions
|
nextRow += Math.floor(nextCol / cols);
|
||||||
);
|
nextCol = nextCol % cols;
|
||||||
|
}
|
||||||
|
result = this._engine.find(term, nextRow, nextCol, searchOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._resultTracker.updateResults(results, this._highlightLimit);
|
this._resultTracker.updateResults(results, this._highlightLimit);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SearchEngine } from './SearchEngine';
|
|||||||
import { SearchLineCache } from './SearchLineCache';
|
import { SearchLineCache } from './SearchLineCache';
|
||||||
import { Terminal } from 'browser/public/Terminal';
|
import { Terminal } from 'browser/public/Terminal';
|
||||||
import type { ISearchOptions } from '@xterm/addon-search';
|
import type { ISearchOptions } from '@xterm/addon-search';
|
||||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
import { DisposableStore } from 'common/Lifecycle';
|
||||||
|
|
||||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||||
return new Promise(r => terminal.write(data, r));
|
return new Promise(r => terminal.write(data, r));
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export class SearchEngine {
|
|||||||
this._terminal.clearSelection();
|
this._terminal.clearSelection();
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (startCol > this._terminal.cols) {
|
if (startCol >= this._terminal.cols) {
|
||||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ export class SearchEngine {
|
|||||||
this._terminal.clearSelection();
|
this._terminal.clearSelection();
|
||||||
|
|
||||||
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
||||||
let startCol = this._terminal.cols;
|
const startCol = this._terminal.cols;
|
||||||
const isReverseSearch = true;
|
const isReverseSearch = true;
|
||||||
|
|
||||||
this._lineCache.initLinesCache();
|
this._lineCache.initLinesCache();
|
||||||
@@ -186,14 +186,14 @@ export class SearchEngine {
|
|||||||
let result: ISearchResult | undefined;
|
let result: ISearchResult | undefined;
|
||||||
if (prevSelectedPos) {
|
if (prevSelectedPos) {
|
||||||
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
||||||
searchPosition.startCol = startCol = prevSelectedPos.start.x;
|
searchPosition.startCol = prevSelectedPos.start.x;
|
||||||
if (cachedSearchTerm !== term) {
|
if (cachedSearchTerm !== term) {
|
||||||
// Try to expand selection to right first.
|
// Try to expand selection to right first.
|
||||||
result = this._findInLine(term, searchPosition, searchOptions, false);
|
result = this._findInLine(term, searchPosition, searchOptions, false);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// If selection was not able to be expanded to the right, then try reverse search
|
// If selection was not able to be expanded to the right, then try reverse search
|
||||||
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
||||||
searchPosition.startCol = startCol = prevSelectedPos.end.x;
|
searchPosition.startCol = prevSelectedPos.end.x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
|
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
|
||||||
import { Terminal } from 'browser/public/Terminal';
|
import { Terminal } from 'browser/public/Terminal';
|
||||||
import { timeout } from 'vs/base/common/async';
|
import { timeout } from 'common/Async';
|
||||||
|
|
||||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||||
return new Promise(r => terminal.write(data, r));
|
return new Promise(r => terminal.write(data, r));
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||||
import { disposableTimeout } from 'vs/base/common/async';
|
import { disposableTimeout } from 'common/Async';
|
||||||
|
|
||||||
export type LineCacheEntry = [
|
export type LineCacheEntry = [
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
|
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
|
||||||
import type { IDisposable } from '@xterm/xterm';
|
import type { IDisposable } from '@xterm/xterm';
|
||||||
import { Emitter, Event } from 'vs/base/common/event';
|
import { Emitter, type IEvent } from 'common/Event';
|
||||||
import { Disposable } from 'vs/base/common/lifecycle';
|
import { Disposable } from 'common/Lifecycle';
|
||||||
import type { ISearchResult } from './SearchEngine';
|
import type { ISearchResult } from './SearchEngine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +25,7 @@ export class SearchResultTracker extends Disposable {
|
|||||||
private _selectedDecoration: ISelectedDecoration | undefined;
|
private _selectedDecoration: ISelectedDecoration | undefined;
|
||||||
|
|
||||||
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
||||||
public get onDidChangeResults(): Event<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current search results.
|
* Gets the current search results.
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,10 +414,24 @@ test.describe('Search Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Regression tests', () => {
|
test.describe('Regression tests', () => {
|
||||||
|
test('should advance highlight-all scan by buffer match size for wide characters', async () => {
|
||||||
|
await ctx.page.evaluate(`
|
||||||
|
window.calls = [];
|
||||||
|
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||||
|
`);
|
||||||
|
await ctx.proxy.resize(3, 5);
|
||||||
|
await ctx.proxy.write('𝄞𝄞𝄞');
|
||||||
|
strictEqual(await ctx.page.evaluate(`window.search.findNext('𝄞', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||||
|
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||||
|
{ resultCount: 3, resultIndex: 0 }
|
||||||
|
]);
|
||||||
|
await ctx.proxy.resize(80, 24);
|
||||||
|
});
|
||||||
|
|
||||||
test.describe('#2444 wrapped line content not being found', () => {
|
test.describe('#2444 wrapped line content not being found', () => {
|
||||||
let fixture: string;
|
let fixture: string;
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
fixture = (await new Promise<Buffer>(r => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => r(data)))).toString();
|
fixture = (await new Promise<Buffer>((res, rej) => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => err ? rej(err) : res(data)))).toString();
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
fixture = fixture.replace(/\n/g, '\n\r');
|
fixture = fixture.replace(/\n/g, '\n\r');
|
||||||
}
|
}
|
||||||
@@ -501,7 +515,7 @@ test.describe('Search Tests', () => {
|
|||||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||||
`);
|
`);
|
||||||
// Move cursor forward 1 time to create a null character, as opposed to regular whitespace
|
// Move cursor forward 1 time to create a null character, as opposed to regular whitespace
|
||||||
await ctx.proxy.write('\\x1b[CHi Hi');
|
await ctx.proxy.write('\x1b[CHi Hi');
|
||||||
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true);
|
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true);
|
||||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||||
{ resultCount: 2, resultIndex: 1 }
|
{ resultCount: 2, resultIndex: 1 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": "nodenext",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"target": "es2021",
|
"target": "es2021",
|
||||||
"module": "commonjs",
|
"module": "nodenext",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": ["../../../src/common/*"],
|
"common/*": ["../../../src/common/*"],
|
||||||
"browser/*": ["../../../src/browser/*"],
|
"browser/*": ["../../../src/browser/*"],
|
||||||
|
|||||||
@@ -16,13 +16,13 @@
|
|||||||
"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",
|
||||||
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json",
|
"benchmark": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json",
|
||||||
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
"benchmark-baseline": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
||||||
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
|
"benchmark-eval": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
|
|
||||||
import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
|
import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||||
import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';
|
import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';
|
||||||
import { IAttributeData, IColor } from 'common/Types';
|
import { IColor } from 'common/Types';
|
||||||
|
import { IAttributeData } from 'common/buffer/Types';
|
||||||
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
|
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
|
||||||
import { UnderlineStyle } from 'common/buffer/Constants';
|
import { UnderlineStyle } from 'common/buffer/Constants';
|
||||||
|
|
||||||
@@ -89,11 +90,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 +118,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>();
|
||||||
@@ -154,7 +173,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();
|
private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();
|
||||||
private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();
|
private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();
|
||||||
protected _rowEnd(row: number, isLastRow: boolean): void {
|
protected _rowEnd(row: number, isLastRow: boolean): void {
|
||||||
// if there is colorful empty cell at line end, whe must pad it back, or the the color block
|
// if there is colorful empty cell at line end, we must pad it back, or the color block
|
||||||
// will missing
|
// will missing
|
||||||
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
|
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
|
||||||
// use clear right to set background.
|
// use clear right to set background.
|
||||||
@@ -202,7 +221,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
// you can't use control sequence to move cursor to (x === row)
|
// you can't use control sequence to move cursor to (x === row)
|
||||||
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
|
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
|
||||||
// change background of the first wrapped cell also affects BCE
|
// change background of the first wrapped cell also affects BCE
|
||||||
// so we mark it as invalid to simply the process to determine line separator
|
// so we mark it as invalid to simplify the process to determine line separator
|
||||||
equalBg(thisRowLastChar, nextRowFirstChar)
|
equalBg(thisRowLastChar, nextRowFirstChar)
|
||||||
) {
|
) {
|
||||||
isValid = true;
|
isValid = true;
|
||||||
@@ -214,7 +233,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
isNextRowFirstCharDoubleWidth &&
|
isNextRowFirstCharDoubleWidth &&
|
||||||
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
|
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
|
||||||
// change background of the first wrapped cell also affects BCE
|
// change background of the first wrapped cell also affects BCE
|
||||||
// so we mark it as invalid to simply the process to determine line separator
|
// so we mark it as invalid to simplify the process to determine line separator
|
||||||
equalBg(thisRowLastChar, nextRowFirstChar) &&
|
equalBg(thisRowLastChar, nextRowFirstChar) &&
|
||||||
equalBg(thisRowLastSecondChar, nextRowFirstChar)
|
equalBg(thisRowLastSecondChar, nextRowFirstChar)
|
||||||
) {
|
) {
|
||||||
@@ -258,6 +277,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 +312,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);
|
||||||
@@ -675,12 +698,11 @@ 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,
|
||||||
@@ -693,8 +715,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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:
|
||||||
@@ -713,6 +734,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,
|
||||||
@@ -185,7 +192,7 @@ test.describe('SerializeAddon', () => {
|
|||||||
const cols = 10;
|
const cols = 10;
|
||||||
const line = '+'.repeat(cols);
|
const line = '+'.repeat(cols);
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags a the end, serialize will use \x1b[0m to clear instead of the sepcific disable sequence
|
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags at the end, serialize will use \x1b[0m to clear instead of the specific disable sequence
|
||||||
sgr(INVERSE) + line,
|
sgr(INVERSE) + line,
|
||||||
sgr(BOLD) + line,
|
sgr(BOLD) + line,
|
||||||
sgr(UNDERLINED) + line,
|
sgr(UNDERLINED) + line,
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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": "nodenext",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"target": "es2015",
|
"target": "es2015",
|
||||||
"module": "commonjs",
|
"module": "nodenext",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"common/*": ["../../../src/common/*"],
|
"common/*": ["../../../src/common/*"],
|
||||||
"browser/*": ["../../../src/browser/*"],
|
"browser/*": ["../../../src/browser/*"],
|
||||||
|
|||||||
@@ -16,13 +16,13 @@
|
|||||||
"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",
|
||||||
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
|
"benchmark": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
|
||||||
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
"benchmark-baseline": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
|
||||||
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
|
"benchmark-eval": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||||
* @license MIT
|
* @license MIT
|
||||||
*
|
*
|
||||||
* UnicodeVersionProvider for V15 with grapeme cluster handleing.
|
* UnicodeVersionProvider for V15 with grapheme cluster handling.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm';
|
import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm';
|
||||||
|
|||||||
@@ -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/*"
|
||||||
|
|||||||