Compare commits
1660
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c6f793aa2 | ||
|
|
f22ef02831 | ||
|
|
c569e5ab19 | ||
|
|
9a65429a30 | ||
|
|
43e8365f0e | ||
|
|
c7a94fc0bc | ||
|
|
4bb9c06b5f | ||
|
|
00e54532b3 | ||
|
|
e96133603b | ||
|
|
b2003861ec | ||
|
|
38f77fd4ca | ||
|
|
d3352cf1d5 | ||
|
|
0c4ec34df8 | ||
|
|
743852b60c | ||
|
|
2069176528 | ||
|
|
acd2472961 | ||
|
|
c2ecfe7446 | ||
|
|
5656443163 | ||
|
|
38174907f3 | ||
|
|
1b95efa8c8 | ||
|
|
91fcb46a1a | ||
|
|
ce1f5f9608 | ||
|
|
1d569f873d | ||
|
|
bf8af4a871 | ||
|
|
f8e9a81d0e | ||
|
|
7711d780c2 | ||
|
|
e89fbf2979 | ||
|
|
db244ea5a8 | ||
|
|
fdcd1c2720 | ||
|
|
f8cef535c7 | ||
|
|
57b48f6048 | ||
|
|
5ad56446b8 | ||
|
|
ec0c93fd5a | ||
|
|
be339bf796 | ||
|
|
fbb2a9710f | ||
|
|
7294124736 | ||
|
|
2c3fad39af | ||
|
|
edb4f89dd8 | ||
|
|
224c113b64 | ||
|
|
3e0c7d18e5 | ||
|
|
95d9224b8c | ||
|
|
7499a2181d | ||
|
|
7dc820f5fa | ||
|
|
e59a6c1f7b | ||
|
|
f3e2c08a5d | ||
|
|
b138bf38d0 | ||
|
|
c6e4627b45 | ||
|
|
d91045dc5f | ||
|
|
41dfdba2cf | ||
|
|
ba33decb68 | ||
|
|
17e3b84bcc | ||
|
|
182ff2f635 | ||
|
|
bcb4e5298b | ||
|
|
42facac2d9 | ||
|
|
2ab0f10a26 | ||
|
|
280dc0a36a | ||
|
|
ebe8cb749a | ||
|
|
c28f3c8d73 | ||
|
|
3bd7ac083a | ||
|
|
30b42838fd | ||
|
|
80e832e417 | ||
|
|
470947abd8 | ||
|
|
bb49a80ace | ||
|
|
65b87e0f40 | ||
|
|
39fc0f259e | ||
|
|
b787ac8b1b | ||
|
|
457efbf55d | ||
|
|
42270a0b68 | ||
|
|
090b77f354 | ||
|
|
534ce1365b | ||
|
|
f757c965ee | ||
|
|
4aa02f3fd9 | ||
|
|
f43273b3f0 | ||
|
|
c06c3025f2 | ||
|
|
1064e4f783 | ||
|
|
fad4821fd9 | ||
|
|
306c3d73ec | ||
|
|
8f2bb32c9c | ||
|
|
7b6c814566 | ||
|
|
adc9dea083 | ||
|
|
d565a2750e | ||
|
|
c5ab83d697 | ||
|
|
c62e6dd765 | ||
|
|
29a07f1d8f | ||
|
|
88cef97049 | ||
|
|
0e823004e6 | ||
|
|
df3422fb0b | ||
|
|
4ec85e67ba | ||
|
|
1a67b6f4b3 | ||
|
|
afaed108e4 | ||
|
|
c27d6bb462 | ||
|
|
9c4f0f96ca | ||
|
|
18c370a154 | ||
|
|
850f4ad738 | ||
|
|
b665f6259a | ||
|
|
d195dc9a3b | ||
|
|
4e5994d8a2 | ||
|
|
4a0cb5ae6b | ||
|
|
9e72a4b6d3 | ||
|
|
f484584b70 | ||
|
|
5a6427e867 | ||
|
|
8519ac6d99 | ||
|
|
ae611694de | ||
|
|
e749cb6125 | ||
|
|
8df996f9e7 | ||
|
|
51f6186352 | ||
|
|
ae5c41c518 | ||
|
|
17e7dafa17 | ||
|
|
df9fb7e473 | ||
|
|
41d9c4080f | ||
|
|
f100e93ba5 | ||
|
|
1d3825d341 | ||
|
|
57315e6cc9 | ||
|
|
2be4fc68e5 | ||
|
|
66fabfc232 | ||
|
|
3e2a42eb29 | ||
|
|
6a47d09b4c | ||
|
|
b36001ee7b | ||
|
|
e64f25a0dd | ||
|
|
3bcb57545e | ||
|
|
dc726a2ae6 | ||
|
|
06e5ac7ab4 | ||
|
|
c6e3492a4a | ||
|
|
86291b6e96 | ||
|
|
c8c8b3a7fe | ||
|
|
50e90ddf8a | ||
|
|
772df0cb98 | ||
|
|
aa93189cc5 | ||
|
|
7059a07c09 | ||
|
|
c5c6fc2a64 | ||
|
|
e0add7fe4b | ||
|
|
b0f5bc2d28 | ||
|
|
f82a8029d2 | ||
|
|
a2f321c887 | ||
|
|
eb58cefa8e | ||
|
|
d92ca87cbe | ||
|
|
c5a11220c1 | ||
|
|
6f55150485 | ||
|
|
c8dddddbbb | ||
|
|
224167bbbe | ||
|
|
740fa44ae4 | ||
|
|
636baa8d79 | ||
|
|
44a27dc958 | ||
|
|
dc1ae4514b | ||
|
|
6ba731d73b | ||
|
|
2d3909be2c | ||
|
|
1e189625fa | ||
|
|
80debafa11 | ||
|
|
1023e40428 | ||
|
|
5c47e6dd9a | ||
|
|
4ebb3fffe9 | ||
|
|
093e81b9a7 | ||
|
|
e04dd2501f | ||
|
|
d911b40a47 | ||
|
|
3dbfa7ce3e | ||
|
|
6287c6ddfe | ||
|
|
52d4b2719c | ||
|
|
7615cd210a | ||
|
|
4d4b994c35 | ||
|
|
7fa8b7a069 | ||
|
|
a2e51e8d36 | ||
|
|
f89253d24b | ||
|
|
7aeee95346 | ||
|
|
66fcdb2583 | ||
|
|
2a18600d18 | ||
|
|
5ef4f86543 | ||
|
|
874e767b40 | ||
|
|
605a7c6e30 | ||
|
|
802decb046 | ||
|
|
79a95dbfd8 | ||
|
|
75ba88c51b | ||
|
|
f9cea732f2 | ||
|
|
494cf1f2e1 | ||
|
|
645865efec | ||
|
|
83e3657a82 | ||
|
|
53554a05f7 | ||
|
|
51c8d8abf8 | ||
|
|
05ea5ac324 | ||
|
|
9e27c7debb | ||
|
|
4dc934ce7c | ||
|
|
b2eff5ac67 | ||
|
|
31c53bc9c2 | ||
|
|
0d2975884f | ||
|
|
e8b5fd3c23 | ||
|
|
7abcca7e73 | ||
|
|
92339ac9dd | ||
|
|
ad4a6e8cb9 | ||
|
|
a094340afe | ||
|
|
cc7e3bbbf7 | ||
|
|
73d87b2c20 | ||
|
|
c5bf395b10 | ||
|
|
9d24099aa1 | ||
|
|
066c227436 | ||
|
|
32ad3a18bf | ||
|
|
d43b19c260 | ||
|
|
7dd0dac859 | ||
|
|
7e70d6ad78 | ||
|
|
893cdd494a | ||
|
|
2794e32b3f | ||
|
|
7d69e730ca | ||
|
|
da51d9608c | ||
|
|
3a84bcb7ae | ||
|
|
d9082215e7 | ||
|
|
41938b307e | ||
|
|
785c3cb336 | ||
|
|
ea7e958959 | ||
|
|
915b0b81a1 | ||
|
|
4e9abe7d96 | ||
|
|
84aca0b912 | ||
|
|
d82d42ef9a | ||
|
|
4d2f2e0d8e | ||
|
|
56986cb7eb | ||
|
|
4946436997 | ||
|
|
bf83ea06e4 | ||
|
|
b7e535e2ec | ||
|
|
8e12e69e4e | ||
|
|
849a9a53ca | ||
|
|
ee7d84bdd3 | ||
|
|
9c8de633e9 | ||
|
|
9c5420371b | ||
|
|
22778b5b49 | ||
|
|
cbe59a17ae | ||
|
|
76671cecb6 | ||
|
|
a11fcd2e0b | ||
|
|
b28b5d0c2b | ||
|
|
685434ca84 | ||
|
|
7fe89a1c1a | ||
|
|
eaebe2ab15 | ||
|
|
32553b4135 | ||
|
|
6c2465d745 | ||
|
|
5922123b9e | ||
|
|
eb65910c1b | ||
|
|
f0aeb70c92 | ||
|
|
9deec7aabf | ||
|
|
18cd9858ed | ||
|
|
6fd7ecc812 | ||
|
|
ff72c00219 | ||
|
|
0911688295 | ||
|
|
62ce318a07 | ||
|
|
08ae141ef7 | ||
|
|
ba7e775a17 | ||
|
|
487b27f512 | ||
|
|
5d120de3d5 | ||
|
|
3ab299af7f | ||
|
|
6e375cb588 | ||
|
|
7a6e724bfe | ||
|
|
69ee4ad23b | ||
|
|
253d1e9beb | ||
|
|
4b77e601cd | ||
|
|
34e0179355 | ||
|
|
bd722e7b07 | ||
|
|
cccdd447a0 | ||
|
|
446bcb1f75 | ||
|
|
4316263457 | ||
|
|
4a35a4880d | ||
|
|
ad730f2d54 | ||
|
|
9c7c163dd4 | ||
|
|
8674bc5e82 | ||
|
|
d552be8c45 | ||
|
|
082a51127f | ||
|
|
89100f6a3d | ||
|
|
8149d4481f | ||
|
|
2d9b21f5c7 | ||
|
|
f43985c422 | ||
|
|
9f6c7d7ced | ||
|
|
aa7716a333 | ||
|
|
1432e20082 | ||
|
|
f1b0a96094 | ||
|
|
49096a21fd | ||
|
|
63035678ec | ||
|
|
503abee8ae | ||
|
|
4350536685 | ||
|
|
9feb963cc9 | ||
|
|
f4042876c6 | ||
|
|
c294695444 | ||
|
|
fe0952bb83 | ||
|
|
5c2b19f2c8 | ||
|
|
5e9b2cbc14 | ||
|
|
3a59c8ec2b | ||
|
|
fb25eb8f79 | ||
|
|
e7a4578933 | ||
|
|
a05a3321d5 | ||
|
|
26ce9bd4d3 | ||
|
|
1c1aac0da8 | ||
|
|
beeebca1a4 | ||
|
|
fd1e53c63f | ||
|
|
18262c87d9 | ||
|
|
2285ac6ab8 | ||
|
|
5302e62c90 | ||
|
|
af6731b533 | ||
|
|
5b9b531012 | ||
|
|
b0118dfd3a | ||
|
|
6813fc175e | ||
|
|
409f049f39 | ||
|
|
9e1b89651a | ||
|
|
89980fc565 | ||
|
|
16b5da5c4c | ||
|
|
4812abb24f | ||
|
|
5f3a4646be | ||
|
|
d63445be94 | ||
|
|
1400398abb | ||
|
|
2331abe6bd | ||
|
|
b66128b239 | ||
|
|
c727240f16 | ||
|
|
649ce22081 | ||
|
|
73adfdfd56 | ||
|
|
1fc8a4687d | ||
|
|
c29e48c917 | ||
|
|
70fe22993b | ||
|
|
4f3a7331bd | ||
|
|
6f3ce7b0fe | ||
|
|
fdb7346f3a | ||
|
|
783c4a34d2 | ||
|
|
8a1760b924 | ||
|
|
211db9d067 | ||
|
|
ec6ad20e33 | ||
|
|
47235f47fe | ||
|
|
8d6ff3a27b | ||
|
|
55281022a0 | ||
|
|
ea50fbdd1e | ||
|
|
87bf81cf96 | ||
|
|
f155c65e5a | ||
|
|
413c87cbf9 | ||
|
|
1620f8f5a8 | ||
|
|
e769f80a14 | ||
|
|
6651c4ca23 | ||
|
|
cb78a53f57 | ||
|
|
1c7b9f6d79 | ||
|
|
0d535100e4 | ||
|
|
5e19624e71 | ||
|
|
671425bf5a | ||
|
|
df5393ec45 | ||
|
|
b2553c17b4 | ||
|
|
0cea95d3b3 | ||
|
|
163868ff60 | ||
|
|
f4166a87ba | ||
|
|
f7a353cd34 | ||
|
|
7ec41471da | ||
|
|
f5e81afba7 | ||
|
|
a12061c899 | ||
|
|
d6c872bb2c | ||
|
|
a2cb36e6dd | ||
|
|
114c66c86c | ||
|
|
fe9650ce16 | ||
|
|
dedf3b1595 | ||
|
|
2158827b25 | ||
|
|
b406797dc7 | ||
|
|
5c28e27062 | ||
|
|
6e35b44f02 | ||
|
|
30c97a225c | ||
|
|
1186d09e05 | ||
|
|
d99e6bb2bc | ||
|
|
579a0a530f | ||
|
|
0b6220e5ea | ||
|
|
0d119b30d6 | ||
|
|
79c94de968 | ||
|
|
e3980a27db | ||
|
|
898c0d91c3 | ||
|
|
83401e3769 | ||
|
|
6b756318f0 | ||
|
|
3a9bfa94bc | ||
|
|
209e5aa6b9 | ||
|
|
24e2eb9d7b | ||
|
|
d5ae437dda | ||
|
|
5efff43e93 | ||
|
|
ee85a1da9e | ||
|
|
c30fedff6b | ||
|
|
21f51564d1 | ||
|
|
27b01b22ef | ||
|
|
387ec93a90 | ||
|
|
4388a4ac13 | ||
|
|
08c97d12b0 | ||
|
|
fbf1f7bc5d | ||
|
|
c8704930e3 | ||
|
|
e60c96823d | ||
|
|
375be97615 | ||
|
|
74642ce5d5 | ||
|
|
eccbc60aa0 | ||
|
|
6b64660934 | ||
|
|
10182cc656 | ||
|
|
3c7f313c34 | ||
|
|
457542b7b3 | ||
|
|
36118d9d4c | ||
|
|
cf8a4e466d | ||
|
|
4de03aaa38 | ||
|
|
677e5275f9 | ||
|
|
c66b0ed168 | ||
|
|
d4c3b693bb | ||
|
|
2f66b5fdb6 | ||
|
|
008aa1bfb5 | ||
|
|
1d0cf32b28 | ||
|
|
c01e4b7be7 | ||
|
|
fafb195f0f | ||
|
|
2c4997f529 | ||
|
|
f0d291f645 | ||
|
|
f6b98291cf | ||
|
|
16ea2ec0ef | ||
|
|
f7118b19c6 | ||
|
|
a0574f4e9c | ||
|
|
d9b5d150ae | ||
|
|
a25db1c76d | ||
|
|
c6a572fe21 | ||
|
|
b9fa53169f | ||
|
|
eb0f5383c3 | ||
|
|
a4502a6d00 | ||
|
|
c11a8f43f4 | ||
|
|
d7ba56cd28 | ||
|
|
be44833f12 | ||
|
|
fcb3cc1a21 | ||
|
|
376268157c | ||
|
|
5ceb02b9c6 | ||
|
|
d320898231 | ||
|
|
cf3939af4a | ||
|
|
38913e4ae1 | ||
|
|
e14746e6e9 | ||
|
|
82333b9414 | ||
|
|
9eeb2799c1 | ||
|
|
5630471480 | ||
|
|
e0c59e68b2 | ||
|
|
cc8e680790 | ||
|
|
452ee50c81 | ||
|
|
f41e53a0f0 | ||
|
|
596ab845d0 | ||
|
|
a59d527d1a | ||
|
|
b287397c4f | ||
|
|
aebbe81252 | ||
|
|
d155bb6767 | ||
|
|
3a147e8e10 | ||
|
|
c2db7d4c8a | ||
|
|
456bc73bae | ||
|
|
2e776c0820 | ||
|
|
08f85cf826 | ||
|
|
6f163beb97 | ||
|
|
b0d0bf8187 | ||
|
|
32e77c3b81 | ||
|
|
e95ec8d4bc | ||
|
|
cba6e3d191 | ||
|
|
d15b426bd4 | ||
|
|
c907edd75f | ||
|
|
5741e450e6 | ||
|
|
6ef653e589 | ||
|
|
a4c0e4690e | ||
|
|
a31f78bc8b | ||
|
|
ca8e8546b5 | ||
|
|
3d473b52b6 | ||
|
|
a2bec2aeab | ||
|
|
205cf6089d | ||
|
|
8b0a6f9785 | ||
|
|
db0226be5c | ||
|
|
ab5c06cf17 | ||
|
|
9a778f0c98 | ||
|
|
c102b4af50 | ||
|
|
f3a7cac49f | ||
|
|
0d2a58c9d0 | ||
|
|
8a00898533 | ||
|
|
edb0e1ceb3 | ||
|
|
93c1650446 | ||
|
|
1029e19b57 | ||
|
|
d44900faa2 | ||
|
|
5f724c5924 | ||
|
|
77695cb405 | ||
|
|
dc4ebfd6e3 | ||
|
|
fae6f03f90 | ||
|
|
d36af624cf | ||
|
|
a105d3e457 | ||
|
|
d5900c7eae | ||
|
|
beebe8b5dc | ||
|
|
ae58176bcb | ||
|
|
429b2882f1 | ||
|
|
ffac8c03ab | ||
|
|
a7c4a160a8 | ||
|
|
c9dc994090 | ||
|
|
e4710d3286 | ||
|
|
8f9045ed88 | ||
|
|
b8e5ec7304 | ||
|
|
6a3d2480e0 | ||
|
|
735afde77f | ||
|
|
53efc95170 | ||
|
|
3512f140de | ||
|
|
dbe35428cb | ||
|
|
297ac838a3 | ||
|
|
5316cb0812 | ||
|
|
c124550971 | ||
|
|
6525a7e68e | ||
|
|
f666135881 | ||
|
|
b2133d93f0 | ||
|
|
a5e4f90139 | ||
|
|
a27b8feb8f | ||
|
|
eea03cf0a9 | ||
|
|
e96748cc79 | ||
|
|
334c8170fe | ||
|
|
2bb9662bea | ||
|
|
7cba3c9fa1 | ||
|
|
bab7db13db | ||
|
|
1c79c5511c | ||
|
|
7b417f4585 | ||
|
|
dc487dfd6f | ||
|
|
36baf70bea | ||
|
|
87e0b9ab10 | ||
|
|
5e532c13c6 | ||
|
|
db131e0c22 | ||
|
|
51b9a713da | ||
|
|
972c069fa3 | ||
|
|
2c0ba3c058 | ||
|
|
e515647f99 | ||
|
|
7a2794a552 | ||
|
|
da2267a10f | ||
|
|
34e68b1ad4 | ||
|
|
1c42957461 | ||
|
|
77036d3f12 | ||
|
|
ccbf612ddb | ||
|
|
eed8423030 | ||
|
|
431d511c95 | ||
|
|
0ca1bd48be | ||
|
|
7ceb15bb83 | ||
|
|
5ecdc7da44 | ||
|
|
2e2270d01f | ||
|
|
042bb4bed9 | ||
|
|
7e3c41ba49 | ||
|
|
21e4301e38 | ||
|
|
3ef43aedb1 | ||
|
|
9eaf5b45dc | ||
|
|
39037ee806 | ||
|
|
e542affaca | ||
|
|
038883c41b | ||
|
|
6c3d4e4ea4 | ||
|
|
659cfddd8c | ||
|
|
c375696ee6 | ||
|
|
bc3c376e83 | ||
|
|
9fca3d8ecf | ||
|
|
8eed7b4163 | ||
|
|
c29938ee83 | ||
|
|
0e679ea6ae | ||
|
|
d0e8141f7c | ||
|
|
1d322c9e45 | ||
|
|
bad7e329df | ||
|
|
ae5e7e55c8 | ||
|
|
5bb2e46508 | ||
|
|
5938b8823d | ||
|
|
69d87c8a30 | ||
|
|
a247154650 | ||
|
|
b30d9c3533 | ||
|
|
60190c1c48 | ||
|
|
99d69d2236 | ||
|
|
de01d4608c | ||
|
|
d35481c3f9 | ||
|
|
e2d84928b7 | ||
|
|
9ffd16aa9b | ||
|
|
77fa03d11c | ||
|
|
01450f044a | ||
|
|
b04d816d8a | ||
|
|
f80dbf3d03 | ||
|
|
b58bd452af | ||
|
|
e8d99d50a6 | ||
|
|
bae314f67c | ||
|
|
cb8159c4f2 | ||
|
|
b87cba4c2a | ||
|
|
492d950b64 | ||
|
|
a1b69e5ac8 | ||
|
|
d77711936f | ||
|
|
5a79b08661 | ||
|
|
3e8fc48b33 | ||
|
|
8c9afdb364 | ||
|
|
f89524e577 | ||
|
|
a1c1702508 | ||
|
|
15c71e28c8 | ||
|
|
fd641370ec | ||
|
|
259cc67f12 | ||
|
|
b0b0868e90 | ||
|
|
d58e8597cd | ||
|
|
17d439515f | ||
|
|
ae171acb79 | ||
|
|
e2dabc77fd | ||
|
|
40342ecd1e | ||
|
|
1442f84e4e | ||
|
|
6f87ae77a7 | ||
|
|
5f25e05cd2 | ||
|
|
2af9d10171 | ||
|
|
f471c0add8 | ||
|
|
62dc0d9c51 | ||
|
|
5e95ef652d | ||
|
|
14525b334f | ||
|
|
bc1d87dade | ||
|
|
849110d300 | ||
|
|
8b6f8f3172 | ||
|
|
27825f9ed8 | ||
|
|
a507afb388 | ||
|
|
ec5b421dfb | ||
|
|
8f0e711a79 | ||
|
|
fc0dc3fb07 | ||
|
|
bea1628015 | ||
|
|
447746314e | ||
|
|
6a02d7c9f5 | ||
|
|
1ace108422 | ||
|
|
2c1441a52d | ||
|
|
304943658d | ||
|
|
8e67c0093c | ||
|
|
ee3b3626e8 | ||
|
|
b286f21f64 | ||
|
|
3d92a7e2ed | ||
|
|
b01ba7db28 | ||
|
|
1c73006e39 | ||
|
|
03b015d5d3 | ||
|
|
42d408a9a7 | ||
|
|
5ecd980a83 | ||
|
|
63ebe2df5b | ||
|
|
1b2d361b59 | ||
|
|
8e29591b9f | ||
|
|
48df6117ed | ||
|
|
14b6a71391 | ||
|
|
c24eb60bd3 | ||
|
|
2521bab72f | ||
|
|
0f1c9a47db | ||
|
|
777a46445a | ||
|
|
a20ff565b0 | ||
|
|
d28046ebed | ||
|
|
4ddd982e42 | ||
|
|
a3228bd1b8 | ||
|
|
a1fca4feb8 | ||
|
|
afc6f0b7eb | ||
|
|
f4c77d37b4 | ||
|
|
9d0beb7c38 | ||
|
|
f88650ec1c | ||
|
|
8b35c67dd3 | ||
|
|
53dc85eca2 | ||
|
|
0946386cc4 | ||
|
|
ebe18b3da5 | ||
|
|
6c2792a1d5 | ||
|
|
ee47054678 | ||
|
|
91c4761b82 | ||
|
|
c9fc6fca8e | ||
|
|
73cba6dda3 | ||
|
|
50b0f3d7ee | ||
|
|
686c67a14b | ||
|
|
ec78500f86 | ||
|
|
98ea14824a | ||
|
|
4c905dad33 | ||
|
|
e3fdcc7869 | ||
|
|
0674663a5a | ||
|
|
e07832a420 | ||
|
|
934b5dc8fa | ||
|
|
7a544d8346 | ||
|
|
c624a47780 | ||
|
|
5a29d489c3 | ||
|
|
4a67836a5b | ||
|
|
3adf6be988 | ||
|
|
566589f7c1 | ||
|
|
8dbc91dc24 | ||
|
|
992da3cc4d | ||
|
|
3fbaef06ef | ||
|
|
2265fa2ade | ||
|
|
756c353237 | ||
|
|
49a8ac8fc0 | ||
|
|
bf7c95b6cc | ||
|
|
8112fbf210 | ||
|
|
16a13f8504 | ||
|
|
9bd128b315 | ||
|
|
729a670043 | ||
|
|
bd8454db55 | ||
|
|
d6f6a6af9a | ||
|
|
d65f9312ea | ||
|
|
a437d12004 | ||
|
|
437490308c | ||
|
|
3250c31202 | ||
|
|
55d12e03d1 | ||
|
|
6efb319ece | ||
|
|
b69afe5765 | ||
|
|
613aa6ba23 | ||
|
|
a8c9128841 | ||
|
|
94a264679e | ||
|
|
cb35d1b487 | ||
|
|
c97abced76 | ||
|
|
9db31db886 | ||
|
|
ccf9727b85 | ||
|
|
1dfdde9e27 | ||
|
|
2712e88d0a | ||
|
|
b5da8a28f6 | ||
|
|
66c4f8b116 | ||
|
|
9148c80d1a | ||
|
|
e1bfc7dd1f | ||
|
|
c6c4a2ffbc | ||
|
|
2cf2929869 | ||
|
|
b0145998c5 | ||
|
|
0e57643f16 | ||
|
|
a35223a819 | ||
|
|
5e95dc1f10 | ||
|
|
b4e9a7f9ea | ||
|
|
73e3357526 | ||
|
|
483c7b4a81 | ||
|
|
f4453b3e18 | ||
|
|
7389a1097d | ||
|
|
0c7b1cb448 | ||
|
|
7a3b5339e7 | ||
|
|
8193ceae12 | ||
|
|
064e299bb0 | ||
|
|
6211bf078c | ||
|
|
a639808196 | ||
|
|
b522e255f0 | ||
|
|
d7850af9a2 | ||
|
|
4182f2226f | ||
|
|
e2f45b5f4e | ||
|
|
8712f8067d | ||
|
|
08c49301b7 | ||
|
|
917a8ded92 | ||
|
|
d06d278f0c | ||
|
|
f84384c83a | ||
|
|
ec03109c39 | ||
|
|
21b8f77afb | ||
|
|
363fc84fd2 | ||
|
|
8e4de23c11 | ||
|
|
1caf6dbe50 | ||
|
|
1c97e40a6a | ||
|
|
cdbafb231d | ||
|
|
44a8c7efbc | ||
|
|
6277bb363c | ||
|
|
40f2eefec7 | ||
|
|
519b1c5ef7 | ||
|
|
97e356fb83 | ||
|
|
42872a84dd | ||
|
|
4700e18833 | ||
|
|
d143340b60 | ||
|
|
a8f26bb758 | ||
|
|
da4243cf02 | ||
|
|
54e66f3ac4 | ||
|
|
7d8ef60860 | ||
|
|
595c119404 | ||
|
|
481431fbc1 | ||
|
|
5c2cd474ce | ||
|
|
0b9a7cb856 | ||
|
|
a8a4455189 | ||
|
|
11fbdb3db7 | ||
|
|
88ce0d40e7 | ||
|
|
2785f11860 | ||
|
|
857c4529cd | ||
|
|
d30c3e7204 | ||
|
|
982f5e15ff | ||
|
|
ec3e4f2af1 | ||
|
|
13914ee01e | ||
|
|
e27cc2c089 | ||
|
|
214289aaab | ||
|
|
a5af434e3e | ||
|
|
a513120348 | ||
|
|
ef978b370b | ||
|
|
00ad3eac40 | ||
|
|
29d3eb2e52 | ||
|
|
8b594140e9 | ||
|
|
7cf48c1eb5 | ||
|
|
8e6b6e7464 | ||
|
|
1b0554714b | ||
|
|
abdfa2628c | ||
|
|
5b4d794f7a | ||
|
|
9eb1da79e3 | ||
|
|
d4ba4c109b | ||
|
|
1ad23d6081 | ||
|
|
73c01cae89 | ||
|
|
5174a7310f | ||
|
|
1d793c9054 | ||
|
|
1b7c61a6fc | ||
|
|
9780b44115 | ||
|
|
8d532a35c4 | ||
|
|
d6df8d7902 | ||
|
|
4b9566cef0 | ||
|
|
b30bb1e3b0 | ||
|
|
d67e73a4da | ||
|
|
e7434a932d | ||
|
|
cdb0497d0d | ||
|
|
2fb42c554e | ||
|
|
3632496c94 | ||
|
|
a6407fb3c6 | ||
|
|
65aa28464c | ||
|
|
2baad39ccf | ||
|
|
c975956f2a | ||
|
|
4619a755b1 | ||
|
|
bc795d36c0 | ||
|
|
b989c5a568 | ||
|
|
d66214b25e | ||
|
|
6157f523a8 | ||
|
|
3ab93ea083 | ||
|
|
7ef4c55ca9 | ||
|
|
d393154271 | ||
|
|
2c32ef5963 | ||
|
|
a730533790 | ||
|
|
8a5ffc1bb1 | ||
|
|
a966f7d591 | ||
|
|
4985c9f3f9 | ||
|
|
2e1651badd | ||
|
|
7d1f74fb8b | ||
|
|
f8c1f5a32f | ||
|
|
79944f7524 | ||
|
|
52b6710b19 | ||
|
|
2a283a307c | ||
|
|
ab4524873b | ||
|
|
eb5f0d709a | ||
|
|
8a8f262616 | ||
|
|
ca5a681363 | ||
|
|
fb10cd62c9 | ||
|
|
28b62a5e0b | ||
|
|
bedf7e8326 | ||
|
|
f50028abce | ||
|
|
7a4c9cadff | ||
|
|
01c1c477c3 | ||
|
|
33daadb4ea | ||
|
|
ef6013aac9 | ||
|
|
82f24ad5a5 | ||
|
|
97c435862b | ||
|
|
6488e839a1 | ||
|
|
5d3564bb38 | ||
|
|
50985d30f9 | ||
|
|
733e48ec73 | ||
|
|
057fefd39e | ||
|
|
4d48f8624b | ||
|
|
995b491a7a | ||
|
|
4fe118a8ff | ||
|
|
45375c94e2 | ||
|
|
f83760d027 | ||
|
|
1a1bdefc83 | ||
|
|
e28db9e8a9 | ||
|
|
be5f510bfb | ||
|
|
ac6b8ebb10 | ||
|
|
1ada355048 | ||
|
|
4ce3e3106b | ||
|
|
942aff6af7 | ||
|
|
635b898dc0 | ||
|
|
e01d1f2f17 | ||
|
|
bd16eeed90 | ||
|
|
df8f9fc519 | ||
|
|
373bf513f1 | ||
|
|
25a5497329 | ||
|
|
4406043e6b | ||
|
|
82f961f76f | ||
|
|
a26471b9f3 | ||
|
|
ba989a6e6c | ||
|
|
d4f725c649 | ||
|
|
79f5198b2d | ||
|
|
d8a93b757a | ||
|
|
9453634f56 | ||
|
|
10e12cf743 | ||
|
|
79621a4e3f | ||
|
|
546c5e081d | ||
|
|
e6ad59b1b1 | ||
|
|
8eab98b78d | ||
|
|
0ed6fd0db6 | ||
|
|
e3f87ab8a1 | ||
|
|
8119bb0a4a | ||
|
|
dc0d7c81d9 | ||
|
|
fa87ecdc4e | ||
|
|
b82e8f345f | ||
|
|
02e9e56eef | ||
|
|
4fffb96f25 | ||
|
|
6fe5a124ca | ||
|
|
f62043de91 | ||
|
|
55e5ec6037 | ||
|
|
7b2a5cb50f | ||
|
|
8740dcda4d | ||
|
|
c046f5b4b1 | ||
|
|
6bde923f07 | ||
|
|
40b2bbef34 | ||
|
|
c04806fc92 | ||
|
|
8b1306f46d | ||
|
|
6612153c44 | ||
|
|
2be529ce85 | ||
|
|
96aa97ce11 | ||
|
|
98f4c3128c | ||
|
|
ce1ab6fdc4 | ||
|
|
e87e0f5443 | ||
|
|
4a8d8bd247 | ||
|
|
edf100a24b | ||
|
|
ed2d8b8fdc | ||
|
|
faeefb53bf | ||
|
|
32ff1f2115 | ||
|
|
acb59daf33 | ||
|
|
a3d6bf6710 | ||
|
|
5e5c623d8b | ||
|
|
168520f727 | ||
|
|
23f61911e2 | ||
|
|
491fa5586a | ||
|
|
2f549d74ae | ||
|
|
9df2430433 | ||
|
|
ce9f6275ed | ||
|
|
9ca04799fe | ||
|
|
83757d1bf0 | ||
|
|
60fa9122a2 | ||
|
|
a8d40a1edf | ||
|
|
78cc07f88a | ||
|
|
72d7c69a29 | ||
|
|
0e0b445784 | ||
|
|
5d24866042 | ||
|
|
cbea462737 | ||
|
|
a3ae94af1d | ||
|
|
0e35521810 | ||
|
|
40590fedfa | ||
|
|
1551d94de4 | ||
|
|
d605803866 | ||
|
|
a797852dfd | ||
|
|
d42f2b54ff | ||
|
|
79084c3066 | ||
|
|
b4f4946cca | ||
|
|
ed779b9062 | ||
|
|
20fdfba16f | ||
|
|
11dae84ff7 | ||
|
|
579573e77d | ||
|
|
2114abe88c | ||
|
|
d4fd96639f | ||
|
|
269d8c20ae | ||
|
|
49b49627f1 | ||
|
|
663b94011a | ||
|
|
54fcb1ea2f | ||
|
|
a9da3fbf05 | ||
|
|
091c3967f7 | ||
|
|
10d6ce3805 | ||
|
|
eb285af11d | ||
|
|
0976935f87 | ||
|
|
884b667f4e | ||
|
|
b8843732bf | ||
|
|
44d8d2c873 | ||
|
|
3d3abf9cf2 | ||
|
|
268f7af5db | ||
|
|
16b72dfd63 | ||
|
|
a0e658a18c | ||
|
|
ea6385f310 | ||
|
|
02a9d2b047 | ||
|
|
5c0c553936 | ||
|
|
eb743e0e19 | ||
|
|
29d91fe264 | ||
|
|
e8dcf46f39 | ||
|
|
66c1316f87 | ||
|
|
e1803ed5a5 | ||
|
|
1c3813f3d4 | ||
|
|
e12aef9287 | ||
|
|
f5edba0b64 | ||
|
|
5a995e5595 | ||
|
|
2bae212f83 | ||
|
|
bde3680cd1 | ||
|
|
717399ceb6 | ||
|
|
51121f1913 | ||
|
|
483acd3335 | ||
|
|
9dc8cecea3 | ||
|
|
89c2649504 | ||
|
|
390076fc3f | ||
|
|
a4edeed85d | ||
|
|
e61697fc86 | ||
|
|
9c019b44c3 | ||
|
|
5579d83f7d | ||
|
|
429065487a | ||
|
|
219036cb0f | ||
|
|
3e80637145 | ||
|
|
0af884d379 | ||
|
|
5ae9ddf4b8 | ||
|
|
7ac068844c | ||
|
|
5bf3d57d16 | ||
|
|
6461829411 | ||
|
|
efd6a52033 | ||
|
|
3d9e976cb3 | ||
|
|
415de67106 | ||
|
|
c17c01086f | ||
|
|
ce41b175ee | ||
|
|
32b4547c5a | ||
|
|
b64543aa59 | ||
|
|
f049c42acc | ||
|
|
28760cf482 | ||
|
|
4982305b51 | ||
|
|
7fee40ddcd | ||
|
|
180183e876 | ||
|
|
8308ede3f5 | ||
|
|
a6db7b41f6 | ||
|
|
f11241d9de | ||
|
|
937deb9f4b | ||
|
|
6acc861b06 | ||
|
|
8f05b1854b | ||
|
|
0f461e7a3e | ||
|
|
4237d82923 | ||
|
|
187156764a | ||
|
|
f447274f43 | ||
|
|
f68d1e5d58 | ||
|
|
30691e8b46 | ||
|
|
5fa632518e | ||
|
|
3571d1f51a | ||
|
|
ebc0d83d8e | ||
|
|
d1c50c14e3 | ||
|
|
92b43bdf9b | ||
|
|
a9d3ca19f4 | ||
|
|
cb4d1ef2c0 | ||
|
|
34fd0dcf53 | ||
|
|
7335f15943 | ||
|
|
5e9cd6208f | ||
|
|
6733e13a44 | ||
|
|
ad07f2cf76 | ||
|
|
5396243bce | ||
|
|
12c4f9536e | ||
|
|
e9ba31f1b6 | ||
|
|
ed0ab5f9e4 | ||
|
|
0741f8be5c | ||
|
|
2edd121f1b | ||
|
|
1eee5cdf18 | ||
|
|
20c1674d00 | ||
|
|
32553fff06 | ||
|
|
832aa54478 | ||
|
|
7f50b56ae4 | ||
|
|
bd361af105 | ||
|
|
9ea059de6f | ||
|
|
7cdf3f9dd7 | ||
|
|
7f81df5a3a | ||
|
|
af5dbfcd2c | ||
|
|
5d0eae8576 | ||
|
|
6b63c5f260 | ||
|
|
7228e2e2dc | ||
|
|
a3e1ecf91b | ||
|
|
b41d3ef130 | ||
|
|
8338418471 | ||
|
|
46c510a76d | ||
|
|
80c3164070 | ||
|
|
236004ce6c | ||
|
|
741d9c3273 | ||
|
|
bf4fb3ad64 | ||
|
|
da4ab8a01e | ||
|
|
92eddf8a61 | ||
|
|
637231e6e1 | ||
|
|
6468a78237 | ||
|
|
98e234992b | ||
|
|
e1023d7ec2 | ||
|
|
d186a3a115 | ||
|
|
d03416467d | ||
|
|
65a0e03b66 | ||
|
|
6ae020f51e | ||
|
|
6c79a46513 | ||
|
|
4a62856153 | ||
|
|
5121867bbb | ||
|
|
5a467da137 | ||
|
|
18bafaf9c9 | ||
|
|
5141a91bba | ||
|
|
9fce067266 | ||
|
|
59241e467c | ||
|
|
09e26980d3 | ||
|
|
c3bb36bad9 | ||
|
|
2c57b3219b | ||
|
|
e9e8206467 | ||
|
|
9a7517d3eb | ||
|
|
1bf90d9f47 | ||
|
|
1527e89ad5 | ||
|
|
95cc31566a | ||
|
|
7274e1bbea | ||
|
|
a8fabcfa01 | ||
|
|
67cf25989a | ||
|
|
f87e574971 | ||
|
|
5269462817 | ||
|
|
f4baddaaf1 | ||
|
|
3aa836e5d7 | ||
|
|
2a6e22e8f8 | ||
|
|
32c3cb01a1 | ||
|
|
a1d8e96fdd | ||
|
|
c4780e54fd | ||
|
|
c56336b32b | ||
|
|
d11f411c17 | ||
|
|
f03dfbb3ba | ||
|
|
ffd6dc6ab7 | ||
|
|
5b4b93acc6 | ||
|
|
2b329eb7b7 | ||
|
|
9c5644ddc0 | ||
|
|
398e87490d | ||
|
|
a191370d54 | ||
|
|
bf9ce3bfaf | ||
|
|
ca7e8ca3b7 | ||
|
|
ab43a3bd22 | ||
|
|
32e58c71fe | ||
|
|
b12a9393f1 | ||
|
|
c27af4fa2e | ||
|
|
90bf20d9d8 | ||
|
|
8ffcbd98c4 | ||
|
|
15d90826f9 | ||
|
|
304143a166 | ||
|
|
02b3704af0 | ||
|
|
eb1330ca18 | ||
|
|
3a15c8080d | ||
|
|
6ac9f59ef4 | ||
|
|
bffdc59901 | ||
|
|
76a8a8e347 | ||
|
|
e37aba5924 | ||
|
|
5c0107462e | ||
|
|
87a45acae7 | ||
|
|
ab01a5fd65 | ||
|
|
6e833e5d5d | ||
|
|
d7d7f1b023 | ||
|
|
9c42fb348b | ||
|
|
e4f222f6b0 | ||
|
|
38bee71630 | ||
|
|
1a6a807f68 | ||
|
|
03ce26c697 | ||
|
|
cf6b868dfb | ||
|
|
d862c82386 | ||
|
|
50df7a69e6 | ||
|
|
26ecba8de1 | ||
|
|
0d1e6b7a5e | ||
|
|
b104a5b953 | ||
|
|
ebddd5d346 | ||
|
|
5bd7b4aaec | ||
|
|
a6fdf043ce | ||
|
|
287702610c | ||
|
|
4d59eca5f9 | ||
|
|
753ea81fc0 | ||
|
|
675b2c3a70 | ||
|
|
86745e1320 | ||
|
|
a3ab942ea1 | ||
|
|
e969832946 | ||
|
|
95a9531ec1 | ||
|
|
7637a2de19 | ||
|
|
a589be2826 | ||
|
|
b53096f4a2 | ||
|
|
687d8277e7 | ||
|
|
4c0cf27cfc | ||
|
|
149527ddb8 | ||
|
|
9c3eb170d2 | ||
|
|
c4b707e9ca | ||
|
|
9d35d59fb3 | ||
|
|
877b37d3cc | ||
|
|
f14f55c951 | ||
|
|
8a16a551e0 | ||
|
|
2a5239578a | ||
|
|
d28eb696ec | ||
|
|
4c0c2559c3 | ||
|
|
ee0a3deb6a | ||
|
|
076f051bb5 | ||
|
|
658c57546c | ||
|
|
223a650e3e | ||
|
|
4b6316e510 | ||
|
|
41546230dc | ||
|
|
8413c0f94b | ||
|
|
8828b47cea | ||
|
|
b0f300e61b | ||
|
|
3ad83dd70e | ||
|
|
496b53d84d | ||
|
|
e9c547c1c6 | ||
|
|
0307e2c586 | ||
|
|
e29c2360e7 | ||
|
|
2b38b65392 | ||
|
|
baf12e7aa4 | ||
|
|
064efd6876 | ||
|
|
f615ac58b3 | ||
|
|
2af170dbc9 | ||
|
|
39ad2cf3fe | ||
|
|
21387ecdae | ||
|
|
78fbae3293 | ||
|
|
a8ead811bf | ||
|
|
9696dd0c2b | ||
|
|
5e9ffb4c56 | ||
|
|
e9587cf68f | ||
|
|
1771825e81 | ||
|
|
e5b838a01b | ||
|
|
cd23a21bbf | ||
|
|
42946d72ba | ||
|
|
2c836ede76 | ||
|
|
26d29abef1 | ||
|
|
14d0f14c82 | ||
|
|
a260f7d288 | ||
|
|
16772e8d8b | ||
|
|
e3e11b1a90 | ||
|
|
8576581574 | ||
|
|
2042bb8502 | ||
|
|
18cd9b137f | ||
|
|
1495e7134f | ||
|
|
a7a941ce78 | ||
|
|
1c98c52535 | ||
|
|
9a3d1c6de1 | ||
|
|
a8d43eb2ec | ||
|
|
d81b25c3bf | ||
|
|
c95ec71e09 | ||
|
|
6188f764d1 | ||
|
|
dc9084b5ad | ||
|
|
330e91ef08 | ||
|
|
9caff8d778 | ||
|
|
6f6ce33588 | ||
|
|
8101132352 | ||
|
|
96a3be3ca8 | ||
|
|
75e5cc19e0 | ||
|
|
0692d486f4 | ||
|
|
cfc1071c73 | ||
|
|
6bcd4f0551 | ||
|
|
71f50b841f | ||
|
|
d70f2a462c | ||
|
|
dce403afd9 | ||
|
|
ef4a3fd933 | ||
|
|
c21f32d012 | ||
|
|
66780c03d4 | ||
|
|
6b53ab4b61 | ||
|
|
334ca07eae | ||
|
|
7e01dc1358 | ||
|
|
84f14cab58 | ||
|
|
85992928a7 | ||
|
|
582a027df1 | ||
|
|
db71de2858 | ||
|
|
c1446d6385 | ||
|
|
0151805864 | ||
|
|
408d0995dd | ||
|
|
6a232765f4 | ||
|
|
f516346e09 | ||
|
|
c207edd49c | ||
|
|
828603cfab | ||
|
|
d7cebc312e | ||
|
|
5badbd3a6f | ||
|
|
a1e85a4eb5 | ||
|
|
0433fb8a4b | ||
|
|
9e206415b8 | ||
|
|
ffc61258f9 | ||
|
|
a095c100ce | ||
|
|
3c790ffbc5 | ||
|
|
04c1782f2f | ||
|
|
1efd52af30 | ||
|
|
cc78831262 | ||
|
|
a7b98de833 | ||
|
|
2c11eae732 | ||
|
|
bb7393b1cb | ||
|
|
23e3905529 | ||
|
|
2774a27500 | ||
|
|
763d2e773a | ||
|
|
38d340d176 | ||
|
|
baf5cf45c3 | ||
|
|
ff9a3a1067 | ||
|
|
7541cf2fd7 | ||
|
|
e882f4a46a | ||
|
|
33a5457407 | ||
|
|
112cfbeac8 | ||
|
|
d1b01c58f8 | ||
|
|
9912e7b331 | ||
|
|
c7a1e0aea3 | ||
|
|
5bfb130f03 | ||
|
|
0521378e70 | ||
|
|
ad08a2e15b | ||
|
|
a61fff6a93 | ||
|
|
cd5438c2c5 | ||
|
|
ef67b42fbd | ||
|
|
601efc3745 | ||
|
|
62656b8375 | ||
|
|
af4208dad0 | ||
|
|
db925ad65d | ||
|
|
ce095b3593 | ||
|
|
5230e5aad0 | ||
|
|
d44f7818c4 | ||
|
|
f824083324 | ||
|
|
82598e94a9 | ||
|
|
b579649a39 | ||
|
|
fb2e96e1b0 | ||
|
|
d5a2fed679 | ||
|
|
318f3dc378 | ||
|
|
18c9eb1960 | ||
|
|
da22b0308e | ||
|
|
3e998387fe | ||
|
|
5018a07f3c | ||
|
|
b74ec267e2 | ||
|
|
d12b825ed4 | ||
|
|
9f9bb3c11c | ||
|
|
9824230caf | ||
|
|
94ca05c8d6 | ||
|
|
569e854a03 | ||
|
|
f0fd3683d5 | ||
|
|
80c57221ef | ||
|
|
6bb2210a91 | ||
|
|
1978399259 | ||
|
|
bc7288a389 | ||
|
|
e826f6b69f | ||
|
|
0d67393072 | ||
|
|
65899a9b50 | ||
|
|
d7364faeee | ||
|
|
ae2622e5ce | ||
|
|
904ddbc884 | ||
|
|
92c21a6950 | ||
|
|
780f9a31ff | ||
|
|
9aba07f8f8 | ||
|
|
db621d7294 | ||
|
|
9c66ad1139 | ||
|
|
8d23cbc051 | ||
|
|
9e1d5ba7db | ||
|
|
41e8ae3959 | ||
|
|
7fab293cd6 | ||
|
|
b3f3cf1216 | ||
|
|
77885ed63d | ||
|
|
3d9b1073d0 | ||
|
|
aca9c89ece | ||
|
|
3948482dc7 | ||
|
|
6684c406e8 | ||
|
|
952ac61761 | ||
|
|
a1b438cea6 | ||
|
|
932b65ef67 | ||
|
|
f701d073d1 | ||
|
|
085a275038 | ||
|
|
9c74c81608 | ||
|
|
687126970d | ||
|
|
39bd429835 | ||
|
|
104c63d7e0 | ||
|
|
70c9861c39 | ||
|
|
8faf097e83 | ||
|
|
d3298935b7 | ||
|
|
f9cf9b9782 | ||
|
|
a5fc111d62 | ||
|
|
5623ba6b63 | ||
|
|
3d9668f8fa | ||
|
|
c93623cb3b | ||
|
|
b0389a2ed1 | ||
|
|
621c702ff8 | ||
|
|
7907f13774 | ||
|
|
956c7cbcba | ||
|
|
23bef99132 | ||
|
|
b104bcd144 | ||
|
|
809b9aa1ad | ||
|
|
9a536f758d | ||
|
|
15a63bae22 | ||
|
|
41640d9402 | ||
|
|
15664de472 | ||
|
|
9186c724c4 | ||
|
|
f764c0ac91 | ||
|
|
f56675f7e5 | ||
|
|
aa94fd6cfe | ||
|
|
e011513b15 | ||
|
|
5c10617981 | ||
|
|
c7f5ec4064 | ||
|
|
7642f55a1c | ||
|
|
3d9d526977 | ||
|
|
4e9233d150 | ||
|
|
ac79c18090 | ||
|
|
08221f17d6 | ||
|
|
d910cfd696 | ||
|
|
0bf10e525e | ||
|
|
7cc921693a | ||
|
|
ee3901f5da | ||
|
|
8bd4e10643 | ||
|
|
44f9fa39ae | ||
|
|
fd0bb58379 | ||
|
|
13d078ecc7 | ||
|
|
9a9ccbe29c | ||
|
|
7ebbb86b50 | ||
|
|
6b54599ca7 | ||
|
|
1f60a244c2 | ||
|
|
54789266b6 | ||
|
|
2d8c6be264 | ||
|
|
1b48cd04b8 | ||
|
|
b307adb081 | ||
|
|
a07d97a9d3 | ||
|
|
4914c4322c | ||
|
|
f186475ec9 | ||
|
|
be84a02c8d | ||
|
|
fcad91a682 | ||
|
|
ad38b1a166 | ||
|
|
c6a50d5a1a | ||
|
|
dd54ef13bc | ||
|
|
c2aee5b6c1 | ||
|
|
abe0640331 | ||
|
|
cb3bd463a5 | ||
|
|
56ec86242a | ||
|
|
2a6cd8230c | ||
|
|
b192ba2304 | ||
|
|
bf41eb4204 | ||
|
|
8a2a7ac6db | ||
|
|
93adffa819 | ||
|
|
144bdb5dd0 | ||
|
|
aee936264b | ||
|
|
866bc9eb90 | ||
|
|
a7952ff36c | ||
|
|
9712057b91 | ||
|
|
9d8bba7a18 | ||
|
|
060ce7ad28 | ||
|
|
180fd3c9ac | ||
|
|
bd700fe515 | ||
|
|
b6b19babc1 | ||
|
|
618954b5d6 | ||
|
|
03c2b2f68e | ||
|
|
ae8f2dd61a | ||
|
|
460a1370d7 | ||
|
|
68e0eb714d | ||
|
|
10b97f4856 | ||
|
|
26c73b8ead | ||
|
|
e267f05141 | ||
|
|
9c7e2ea98e | ||
|
|
9141b525a6 | ||
|
|
90ff6f3d06 | ||
|
|
9187cd3d0a | ||
|
|
d0d759155b | ||
|
|
618e47204d | ||
|
|
e9fd5c70ba | ||
|
|
cbcd195cad | ||
|
|
55303647c1 | ||
|
|
f3bfe80995 | ||
|
|
90a1da28bd | ||
|
|
73ee951807 | ||
|
|
0f373512d2 | ||
|
|
721d483cc3 | ||
|
|
2da5eca384 | ||
|
|
a644b5d068 | ||
|
|
d532a15bf2 | ||
|
|
31e0af745d | ||
|
|
444c07fd81 | ||
|
|
f155d67c45 | ||
|
|
59a8f0cf8e | ||
|
|
1096b7f17f | ||
|
|
d6f7133fa6 | ||
|
|
55c627572c | ||
|
|
2b8ccb5e51 | ||
|
|
d1d25a5bf7 | ||
|
|
ade5591d60 | ||
|
|
eca0800b9d | ||
|
|
884141cd8f | ||
|
|
c7f80986c8 | ||
|
|
9b26bb4436 | ||
|
|
75bf192af5 | ||
|
|
416f12fcc3 | ||
|
|
c2b90c6dae | ||
|
|
8b83f44a46 | ||
|
|
522b39be86 | ||
|
|
1b0078ad20 | ||
|
|
345b6906f9 | ||
|
|
c3c787eaa7 | ||
|
|
1dada9f256 | ||
|
|
d904f2fce5 | ||
|
|
27195b003a | ||
|
|
b401865957 | ||
|
|
381200bce1 | ||
|
|
c33ed46161 | ||
|
|
86c875f7e9 | ||
|
|
99db197472 | ||
|
|
4789c24e1e | ||
|
|
c30bffc60c | ||
|
|
190e1f014e | ||
|
|
995ccc72f8 | ||
|
|
5eb592f8c0 | ||
|
|
cb9b96dbbb | ||
|
|
6382067e5b | ||
|
|
844315f05a | ||
|
|
c5d352bbe1 | ||
|
|
aa6907335a | ||
|
|
d5921cd21b | ||
|
|
f341d0a3e9 | ||
|
|
ab8453c773 | ||
|
|
2fc8acf81e | ||
|
|
89e315ab2b | ||
|
|
faed3e7c80 | ||
|
|
215315b377 | ||
|
|
b6a9c24c7d | ||
|
|
ff01995691 | ||
|
|
62c5747e3f | ||
|
|
c337fa8dc5 | ||
|
|
1911d19cf8 | ||
|
|
4d45215622 | ||
|
|
804a2b7693 | ||
|
|
992fa015a4 | ||
|
|
208d6e96f8 | ||
|
|
93bf18c5b0 | ||
|
|
057abba08b | ||
|
|
40c5fbf0cb | ||
|
|
24813f56ed | ||
|
|
6135757349 | ||
|
|
c9baa366ab | ||
|
|
be49bb6263 | ||
|
|
9c3694af5a | ||
|
|
ba3ba899a5 | ||
|
|
1f11c7e46c | ||
|
|
5b5e262dff | ||
|
|
a7b248ea9b | ||
|
|
039cd02482 | ||
|
|
7b3927a810 | ||
|
|
4a9e52ee44 | ||
|
|
8f8c94a734 | ||
|
|
7b2e8a6ce1 | ||
|
|
8927d5c50f | ||
|
|
132553f521 | ||
|
|
795839cbe1 | ||
|
|
a1b2a1c5ee | ||
|
|
cae6fa0561 | ||
|
|
224409d7d6 | ||
|
|
f0d2cb8d05 | ||
|
|
aee480afd3 | ||
|
|
fbd430af6e | ||
|
|
a884f91b22 | ||
|
|
46a3676634 | ||
|
|
46a1221fc6 | ||
|
|
b6ea2c4a99 | ||
|
|
d0782ad6b1 | ||
|
|
11a909453f | ||
|
|
fec21b6e8e | ||
|
|
11b9eb4a1c | ||
|
|
2b735a0159 | ||
|
|
135c038167 | ||
|
|
7489ddc4f7 | ||
|
|
a9fd92eba6 | ||
|
|
bc5074116e | ||
|
|
b9fbbfbb8e | ||
|
|
26c072f45c | ||
|
|
b7988bf00a | ||
|
|
cca051041f | ||
|
|
3c84cfe610 | ||
|
|
e2c5d91a2d | ||
|
|
7970eb3b3b | ||
|
|
f44d68ef4a | ||
|
|
54a20c511b | ||
|
|
147b542c9d | ||
|
|
0f37152a9b | ||
|
|
efd4c5b340 | ||
|
|
580bb082b5 | ||
|
|
779ec7fad8 | ||
|
|
89f3353a83 | ||
|
|
ab2cb9a809 | ||
|
|
c9ad9cbf0e | ||
|
|
6047093325 | ||
|
|
713f765731 | ||
|
|
4295dfaf20 | ||
|
|
57bac91904 | ||
|
|
958ae9b0ce | ||
|
|
50032e2820 | ||
|
|
daa2440a40 | ||
|
|
645b698f37 | ||
|
|
4b6c8f1fe4 | ||
|
|
9a7941c004 | ||
|
|
f77a763858 | ||
|
|
2b716cd436 | ||
|
|
a3024a98fe | ||
|
|
12e5841959 | ||
|
|
599cbc49f7 | ||
|
|
c73793fa11 | ||
|
|
dd106ae9b8 | ||
|
|
baa4e1b0e8 | ||
|
|
f02d68b36d | ||
|
|
80d2ee78e7 | ||
|
|
d52f09de3c | ||
|
|
3c017f2291 | ||
|
|
9b1396fb32 | ||
|
|
2edc65baaa | ||
|
|
6ed1f42fbe | ||
|
|
826c057047 | ||
|
|
1709d44697 | ||
|
|
d77b41ed25 | ||
|
|
0e77f839ff | ||
|
|
487278ac79 | ||
|
|
499afa1e30 | ||
|
|
af8a663f70 | ||
|
|
6be1c3fdf2 | ||
|
|
f1bb8f48c5 | ||
|
|
7938dfcc17 | ||
|
|
ce3e0852d8 | ||
|
|
ca269907dc | ||
|
|
bca4e717b8 | ||
|
|
cbb3472780 | ||
|
|
b0c55d1bbf | ||
|
|
f1e0737c5e | ||
|
|
650ecb0640 | ||
|
|
75491f4152 | ||
|
|
770c17318e | ||
|
|
c52b4c3ed5 | ||
|
|
a7df139c46 | ||
|
|
bada1d28d6 | ||
|
|
e4bbbcf1fb | ||
|
|
292042e813 | ||
|
|
3cedfbe9bf | ||
|
|
0d799ba69b | ||
|
|
7b66da2706 | ||
|
|
6b4bf428de | ||
|
|
a4a7ce843c | ||
|
|
73fe7015b7 | ||
|
|
ef1152a8fb | ||
|
|
71dddbc8b9 | ||
|
|
45fd3c900f | ||
|
|
69c4966804 | ||
|
|
3af11c2b1f | ||
|
|
dc541d5495 | ||
|
|
5c8a9084e3 | ||
|
|
24b04e9243 | ||
|
|
0562f32af6 | ||
|
|
21d7f784d1 | ||
|
|
16db227119 | ||
|
|
9ebfb11ac8 | ||
|
|
98b0e16984 | ||
|
|
87169ee3ac | ||
|
|
47a46870b6 | ||
|
|
8458bb4c4f | ||
|
|
5238461f3f | ||
|
|
1f7db053cd | ||
|
|
8a9cb37273 | ||
|
|
d2b8e6c125 | ||
|
|
e845abc784 | ||
|
|
70dd76f954 | ||
|
|
887e5a6cba | ||
|
|
acc0a2ef78 | ||
|
|
6ac75d88c4 | ||
|
|
00514d41b6 | ||
|
|
52e8a75e9f | ||
|
|
ce9e92f8e5 | ||
|
|
a709187007 | ||
|
|
fad97bc4d5 | ||
|
|
94648eb4e9 | ||
|
|
805cabafe8 | ||
|
|
9ba6c00a19 | ||
|
|
78297e52aa | ||
|
|
431045619e | ||
|
|
55e34cb174 | ||
|
|
cdb45c743f | ||
|
|
9886d60be0 | ||
|
|
53bc9f8442 | ||
|
|
4c90009a0e | ||
|
|
bee4fd0914 | ||
|
|
f8ae7edaa0 | ||
|
|
9ec9dca5f8 | ||
|
|
a4f1299f42 | ||
|
|
7b76ca7e49 | ||
|
|
18147b2134 | ||
|
|
c78af2b448 | ||
|
|
e2dc01cf6e | ||
|
|
14fbdd3e9c | ||
|
|
3e5d0015af | ||
|
|
b4c1007075 | ||
|
|
e0533db4d9 | ||
|
|
4071f41f52 | ||
|
|
37efeeb044 | ||
|
|
47409f39f6 | ||
|
|
b40e58bc77 | ||
|
|
b7b9d27627 | ||
|
|
6b3d485d77 | ||
|
|
48c6e96a78 | ||
|
|
44feecfc9a | ||
|
|
df559e3444 | ||
|
|
33a7a9b6a2 | ||
|
|
0658719d23 | ||
|
|
c32313a471 | ||
|
|
553b9f261a | ||
|
|
fe22671a6e | ||
|
|
1da0db435f | ||
|
|
17fd7389c4 | ||
|
|
9da5f1401f | ||
|
|
da3aad4497 | ||
|
|
88bfc4ad9c | ||
|
|
9107ee905a | ||
|
|
b5ca41d658 | ||
|
|
d83f442cff | ||
|
|
67e8e60a69 | ||
|
|
d5827ec77d | ||
|
|
c0044dc4a5 | ||
|
|
8696cf8789 | ||
|
|
693ec1b2bc | ||
|
|
330d7b3100 | ||
|
|
d8f96ab9c7 | ||
|
|
072cd029e4 | ||
|
|
46baff8459 | ||
|
|
8d6fff7000 | ||
|
|
f84fff5f87 | ||
|
|
1a67241f67 | ||
|
|
7f7de14e6f | ||
|
|
815758529d | ||
|
|
819254e540 | ||
|
|
f486989697 | ||
|
|
9e98b631da | ||
|
|
b0667aacca | ||
|
|
750f3179de | ||
|
|
b1d9b4751d | ||
|
|
26dc69446e | ||
|
|
a0da287b9c | ||
|
|
5c1d9b28cd | ||
|
|
0aeeebdd64 | ||
|
|
bdcba308df | ||
|
|
aed94e5317 | ||
|
|
a4e6713823 | ||
|
|
96fcfbded1 | ||
|
|
2a8ddcd508 | ||
|
|
d245e90185 | ||
|
|
092aeaeb6f | ||
|
|
b22762b472 | ||
|
|
db5bfc7525 |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: benchmark
|
||||
description: Run and author benchmark tests in xterm.js. Use when editing benchmark files, working in `*.benchmark.ts`, or when asked how to run the full benchmark suite, a single benchmark file, or a single benchmark case.
|
||||
---
|
||||
|
||||
# Benchmark Run Instructions
|
||||
|
||||
## Running Benchmarks
|
||||
|
||||
- Full suite: `npm run benchmark`
|
||||
- Single benchmark file:
|
||||
- Tree: `npm run benchmark -- -t out-test/benchmark/Event.benchmark.js`
|
||||
- Run file: `npm run benchmark -- -s "out-test/benchmark/Event.benchmark.js" out-test/benchmark/Event.benchmark.js`
|
||||
- Single context/case:
|
||||
- Use `-t` to get the path, then:
|
||||
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
|
||||
|
||||
## Benchmark Case Selection
|
||||
|
||||
When writing benchmark instructions:
|
||||
|
||||
- Use `RuntimeCase` to measure pure runtime in ms.
|
||||
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
|
||||
|
||||
## Notes
|
||||
|
||||
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
|
||||
- Keep `NODE_PATH=./out` (handled by the npm script).
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# 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.
|
||||
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
|
||||
- Avoid comments as most tests should be self-documenting.
|
||||
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "xterm.js",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:0-18-buster",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {} // yarn
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": 22
|
||||
} // yarn
|
||||
},
|
||||
"forwardPorts": [
|
||||
3000
|
||||
],
|
||||
"postCreateCommand": "yarn install",
|
||||
"postCreateCommand": "npm install && npm run setup",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
||||
@@ -5,7 +5,6 @@ indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
|
||||
[*.{j,t}s]
|
||||
max_line_length = 100
|
||||
|
||||
-264
@@ -1,264 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"demo/tsconfig.json",
|
||||
"src/browser/tsconfig.json",
|
||||
"src/common/tsconfig.json",
|
||||
"src/headless/tsconfig.json",
|
||||
"test/api/tsconfig.json",
|
||||
"test/benchmark/tsconfig.json",
|
||||
"test/playwright/tsconfig.json",
|
||||
"addons/addon-attach/src/tsconfig.json",
|
||||
"addons/addon-attach/test/tsconfig.json",
|
||||
"addons/addon-canvas/src/tsconfig.json",
|
||||
"addons/addon-canvas/test/tsconfig.json",
|
||||
"addons/addon-fit/src/tsconfig.json",
|
||||
"addons/addon-fit/test/tsconfig.json",
|
||||
"addons/addon-image/src/tsconfig.json",
|
||||
"addons/addon-image/test/tsconfig.json",
|
||||
"addons/addon-ligatures/src/tsconfig.json",
|
||||
"addons/addon-search/src/tsconfig.json",
|
||||
"addons/addon-search/test/tsconfig.json",
|
||||
"addons/addon-serialize/src/tsconfig.json",
|
||||
"addons/addon-serialize/test/tsconfig.json",
|
||||
"addons/addon-serialize/benchmark/tsconfig.json",
|
||||
"addons/addon-unicode11/src/tsconfig.json",
|
||||
"addons/addon-unicode11/test/tsconfig.json",
|
||||
"addons/addon-unicode-graphemes/src/tsconfig.json",
|
||||
"addons/addon-unicode-graphemes/test/tsconfig.json",
|
||||
"addons/addon-unicode-graphemes/benchmark/tsconfig.json",
|
||||
"addons/addon-web-links/src/tsconfig.json",
|
||||
"addons/addon-web-links/test/tsconfig.json",
|
||||
"addons/addon-webgl/src/tsconfig.json",
|
||||
"addons/addon-webgl/test/tsconfig.json"
|
||||
],
|
||||
"sourceType": "module"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"addons/*/src/third-party/*.ts",
|
||||
"out/*",
|
||||
"out-test/*",
|
||||
"**/inwasm-sdks/*",
|
||||
"**/typings/*.d.ts",
|
||||
"**/node_modules",
|
||||
"**/*.js"
|
||||
],
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"jsdoc"
|
||||
],
|
||||
"rules": {
|
||||
"no-extra-semi": "error",
|
||||
"@typescript-eslint/array-type": [
|
||||
"warn",
|
||||
{
|
||||
"default": "array",
|
||||
"readonly": "generic"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-assertions": "warn",
|
||||
"@typescript-eslint/consistent-type-definitions": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"warn",
|
||||
{
|
||||
"allowExpressions": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/explicit-member-accessibility": [
|
||||
"warn",
|
||||
{
|
||||
"accessibility": "explicit",
|
||||
"overrides": {
|
||||
"constructors": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/indent": [
|
||||
"warn",
|
||||
2
|
||||
],
|
||||
"@typescript-eslint/member-delimiter-style": [
|
||||
"warn",
|
||||
{
|
||||
"multiline": {
|
||||
"delimiter": "semi",
|
||||
"requireLast": true
|
||||
},
|
||||
"singleline": {
|
||||
"delimiter": "comma",
|
||||
"requireLast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"warn",
|
||||
{ "selector": "default", "format": ["camelCase"],
|
||||
"filter": {
|
||||
"regex": "^[a-z]",
|
||||
"match": true
|
||||
}
|
||||
},
|
||||
// variableLike
|
||||
{ "selector": "variable", "format": ["camelCase", "UPPER_CASE"] },
|
||||
{ "selector": "variable", "filter": "^I.+Service$", "format": ["PascalCase"], "prefix": ["I"] },
|
||||
// memberLike
|
||||
{ "selector": "memberLike", "modifiers": ["private"], "format": ["camelCase"], "leadingUnderscore": "require" },
|
||||
{ "selector": "memberLike", "modifiers": ["protected"], "format": ["camelCase"], "leadingUnderscore": "require" },
|
||||
{ "selector": "enumMember", "format": ["UPPER_CASE"] },
|
||||
// memberLike - Allow enum-like objects to use UPPER_CASE
|
||||
{ "selector": "property", "modifiers": ["public"], "format": ["camelCase", "UPPER_CASE"],
|
||||
"filter": {
|
||||
"regex": "^[a-z]",
|
||||
"match": true
|
||||
}
|
||||
},
|
||||
// restrict on* naming for events only
|
||||
{ "selector": "method", "modifiers": ["public"], "format": ["camelCase", "UPPER_CASE"], "custom": {
|
||||
"regex": "^on[A-Z].+",
|
||||
"match": false
|
||||
} },
|
||||
{ "selector": "method", "modifiers": ["private"], "format": ["camelCase"], "leadingUnderscore": "require", "custom": {
|
||||
"regex": "^on[A-Z].+",
|
||||
"match": false
|
||||
} },
|
||||
{ "selector": "method", "modifiers": ["protected"], "format": ["camelCase"], "leadingUnderscore": "require", "custom": {
|
||||
"regex": "^on[A-Z].+",
|
||||
"match": false
|
||||
} },
|
||||
// typeLike
|
||||
{ "selector": "typeLike", "format": ["PascalCase"] },
|
||||
{ "selector": "interface", "format": ["PascalCase"], "prefix": ["I"] }
|
||||
],
|
||||
"@typescript-eslint/no-confusing-void-expression": [
|
||||
"warn",
|
||||
{ "ignoreArrowShorthand": true }
|
||||
],
|
||||
"@typescript-eslint/no-useless-constructor": "warn",
|
||||
"@typescript-eslint/prefer-namespace-keyword": "warn",
|
||||
"@typescript-eslint/type-annotation-spacing": "warn",
|
||||
"@typescript-eslint/quotes": [
|
||||
"warn",
|
||||
"single",
|
||||
{ "allowTemplateLiterals": true }
|
||||
],
|
||||
"@typescript-eslint/semi": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"comma-dangle": [
|
||||
"warn",
|
||||
{
|
||||
"objects": "never",
|
||||
"arrays": "never",
|
||||
"functions": "never"
|
||||
}
|
||||
],
|
||||
"curly": [
|
||||
"warn",
|
||||
"multi-line"
|
||||
],
|
||||
"eol-last": "warn",
|
||||
"eqeqeq": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"jsdoc/check-alignment": 1,
|
||||
"jsdoc/check-param-names": 1,
|
||||
"jsdoc/no-multi-asterisks": 1,
|
||||
"keyword-spacing": "warn",
|
||||
"max-len": [
|
||||
"warn",
|
||||
{
|
||||
"code": 1000, // Don't enforce for code
|
||||
"comments": 100,
|
||||
"ignoreTrailingComments": true,
|
||||
"ignoreUrls": true,
|
||||
"ignorePattern": "^ *((?<vt_comment>(//|\\*) @vt)|(?<table_comment>\\* \\| )|(?<commented_code>// ))"
|
||||
}
|
||||
],
|
||||
"new-parens": "warn",
|
||||
"no-duplicate-imports": "warn",
|
||||
"no-else-return": [
|
||||
"warn",
|
||||
{
|
||||
"allowElseIf": false
|
||||
}
|
||||
],
|
||||
"no-eval": "warn",
|
||||
"no-irregular-whitespace": "warn",
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
"patterns": [
|
||||
".*\\/out\\/.*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"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."
|
||||
}
|
||||
],
|
||||
"no-trailing-spaces": "warn",
|
||||
"no-unsafe-finally": "warn",
|
||||
"no-unused-vars": ["warn", {
|
||||
"vars": "all",
|
||||
"args": "none"
|
||||
}],
|
||||
"no-var": "warn",
|
||||
"one-var": [
|
||||
"warn",
|
||||
"never"
|
||||
],
|
||||
"object-curly-spacing": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"prefer-const": "warn",
|
||||
"spaced-comment": [
|
||||
"warn",
|
||||
"always",
|
||||
{
|
||||
"markers": ["/"],
|
||||
"exceptions": ["-"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.api.ts",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"rules": {
|
||||
"object-curly-spacing": "off",
|
||||
"max-len": "off",
|
||||
"no-unused-vars": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"jsdoc"
|
||||
],
|
||||
"rules": {
|
||||
"no-extra-semi": "error",
|
||||
"@typescript-eslint/array-type": [
|
||||
"warn",
|
||||
{
|
||||
"default": "array",
|
||||
"readonly": "generic"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"warn",
|
||||
{
|
||||
"allowExpressions": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/indent": [
|
||||
"warn",
|
||||
2
|
||||
],
|
||||
"@typescript-eslint/member-delimiter-style": [
|
||||
"warn",
|
||||
{
|
||||
"multiline": {
|
||||
"delimiter": "semi",
|
||||
"requireLast": true
|
||||
},
|
||||
"singleline": {
|
||||
"delimiter": "comma",
|
||||
"requireLast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"warn",
|
||||
{ "selector": "typeLike", "format": ["PascalCase"] },
|
||||
{ "selector": "interface", "format": ["PascalCase"], "prefix": ["I"] }
|
||||
],
|
||||
"@typescript-eslint/prefer-namespace-keyword": "warn",
|
||||
"@typescript-eslint/type-annotation-spacing": "warn",
|
||||
"@typescript-eslint/quotes": [
|
||||
"warn",
|
||||
"single",
|
||||
{ "allowTemplateLiterals": true }
|
||||
],
|
||||
"@typescript-eslint/semi": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"comma-dangle": [
|
||||
"warn",
|
||||
{
|
||||
"objects": "never",
|
||||
"arrays": "never",
|
||||
"functions": "never"
|
||||
}
|
||||
],
|
||||
"curly": [
|
||||
"warn",
|
||||
"multi-line"
|
||||
],
|
||||
"eol-last": "warn",
|
||||
"eqeqeq": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"jsdoc/check-alignment": 1,
|
||||
"jsdoc/check-param-names": 1,
|
||||
"keyword-spacing": "warn",
|
||||
"max-len": [
|
||||
"warn",
|
||||
{
|
||||
"code": 1000, // Don't enforce for code
|
||||
"comments": 80,
|
||||
"ignoreUrls": true,
|
||||
"ignorePattern": "^ *(?<ps_description>\\* Ps=)"
|
||||
}
|
||||
],
|
||||
"no-irregular-whitespace": "warn",
|
||||
"no-trailing-spaces": "warn",
|
||||
"object-curly-spacing": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"spaced-comment": [
|
||||
"warn",
|
||||
"always",
|
||||
{
|
||||
"markers": ["/"],
|
||||
"exceptions": ["-"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+98
-139
@@ -12,44 +12,64 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 18.x
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: 'yarn'
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
- name: Build
|
||||
run: yarn setup
|
||||
run: npm ci
|
||||
- name: Setup and run tsc
|
||||
run: npm run setup
|
||||
- name: Esbuild
|
||||
run: npm run esbuild
|
||||
- name: Zip artifacts
|
||||
run: |
|
||||
zip -r compressed-build \
|
||||
./lib/* \
|
||||
./out/* \
|
||||
./out-test/* \
|
||||
./out-*/* \
|
||||
./addons/addon-attach/lib/* \
|
||||
./addons/addon-attach/out/* \
|
||||
./addons/addon-attach/out-test/* \
|
||||
./addons/addon-canvas/out/* \
|
||||
./addons/addon-canvas/out-test/* \
|
||||
./addons/addon-attach/out-*/* \
|
||||
./addons/addon-clipboard/lib/* \
|
||||
./addons/addon-clipboard/out/* \
|
||||
./addons/addon-clipboard/out-*/* \
|
||||
./addons/addon-fit/lib/* \
|
||||
./addons/addon-fit/out/* \
|
||||
./addons/addon-fit/out-test/* \
|
||||
./addons/addon-fit/out-*/* \
|
||||
./addons/addon-image/lib/* \
|
||||
./addons/addon-image/out/* \
|
||||
./addons/addon-image/out-test/* \
|
||||
./addons/addon-image/out-*/* \
|
||||
./addons/addon-ligatures/lib/* \
|
||||
./addons/addon-ligatures/out/* \
|
||||
./addons/addon-ligatures/out-test/* \
|
||||
./addons/addon-ligatures/out-*/* \
|
||||
./addons/addon-progress/lib/* \
|
||||
./addons/addon-progress/out/* \
|
||||
./addons/addon-progress/out-*/* \
|
||||
./addons/addon-search/lib/* \
|
||||
./addons/addon-search/out/* \
|
||||
./addons/addon-search/out-test/* \
|
||||
./addons/addon-search/out-*/* \
|
||||
./addons/addon-serialize/lib/* \
|
||||
./addons/addon-serialize/out/* \
|
||||
./addons/addon-serialize/out-test/* \
|
||||
./addons/addon-serialize/out-*/* \
|
||||
./addons/addon-unicode11/lib/* \
|
||||
./addons/addon-unicode11/out/* \
|
||||
./addons/addon-unicode11/out-test/* \
|
||||
./addons/addon-unicode11/out-*/* \
|
||||
./addons/addon-unicode-graphemes/lib/* \
|
||||
./addons/addon-unicode-graphemes/out/* \
|
||||
./addons/addon-unicode-graphemes/out-test/* \
|
||||
./addons/addon-unicode-graphemes/out-*/* \
|
||||
./addons/addon-web-links/lib/* \
|
||||
./addons/addon-web-links/out/* \
|
||||
./addons/addon-web-links/out-test/* \
|
||||
./addons/addon-web-links/out-*/* \
|
||||
./addons/addon-web-fonts/lib/* \
|
||||
./addons/addon-web-fonts/out/* \
|
||||
./addons/addon-web-fonts/out-*/* \
|
||||
./addons/addon-webgl/lib/* \
|
||||
./addons/addon-webgl/out/* \
|
||||
./addons/addon-webgl/out-test/*
|
||||
./addons/addon-webgl/out-*st/*
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: compressed-build.zip
|
||||
@@ -60,21 +80,20 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 18.x
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: 'yarn'
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn install-addons
|
||||
npm ci
|
||||
- name: Lint code
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
run: yarn lint
|
||||
run: npm run lint
|
||||
- name: Lint API
|
||||
run: yarn lint-api
|
||||
run: npm run lint-api
|
||||
|
||||
test-unit-coverage:
|
||||
needs: build
|
||||
@@ -82,16 +101,15 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 18.x
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: 'yarn'
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn install-addons
|
||||
- uses: actions/download-artifact@v3
|
||||
npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -105,16 +123,16 @@ jobs:
|
||||
ls -R
|
||||
- name: Unit test coverage
|
||||
run: |
|
||||
yarn test-unit-coverage --forbid-only
|
||||
npm run test-unit-coverage --forbid-only
|
||||
EXIT_CODE=$?
|
||||
./node_modules/.bin/nyc report --reporter=cobertura
|
||||
exit $EXIT_CODE
|
||||
|
||||
test-unit-parallel:
|
||||
test-unit:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18]
|
||||
node-version: [22]
|
||||
runs-on: [ubuntu, macos, windows]
|
||||
runs-on: ${{ matrix.runs-on }}-latest
|
||||
steps:
|
||||
@@ -123,17 +141,16 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'yarn'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn install-addons
|
||||
npm ci
|
||||
- name: Wait for build job
|
||||
uses: NathanFirmo/wait-for-other-job@v1.1.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
job: build
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -146,68 +163,26 @@ jobs:
|
||||
fi
|
||||
ls -R
|
||||
- name: Unit tests
|
||||
run: yarn test-unit --forbid-only
|
||||
run: npm run test-unit --forbid-only
|
||||
|
||||
test-api-parallel:
|
||||
test-integration:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18] # just one as integration tests are about testing in browser
|
||||
runs-on: [ubuntu] # macos is flaky
|
||||
browser: [chromium, firefox]
|
||||
runs-on: ${{ matrix.runs-on }}-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'yarn'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn install-addons
|
||||
- name: Install playwright
|
||||
run: npx playwright install
|
||||
- name: Wait for build job
|
||||
uses: NathanFirmo/wait-for-other-job@v1.1.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
job: build
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
pwsh -Command "7z x compressed-build.zip -aoa -o${{ github.workspace }}"
|
||||
else
|
||||
unzip -o compressed-build.zip
|
||||
fi
|
||||
ls -R
|
||||
- name: Integration tests (${{ matrix.browser }})
|
||||
run: yarn test-api-${{ matrix.browser }} --headless --forbid-only
|
||||
|
||||
test-playwright-parallel:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18] # just one as integration tests are about testing in browser
|
||||
runs-on: [ubuntu] # macos is flaky
|
||||
node-version: [22] # just one as integration tests are about testing in browser
|
||||
runs-on: [ubuntu-22.04] # macos is flaky
|
||||
browser: [chromium, firefox, webkit]
|
||||
runs-on: ${{ matrix.runs-on }}-latest
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'yarn'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn install-addons
|
||||
npm ci
|
||||
- name: Install playwright
|
||||
run: npx playwright install --with-deps ${{ matrix.browser }}
|
||||
- name: Wait for build job
|
||||
@@ -215,7 +190,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
job: build
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -227,72 +202,56 @@ jobs:
|
||||
unzip -o compressed-build.zip
|
||||
fi
|
||||
ls -R
|
||||
- name: Build demo
|
||||
run: yarn build-demo
|
||||
- name: Build demo client
|
||||
run: npm run esbuild-demo-client
|
||||
- name: Build demo server
|
||||
run: npm run esbuild-demo-server
|
||||
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness
|
||||
run: yarn test-playwright-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
|
||||
- name: Integration tests (addon-canvas)
|
||||
run: yarn test-playwright-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-canvas
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core
|
||||
- name: Integration tests (addon-attach)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach
|
||||
- name: Integration tests (addon-clipboard)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard
|
||||
- name: Integration tests (addon-fit)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit
|
||||
- name: Integration tests (addon-image)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image
|
||||
- name: Integration tests (addon-progress)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress
|
||||
- name: Integration tests (addon-search)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search
|
||||
- name: Integration tests (addon-serialize)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize
|
||||
- name: Integration tests (addon-unicode-graphemes)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes
|
||||
- name: Integration tests (addon-unicode11)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11
|
||||
- name: Integration tests (addon-web-fonts)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts
|
||||
- name: Integration tests (addon-web-links)
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links
|
||||
- name: Integration tests (addon-webgl)
|
||||
run: yarn test-playwright-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
|
||||
|
||||
test-api:
|
||||
needs: build
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18] # just one as integration tests are about testing in browser
|
||||
runs-on: [windows] # macos is flaky
|
||||
browser: [chromium, firefox]
|
||||
runs-on: ${{ matrix.runs-on }}-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'yarn'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn install-addons
|
||||
- name: Install playwright
|
||||
run: npx playwright install
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
pwsh -Command "7z x compressed-build.zip -aoa -o${{ github.workspace }}"
|
||||
else
|
||||
unzip -o compressed-build.zip
|
||||
fi
|
||||
ls -R
|
||||
- name: Integration tests (${{ matrix.browser }})
|
||||
run: yarn test-api-${{ matrix.browser }} --headless --forbid-only
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl
|
||||
|
||||
release-dry-run:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18]
|
||||
node-version: [22]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}.x
|
||||
cache: 'yarn'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn install-addons
|
||||
npm ci
|
||||
- name: Install playwright
|
||||
run: npx playwright install
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
- name: Unzip artifacts
|
||||
@@ -306,7 +265,7 @@ jobs:
|
||||
ls -R
|
||||
- name: Package headless
|
||||
run: |
|
||||
yarn package-headless
|
||||
npm run package-headless
|
||||
node ./bin/package_headless.js
|
||||
- name: Publish to npm (dry run)
|
||||
run: node ./bin/publish.js --dry
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Setup and run tsc
|
||||
run: npm run setup
|
||||
- name: Esbuild
|
||||
run: npm run esbuild
|
||||
@@ -11,18 +11,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 18.x
|
||||
- name: Use Node.js 22.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: 'yarn'
|
||||
node-version: 22.x
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: yarn setup
|
||||
run: npm run setup
|
||||
- name: Package headless
|
||||
run: |
|
||||
yarn package-headless
|
||||
npm run package-headless
|
||||
node ./bin/package_headless.js
|
||||
- name: Publish to npm
|
||||
env:
|
||||
|
||||
+9
-1
@@ -3,7 +3,10 @@ node_modules/
|
||||
.lock-wscript
|
||||
lib/
|
||||
out/
|
||||
out-demo/
|
||||
out-test/
|
||||
out-esbuild/
|
||||
out-esbuild-test/
|
||||
.nyc_output/
|
||||
Makefile.gyp
|
||||
*.Makefile
|
||||
@@ -16,7 +19,12 @@ npm-debug.log
|
||||
.env
|
||||
build/
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
test-results/
|
||||
|
||||
# Tracing/debug files
|
||||
.cursor/nal-trace/
|
||||
.cursor/*.log
|
||||
|
||||
# Keep bundled code out of Git
|
||||
dist/
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Antonis Kalipetis <akalipetis@sourcelair.com>
|
||||
Antonis Kalipetis <akalipetis@sourcelair.com> <akalipetis@gmail.com>
|
||||
Daniel Imms <daimms@microsoft.com> <tyriar@tyriar.com>
|
||||
Paris Kasidiaris <paris@sourcelair.com>
|
||||
Paris Kasidiaris <pariskasidiaris@gmail.com>
|
||||
Thanasis Daglis <thanasis@sourcelair.com>
|
||||
Thanasis Daglis <thanasis@sourcelair.com> <ath.daglis@gmail.com>
|
||||
@@ -1 +0,0 @@
|
||||
NODE_PATH=./out
|
||||
@@ -1,5 +0,0 @@
|
||||
require:
|
||||
- source-map-support/register
|
||||
reporter: spec
|
||||
color: true
|
||||
check-leaks: true
|
||||
@@ -17,6 +17,9 @@
|
||||
!lib/**/*.js
|
||||
!lib/**/*.js.map
|
||||
|
||||
!lib/**/*.mjs
|
||||
!lib/**/*.mjs.map
|
||||
|
||||
!lib/**/*.css
|
||||
|
||||
# Whitelist - src/
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-1
@@ -61,7 +61,7 @@
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["start"],
|
||||
"stopOnEntry": true,
|
||||
"runtimeVersion": "16",
|
||||
"runtimeVersion": "22",
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "App listening to (http://.*?:[0-9]+)"
|
||||
|
||||
Vendored
+1
-13
@@ -1,16 +1,4 @@
|
||||
{
|
||||
"files.associations": {
|
||||
".eslintrc.json.typings": "jsonc"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.preferences.quoteStyle": "single",
|
||||
"mochaExplorer.envPath": ".mocha.env",
|
||||
"mochaExplorer.files": [
|
||||
"out/**/*.test.js",
|
||||
"addons/**/out/*.test.js"
|
||||
],
|
||||
"mochaExplorer.watch": [
|
||||
"out/**/*.js",
|
||||
"addons/**/out/*.js"
|
||||
]
|
||||
"typescript.preferences.quoteStyle": "single"
|
||||
}
|
||||
|
||||
Vendored
+81
-53
@@ -8,6 +8,87 @@
|
||||
"showReuseMessage": true
|
||||
},
|
||||
"tasks": [
|
||||
// Compound tasks
|
||||
{
|
||||
"label": "dev",
|
||||
"detail": "Runs all tasks required to run the demo in a single terminal using concurrently. This does not support problem matching.",
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"isBackground": true,
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "dev (separate terminals)",
|
||||
"detail": "Runs all tasks required to run the demo in separate terminals. This does support problem matching.",
|
||||
"dependsOn": ["demo-server", "tsc", "esbuild", "esbuild-demo-client", "esbuild-demo-server"],
|
||||
"group": "build"
|
||||
},
|
||||
|
||||
// Demo
|
||||
{
|
||||
"label": "demo-server",
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"group": "xterm-demo"
|
||||
}
|
||||
},
|
||||
|
||||
// Build
|
||||
{
|
||||
"label": "tsc",
|
||||
"type": "npm",
|
||||
"script": "tsc-watch",
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"presentation": {
|
||||
"group": "xterm-build"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "esbuild",
|
||||
"type": "npm",
|
||||
"script": "esbuild-watch",
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": "$esbuild-watch",
|
||||
"presentation": {
|
||||
"group": "xterm-build"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "esbuild-demo-client",
|
||||
"type": "npm",
|
||||
"script": "esbuild-demo-client-watch",
|
||||
"dependsOn": ["esbuild", "tsc"],
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": "$esbuild-watch",
|
||||
"presentation": {
|
||||
"group": "xterm-demo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "esbuild-demo-server",
|
||||
"type": "npm",
|
||||
"script": "esbuild-demo-server-watch",
|
||||
"dependsOn": ["esbuild", "tsc"],
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": "$esbuild-watch",
|
||||
"presentation": {
|
||||
"group": "xterm-demo"
|
||||
}
|
||||
},
|
||||
|
||||
// Test
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
@@ -16,59 +97,6 @@
|
||||
"isDefault": true
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "watch",
|
||||
"type": "npm",
|
||||
"script": "watch",
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"presentation": {
|
||||
"group": "vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "start",
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"group": "build",
|
||||
"isBackground": true,
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"group": "vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Start demo",
|
||||
"dependsOn": ["start", "watch"],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"fileLocation": [
|
||||
"relative",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^([^\\\\s].*)\\\\((\\\\d+,\\\\d+)\\\\):\\\\s*(.*)$",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"beginsPattern": "assets by",
|
||||
"endsPattern": "webpack \\d+\\.\\d+\\.\\d+ compiled successfully"
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"group": "vscode"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# xterm.js Copilot Instructions
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Core Structure**: xterm.js is a multi-target terminal emulator with these main source areas:
|
||||
- `src/primitives/`: Building blocks (buffer, parser, input, data, utilities); TypeScript project `src/primitives/tsconfig.json`
|
||||
- `src/runtime/`: Services, `CoreTerminal`, `InputHandler`, public API adapters; TypeScript project `src/runtime/tsconfig.json`
|
||||
- `src/target-browser/`: Browser terminal with DOM rendering
|
||||
- `src/target-headless/`: Node terminal without DOM
|
||||
|
||||
See `src/ARCHITECTURE.md` for the dependency graph and import-path conventions (`common/*`, `target-browser/*`, `target-headless/*`).
|
||||
|
||||
**Key Classes**:
|
||||
- `Terminal` (browser/headless): Public API wrapper
|
||||
- `CoreTerminal` (common): Core terminal logic and state
|
||||
- `CoreBrowserTerminal` (browser): Browser-specific terminal implementation
|
||||
|
||||
## Development Workflow
|
||||
|
||||
**Build System**:
|
||||
```bash
|
||||
npm run build && npm run esbuild # Build all TypeScript and bundle
|
||||
```
|
||||
|
||||
**Testing**:
|
||||
- Unit tests: `npm run test-unit` (Mocha)
|
||||
- Unit tests filtering to file: `npm run test-unit -- **/fileName.ts
|
||||
- Per-addon unit tests: `npm run test-unit -- addons/addon-image/out-esbuild/*.test.js`
|
||||
- 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 addon: `npm run test-integration -- --suite=addon-search`. Suites always follow the format `addon-<something>`
|
||||
- Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them
|
||||
|
||||
## Addon Development Pattern
|
||||
|
||||
All addons follow this structure:
|
||||
```typescript
|
||||
export class MyAddon implements ITerminalAddon {
|
||||
activate(terminal: Terminal): void {
|
||||
// Called when loaded via terminal.loadAddon()
|
||||
// Register handlers, access terminal APIs
|
||||
}
|
||||
dispose(): void {
|
||||
// Cleanup when addon is disposed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Examples**:
|
||||
- `addons/addon-fit/`: Terminal sizing
|
||||
- `addons/addon-webgl/`: GPU-accelerated rendering
|
||||
- `addons/addon-search/`: Text search functionality
|
||||
|
||||
## Project-Specific Conventions
|
||||
|
||||
**TypeScript Project Structure**: Uses TypeScript project references (`tsconfig.all.json`) for incremental builds across browser/headless/addons.
|
||||
|
||||
**API Design**:
|
||||
- Browser and headless terminals share the same public API
|
||||
- Proposed APIs require `allowProposedApi: true` option
|
||||
- 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:
|
||||
- `openTerminal(ctx, options)` for setup
|
||||
- `pollFor(page, fn, expectedValue)` for async assertions
|
||||
- `writeSync(page, data)` for terminal input
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Parser Integration**: Register custom escape sequence handlers:
|
||||
```typescript
|
||||
terminal.parser.registerCsiHandler('m', params => {
|
||||
// Handle SGR sequences
|
||||
return true; // Handled
|
||||
});
|
||||
```
|
||||
|
||||
**Buffer Access**: Read terminal content via buffer API:
|
||||
```typescript
|
||||
const line = terminal.buffer.active.getLine(0);
|
||||
const cell = line?.getCell(0);
|
||||
```
|
||||
|
||||
**Events**: All terminals emit standard events (onData, onResize, onRender) plus platform-specific ones.
|
||||
|
||||
## Critical Implementation Details
|
||||
|
||||
- Terminal rendering uses either DOM or WebGL renderers
|
||||
- Buffer lines are immutable; create new instances for modifications
|
||||
- Character width handling supports Unicode 11+ and grapheme clustering
|
||||
- Mouse events translate web events to terminal protocols (X10, VT200, etc.)
|
||||
- Color theming supports both palette and true color modes
|
||||
|
||||
## Writing unit tests
|
||||
|
||||
- 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.
|
||||
+35
-47
@@ -1,62 +1,50 @@
|
||||
# How to contribute to xterm.js
|
||||
|
||||
- [Opening issues for bug reports or feature requests](#opening-issues)
|
||||
- [Contributing code](#contributing-code)
|
||||
- [Opening issues](#opening-issues)
|
||||
- [Answering discussion questions](#answering-discussion-questions)
|
||||
|
||||
## Contributing code
|
||||
|
||||
You can find issues to work on by looking at issues labeled with [help wanted](https://github.com/xtermjs/xterm.js/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) or [good first issue](https://github.com/xtermjs/xterm.js/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). It's a good idea to comment on the issue saying that you're looking into it, just in case someone else comes along and you duplicate work. Once you have your issue, here are the steps to contribute:
|
||||
|
||||
- Fork [xterm.js](https://github.com/xtermjs/xterm.js/) ([how to fork a repo](https://help.github.com/articles/fork-a-repo)).
|
||||
- Get the [xterm.js demo](https://github.com/xtermjs/xterm.js/wiki/Contributing#running-the-demo) running.
|
||||
- Make the fix and verify it works in the demo. Be sure to follow thew general code style of the rest of the project.
|
||||
- If your changes are easy to test or likely to regress in the future, add tests. These could be unit or integration (playwright) tests.
|
||||
- Submit a pull request ([how to create a pull request](https://help.github.com/articles/fork-a-repo)).
|
||||
|
||||
> ![TIP]
|
||||
> Don't put more than one feature or fix in a single pull request. The smaller pull requests are the easier they are to review and merge.
|
||||
|
||||
By contributing code to xterm.js you:
|
||||
|
||||
- Agree to license the contributed code under xterm.js' [MIT license](LICENSE).
|
||||
- Confirm that you have the right to contribute and license the code in question. This means that either you hold all rights on the code, or the rights holder has explicitly granted the right to use it like this, through a compatible open source license or through a direct agreement with you.
|
||||
|
||||
## Opening issues
|
||||
|
||||
The preferred way to report bugs or request features is to use
|
||||
[GitHub issues](http://github.com/sourcelair/xterm.js/issues). Before
|
||||
opening an issue, read these pointers.
|
||||
[GitHub issues](http://github.com/xtermjs/xterm.js/issues).
|
||||
|
||||
### Opening issues effectively
|
||||
### Creating great issues
|
||||
|
||||
- Include information about **the browser in which the problem occurred**. Even
|
||||
if you tested several browsers, and the problem occurred in all of them,
|
||||
mention this fact in the bug report. Also include browser version numbers and
|
||||
the operating system that you're on.
|
||||
- Include information about **the browser in which the problem occurred** or the terminal being used. If you tested several browsers and the problem occurred in all of them, mention this fact in the bug report. Also include browser version numbers and the operating system that you're on.
|
||||
- Include the version of xterm.js being used, preferably either with the latest `beta` tagged release on npm or reproducing in the demo on the `master` branch.
|
||||
- Mention precisely what went wrong. What did you expect to happen? What happened instead? Describe the exact steps a maintainer has to take to make the problem occur.
|
||||
- If the problem can not be reproduced in the [demo of xterm.js](https://github.com/xtermjs/xterm.js/wiki/Contributing#running-the-demo), provide an HTML document that demonstrates the problem.
|
||||
- Be polite and follow [the code of conduct](https://github.com/xtermjs/xterm.js?tab=coc-ov-file#readme).
|
||||
|
||||
- Mention which release of xterm.js you're using. Preferably, try also with
|
||||
the current HEAD of the master branch, to ensure the problem has not already been
|
||||
fixed.
|
||||
### Issue triaging philosophy
|
||||
|
||||
- Mention precisely what went wrong. What did you expect to happen? What happened instead? Describe the
|
||||
exact steps a maintainer has to take to make the problem occur.
|
||||
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:
|
||||
|
||||
- If the problem can not be reproduced in the [demo of xterm.js](https://github.com/xtermjs/xterm.js/wiki/Contributing#running-the-demo), please provide an HTML document that demonstrates the problem.
|
||||
|
||||
- Be polite. Issues with an indignant or belligerent tone tend to be moved to the
|
||||
bottom of the pile.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 bug is not easily reproducible it may be closed or moved to a discussion. Generally issues that are labeled are something we want to do or has actionable steps to look into further.
|
||||
|
||||
## Answering discussion questions
|
||||
|
||||
Issues are only meant to track (likely) feature requests and bugs. We use [GitHub Discussions](https://github.com/xtermjs/xterm.js/discussions) for general Q&A as well as discussing possible features. If you want to help out, many questions get asked over at the [discussions page](https://github.com/xtermjs/xterm.js/discussions) which could use an expert as the core team is often stretched thin.
|
||||
|
||||
## Contributing code
|
||||
|
||||
You can find issues to work on by looking at the [help wanted](https://github.com/xtermjs/xterm.js/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22) or [good first issue](https://github.com/xtermjs/xterm.js/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) issues. It's a good idea to comment on the issue saying that you're taking it, just in case someone else comes along and you duplicate work. Once you have your issue, here are the steps to contribute:
|
||||
|
||||
- Fork [xterm.js](https://github.com/sourcelair/xterm.js/)
|
||||
([how to fork a repo](https://help.github.com/articles/fork-a-repo)).
|
||||
- Get the [xterm.js demo](https://github.com/xtermjs/xterm.js/wiki/Contributing#running-the-demo) running.
|
||||
- Make your changes.
|
||||
- If your changes are easy to test or likely to regress, add tests. Tests go into `test`, directory.
|
||||
- Follow the general code style of the rest of the project (see below).
|
||||
- Submit a pull request
|
||||
([how to create a pull request](https://help.github.com/articles/fork-a-repo)).
|
||||
Don't put more than one feature/fix in a single pull request.
|
||||
|
||||
By contributing code to xterm.js you:
|
||||
|
||||
- Agree to license the contributed code under xterm.js' [MIT
|
||||
license](LICENSE).
|
||||
|
||||
- Confirm that you have the right to contribute and license the code
|
||||
in question. (Either you hold all rights on the code, or the rights
|
||||
holder has explicitly granted the right to use it like this,
|
||||
through a compatible open source license or through a direct
|
||||
agreement with you.)
|
||||
|
||||
### Test coverage
|
||||
|
||||
One area that always needs attention is improving out unit test coverage, you can view the code coverage report on [Azure Pipelines](https://dev.azure.com/xtermjs/xterm.js/_build/latest?definitionId=3) by clicking the Code Coverage tab.
|
||||
Issues are only meant to track (likely) feature requests and bugs. We use [GitHub Discussions](https://github.com/xtermjs/xterm.js/discussions) for general Q&A as well as discussing possible features. If you want to help out, many questions get asked over at the [discussions page](https://github.com/xtermjs/xterm.js/discussions) which could use an expert as the core team is often stretched thin.
|
||||
@@ -1,42 +1,71 @@
|
||||
# [](https://xtermjs.org)
|
||||
# [](https://xtermjs.org)
|
||||
|
||||
Xterm.js is a front-end component written in TypeScript that lets applications bring fully-featured terminals to their users in the browser. It's used by popular projects such as VS Code, Hyper and Theia.
|
||||
Xterm.js is a frontend component that enables applications to bring fully-featured terminals to their users in the browser. It's used by popular projects such as [VS Code](https://code.visualstudio.com/) (and its forks), [Tabby](https://tabby.sh/) and [Hyper](https://hyper.is/).
|
||||
|
||||
## Features
|
||||
|
||||
- **Terminal apps just work**: Xterm.js works with most terminal apps such as `bash`, `vim`, and `tmux`, including support for curses-based apps and mouse events.
|
||||
- **Performant**: Xterm.js is *really* fast, it even includes a GPU-accelerated renderer.
|
||||
- **Performant**: Xterm.js is *really* fast and includes an optional GPU-accelerated renderer.
|
||||
- **Rich Unicode support**: Supports CJK, emojis, and IMEs.
|
||||
- **Self-contained**: Requires zero dependencies to work.
|
||||
- **Accessible**: Screen reader and minimum contrast ratio support can be turned on.
|
||||
- **And much more**: Links, theming, addons, well documented API, etc.
|
||||
- **Self-contained**: The core library has zero dependencies.
|
||||
- **Accessible**: Screen reader mode and minimum contrast ratio support can be turned on.
|
||||
- **And much more**: Links, theming, custom glyphs, addons, well documented API, etc.
|
||||
|
||||
## What xterm.js is not
|
||||
|
||||
- Xterm.js is not a terminal application that you can download and use on your computer.
|
||||
- Xterm.js is not `bash`. Xterm.js can be connected to processes like `bash` and let you interact with them (provide input, receive output).
|
||||
- Xterm.js is not `bash`. Xterm.js can be connected to processes like `bash` and let you interact with them (provide input, receive output) through a library like [node-pty](https://github.com/microsoft/node-pty).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, you need to install the module, we ship exclusively through [npm](https://www.npmjs.com/), so you need that installed and then add xterm.js as a dependency by running:
|
||||
First, you need to install the module. We ship exclusively through [npm](https://www.npmjs.com), so you need that installed and then add [@xterm/xterm](https://www.npmjs.com/package/@xterm/xterm) as a dependency by running:
|
||||
|
||||
```bash
|
||||
npm install 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
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="node_modules/xterm/css/xterm.css" />
|
||||
<script src="node_modules/xterm/lib/xterm.js"></script>
|
||||
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
|
||||
<script src="node_modules/@xterm/xterm/lib/xterm.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<script>
|
||||
var term = new Terminal();
|
||||
const term = new Terminal();
|
||||
term.open(document.getElementById('terminal'));
|
||||
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
|
||||
</script>
|
||||
@@ -44,22 +73,12 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
|
||||
</html>
|
||||
```
|
||||
|
||||
### Importing
|
||||
|
||||
The recommended way to load xterm.js is via the ES6 module syntax:
|
||||
|
||||
```javascript
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
```
|
||||
|
||||
### Addons
|
||||
|
||||
⚠️ *This section describes the new addon format introduced in v3.14.0, see [here](https://github.com/xtermjs/xterm.js/blob/3.14.2/README.md#addons) for the instructions on the old format*
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
npm i -S @xterm/addon-web-links
|
||||
npm install --save @xterm/addon-web-links
|
||||
```
|
||||
|
||||
Then import the addon, instantiate it and call `Terminal.loadAddon`:
|
||||
@@ -77,58 +96,58 @@ terminal.loadAddon(new WebLinksAddon());
|
||||
The xterm.js team maintains the following addons, but anyone can build them:
|
||||
|
||||
- [`@xterm/addon-attach`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-attach): Attaches to a server running a process via a websocket
|
||||
- [`@xterm/addon-canvas`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-canvas): Renders xterm.js using a `canvas` element's 2d context
|
||||
- [`@xterm/addon-clipboard`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-clipboard): Access the browser's clipboard
|
||||
- [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit): Fits the terminal to the containing element
|
||||
- [`@xterm/addon-image`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image): Adds image support
|
||||
- [`@xterm/addon-ligatures`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-ligatures): Enables rendering of ligatures
|
||||
- [`@xterm/addon-progress`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-progress): Adds support for the progress API (`OSC 9;4`)
|
||||
- [`@xterm/addon-search`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-search): Adds search functionality
|
||||
- [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize): Serializes the terminal's buffer to a VT sequences or HTML
|
||||
- [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize): Serializes the terminal's buffer to VT sequences or HTML
|
||||
- [`@xterm/addon-unicode-graphemes`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-unicode-graphemes): Enhanced unicode support including grapheme clustering (experimental)
|
||||
- [`@xterm/addon-unicode11`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-unicode11): Updates character widths to their unicode11 values
|
||||
- [`@xterm/addon-web-fonts`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-fonts): Easily integrate web fonts
|
||||
- [`@xterm/addon-web-links`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-links): Adds web link detection and interaction
|
||||
- [`@xterm/addon-webgl`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-webgl): Renders xterm.js using a `canvas` element's webgl2 context
|
||||
|
||||
## Browser Support
|
||||
|
||||
Since xterm.js is typically implemented as a developer tool, only modern browsers are supported officially. Specifically the latest versions of *Chrome*, *Edge*, *Firefox*, and *Safari*.
|
||||
|
||||
Xterm.js works seamlessly in [Electron](https://electronjs.org/) apps and may even work on earlier versions of the browsers. These are the versions we strive to keep working.
|
||||
Since xterm.js is typically implemented as a developer tool, generally only modern evergreen browsers are supported officially. Specifically the latest versions of *Chrome*, *Edge*, *Firefox*, and *Safari*. Xterm.js also works seamlessly in [Electron](https://electronjs.org/) apps and may even work on earlier versions of the browsers. These are the versions we strive to keep working.
|
||||
|
||||
### 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 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 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
|
||||
|
||||
The full API for xterm.js is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
|
||||
|
||||
Note that some APIs are marked *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs.
|
||||
Some APIs may be marked with *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs.
|
||||
|
||||
## Releases
|
||||
|
||||
Xterm.js follows a monthly release cycle roughly.
|
||||
|
||||
All current and past releases are available on this repo's [Releases page](https://github.com/sourcelair/xterm.js/releases), you can view the [high-level roadmap on the wiki](https://github.com/xtermjs/xterm.js/wiki/Roadmap) and see what we're working on now by looking through [Milestones](https://github.com/sourcelair/xterm.js/milestones).
|
||||
Stable releases are done on an as needed basis. All current and past releases are available on this repo's [releases page](https://github.com/sourcelair/xterm.js/releases), you can see what's planned for upcoming releases looking through the repository [milestones](https://github.com/sourcelair/xterm.js/milestones).
|
||||
|
||||
### Beta builds
|
||||
|
||||
Our CI releases beta builds to npm for every change that goes into master. Install the latest beta build with:
|
||||
Beta releases are continuously published off the `master` branch. Install the latest beta build with:
|
||||
|
||||
```bash
|
||||
npm install -S xterm@beta
|
||||
npm install --save @xterm/xterm@beta
|
||||
```
|
||||
|
||||
These should generally be stable, but some bugs may slip in. We recommend using the beta build primarily to test out new features and to verify bug fixes.
|
||||
The principal implementation (VS Code) typically uses the latest or near the latest beta build. Generally they are quite stable but can potentially contain bugs or breaking changes. If stability is very important we recommend using the beta build primarily to test out new features and to verify bug fixes, unless you're tracking what's landing and are comfortable taking that risk.
|
||||
|
||||
## Contributing
|
||||
|
||||
You can read the [guide on the wiki](https://github.com/xtermjs/xterm.js/wiki/Contributing) to learn how to contribute and set up xterm.js for development.
|
||||
Read [CONTRIBUTING.md](https://github.com/xtermjs/xterm.js/blob/master/CONTRIBUTING.md) to learn how to contribute to the project.
|
||||
|
||||
## Real-world uses
|
||||
Xterm.js is used in several world-class applications to provide great terminal experiences.
|
||||
|
||||
Xterm.js is used in many world-class applications to provide great terminal experiences.
|
||||
|
||||
- [**SourceLair**](https://www.sourcelair.com/): In-browser IDE that provides its users with fully-featured Linux terminals based on xterm.js.
|
||||
- [**Microsoft Visual Studio Code**](http://code.visualstudio.com/): Modern, versatile, and powerful open source code editor that provides an integrated terminal based on xterm.js.
|
||||
- [**ttyd**](https://github.com/tsl0922/ttyd): A command-line tool for sharing terminal over the web, with fully-featured terminal emulation based on xterm.js.
|
||||
- [**Katacoda**](https://www.katacoda.com/): Katacoda is an Interactive Learning Platform for software developers, covering the latest Cloud Native technologies.
|
||||
- [**Eclipse Che**](http://www.eclipse.org/che): Developer workspace server, cloud IDE, and Eclipse next-generation IDE.
|
||||
- [**Codenvy**](http://www.codenvy.com): Cloud workspaces for development teams.
|
||||
- [**CoderPad**](https://coderpad.io): Online interviewing platform for programmers. Run code in many programming languages, with results displayed by xterm.js.
|
||||
@@ -222,14 +241,27 @@ Xterm.js is used in several world-class applications to provide great terminal e
|
||||
- [**Cloudtutor.io**](https://cloudtutor.io): innovative online learning platform that offers users access to an interactive lab.
|
||||
- [**Helix Editor Playground**](https://github.com/tomgroenwoldt/helix-editor-playground): Online playground for the terminal based helix editor.
|
||||
- [**Coder**](https://github.com/coder/coder): Self-Hosted Remote Development Environments
|
||||
- [**Wave Terminal**](https://waveterm.dev): An open-source, ai-native, terminal built for seamless workflows.
|
||||
- [**eva**](https://github.com/info24/eva): Eva is a web application for SSH remote login, developed in Go.
|
||||
- [**OpenSFTP**](https://opensftp.com): Super beautiful SSH and SFTP integrated workspace client.
|
||||
- [**balena**](https://www.balena.io/): Balena is a full-stack solution for developing, deploying, updating, and troubleshooting IoT Edge devices. We use xterm.js to manage & debug devices on [balenaCloud](https://www.balena.io/cloud).
|
||||
- [**Filet Cloud**](https://github.com/fuglaro/filet-cloud): The lean and powerful personal cloud ⛅.
|
||||
- [**pyTermTk**](https://github.com/ceccopierangiolieugenio/pyTermTk): Python Terminal Toolkit - a Spiced Up Cross Compatible TUI Library 🌶️, use xterm.js for the [HTML5 exporter](https://ceccopierangiolieugenio.github.io/pyTermTk/sandbox/sandbox.html).
|
||||
- [**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.
|
||||
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
|
||||
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
|
||||
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
|
||||
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
|
||||
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
|
||||
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
|
||||
|
||||
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. Note: Please add any new contributions to the end of the list only.
|
||||
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.
|
||||
|
||||
## License Agreement
|
||||
|
||||
If you contribute code to this project, you implicitly allow your code to be distributed under the MIT license. You are also implicitly verifying that all code is your original work.
|
||||
|
||||
Copyright (c) 2017-2022, [The xterm.js authors](https://github.com/xtermjs/xterm.js/graphs/contributors) (MIT License)<br>
|
||||
Copyright (c) 2017-2026, [The xterm.js authors](https://github.com/xtermjs/xterm.js/graphs/contributors) (MIT License)<br>
|
||||
Copyright (c) 2014-2017, SourceLair, Private Company ([www.sourcelair.com](https://www.sourcelair.com/home)) (MIT License)<br>
|
||||
Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
!lib/**/*.js
|
||||
!lib/**/*.js.map
|
||||
|
||||
!lib/**/*.mjs
|
||||
!lib/**/*.mjs.map
|
||||
|
||||
!lib/**/*.css
|
||||
|
||||
# Whitelist - src/
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@xterm/addon-attach",
|
||||
"version": "0.10.0",
|
||||
"version": "0.12.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
},
|
||||
"main": "lib/addon-attach.js",
|
||||
"module": "lib/addon-attach.mjs",
|
||||
"types": "typings/addon-attach.d.ts",
|
||||
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-attach",
|
||||
"license": "MIT",
|
||||
@@ -15,12 +16,10 @@
|
||||
"xterm.js"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "../../node_modules/.bin/tsc -p .",
|
||||
"build": "../../node_modules/.bin/tsgo -p .",
|
||||
"prepackage": "npm run build",
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface IAttachOptions {
|
||||
bidirectional?: boolean;
|
||||
}
|
||||
|
||||
export class AttachAddon implements ITerminalAddon , IAttachApi {
|
||||
export class AttachAddon implements ITerminalAddon, IAttachApi {
|
||||
private _socket: WebSocket;
|
||||
private _bidirectional: boolean;
|
||||
private _disposables: IDisposable[] = [];
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import WebSocket = require('ws');
|
||||
import { openTerminal, pollFor, launchBrowser } from '../../../out-test/api/TestUtils';
|
||||
import { Browser, Page } from '@playwright/test';
|
||||
|
||||
const APP = 'http://127.0.0.1:3001/test';
|
||||
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
const width = 800;
|
||||
const height = 600;
|
||||
|
||||
describe('AttachAddon', () => {
|
||||
before(async function(): Promise<any> {
|
||||
browser = await launchBrowser();
|
||||
page = await (await browser.newContext()).newPage();
|
||||
await page.setViewportSize({ width, height });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => await page.goto(APP));
|
||||
|
||||
it('string', async function(): Promise<any> {
|
||||
await openTerminal(page);
|
||||
const port = 8080;
|
||||
const server = new WebSocket.Server({ port });
|
||||
server.on('connection', socket => socket.send('foo'));
|
||||
await page.evaluate(`window.term.loadAddon(new window.AttachAddon(new WebSocket('ws://localhost:${port}')))`);
|
||||
await pollFor(page, `window.term.buffer.active.getLine(0).translateToString(true)`, 'foo');
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('utf8', async function(): Promise<any> {
|
||||
await openTerminal(page);
|
||||
const port = 8080;
|
||||
const server = new WebSocket.Server({ port });
|
||||
const data = new Uint8Array([102, 111, 111]);
|
||||
server.on('connection', socket => socket.send(data));
|
||||
await page.evaluate(`window.term.loadAddon(new window.AttachAddon(new WebSocket('ws://localhost:${port}')))`);
|
||||
await pollFor(page, `window.term.buffer.active.getLine(0).translateToString(true)`, 'foo');
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import WebSocket = require('ws');
|
||||
|
||||
import test from '@playwright/test';
|
||||
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
|
||||
|
||||
let ctx: ITestContext;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await createTestContext(browser);
|
||||
await openTerminal(ctx);
|
||||
});
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
|
||||
test.describe('Search Tests', () => {
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await ctx.proxy.reset();
|
||||
});
|
||||
|
||||
test('string', async function(): Promise<any> {
|
||||
const port = 8080;
|
||||
const server = new WebSocket.Server({ port });
|
||||
server.on('connection', socket => socket.send('foo'));
|
||||
await ctx.page.evaluate(`window.term.loadAddon(new window.AttachAddon(new WebSocket('ws://localhost:${port}')))`);
|
||||
await pollFor(ctx.page, `window.term.buffer.active.getLine(0).translateToString(true)`, 'foo');
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('utf8', async function(): Promise<any> {
|
||||
const port = 8080;
|
||||
const server = new WebSocket.Server({ port });
|
||||
const data = new Uint8Array([102, 111, 111]);
|
||||
server.on('connection', socket => socket.send(data));
|
||||
await ctx.page.evaluate(`window.term.loadAddon(new window.AttachAddon(new WebSocket('ws://localhost:${port}')))`);
|
||||
await pollFor(ctx.page, `window.term.buffer.active.getLine(0).translateToString(true)`, 'foo');
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
+4
-5
@@ -5,14 +5,13 @@ const config: PlaywrightTestConfig = {
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'Chrome Stable',
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
channel: 'chrome'
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Firefox Stable',
|
||||
name: 'FirefoxStable',
|
||||
use: {
|
||||
browserName: 'firefox'
|
||||
}
|
||||
@@ -26,7 +25,7 @@ const config: PlaywrightTestConfig = {
|
||||
],
|
||||
reporter: 'list',
|
||||
webServer: {
|
||||
command: 'npm run start-server-only',
|
||||
command: 'npm run start',
|
||||
port: 3000,
|
||||
timeout: 120000,
|
||||
reuseExistingServer: !process.env.CI
|
||||
@@ -1,23 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2021",
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"es2015"
|
||||
"es2021",
|
||||
],
|
||||
"rootDir": ".",
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/primitives/*",
|
||||
"../../../src/runtime/*",
|
||||
],
|
||||
"target-browser/*": [
|
||||
"../../../src/target-browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha",
|
||||
"../../../node_modules/@types/node",
|
||||
"../../../out-test/api/TestUtils"
|
||||
"../../../node_modules/@types/node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../../typings/xterm.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../src"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/target-browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../test/playwright"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ module.exports = {
|
||||
filename: mainFile,
|
||||
path: path.resolve('./lib'),
|
||||
library: addonName,
|
||||
libraryTarget: 'umd'
|
||||
libraryTarget: 'umd',
|
||||
// Force usage of globalThis instead of global / self. (This is cross-env compatible)
|
||||
globalObject: 'globalThis',
|
||||
},
|
||||
mode: 'production'
|
||||
};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
lib
|
||||
node_modules
|
||||
@@ -1,28 +0,0 @@
|
||||
## @xterm/addon-canvas
|
||||
|
||||
An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables a canvas-based renderer using a 2d context to draw. This addon requires xterm.js v5+.
|
||||
|
||||
The purpose of this addon is to be used as a fallback for the [webgl addon](https://www.npmjs.com/package/@xterm/addon-webgl) when better performance is desired over the default DOM renderer, but WebGL2 isn't supported or performant for some reason.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install --save @xterm/addon-canvas
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||
|
||||
const terminal = new Terminal();
|
||||
terminal.open(element);
|
||||
terminal.loadAddon(new CanvasAddon());
|
||||
```
|
||||
|
||||
See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-canvas/typings/addon-canvas.d.ts) for more advanced usage.
|
||||
|
||||
### See also
|
||||
|
||||
- [@xterm/addon-webgl](https://www.npmjs.com/package/@xterm/addon-webgl) A renderer for xterm.js that uses WebGL
|
||||
@@ -1,499 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ReadonlyColorSet } from 'browser/Types';
|
||||
import { CellColorResolver } from 'browser/renderer/shared/CellColorResolver';
|
||||
import { acquireTextureAtlas } from 'browser/renderer/shared/CharAtlasCache';
|
||||
import { TEXT_BASELINE } from 'browser/renderer/shared/Constants';
|
||||
import { tryDrawCustomChar } from 'browser/renderer/shared/CustomGlyphs';
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
|
||||
import { IRasterizedGlyph, IRenderDimensions, ISelectionRenderModel, ITextureAtlas } from 'browser/renderer/shared/Types';
|
||||
import { ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
||||
import { EventEmitter, forwardEvent } from 'common/EventEmitter';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { isSafari } from 'common/Platform';
|
||||
import { ICellData } from 'common/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { WHITESPACE_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { IRenderLayer } from './Types';
|
||||
|
||||
export abstract class BaseRenderLayer extends Disposable implements IRenderLayer {
|
||||
private _canvas: HTMLCanvasElement;
|
||||
protected _ctx!: CanvasRenderingContext2D;
|
||||
private _deviceCharWidth: number = 0;
|
||||
private _deviceCharHeight: number = 0;
|
||||
private _deviceCellWidth: number = 0;
|
||||
private _deviceCellHeight: number = 0;
|
||||
private _deviceCharLeft: number = 0;
|
||||
private _deviceCharTop: number = 0;
|
||||
|
||||
protected _selectionModel: ISelectionRenderModel = createSelectionRenderModel();
|
||||
private _cellColorResolver: CellColorResolver;
|
||||
private _bitmapGenerator: (BitmapGenerator | undefined)[] = [];
|
||||
|
||||
protected _charAtlas!: ITextureAtlas;
|
||||
protected _charAtlasDisposable = this.register(new MutableDisposable());
|
||||
|
||||
public get canvas(): HTMLCanvasElement { return this._canvas; }
|
||||
public get cacheCanvas(): HTMLCanvasElement { return this._charAtlas?.pages[0].canvas!; }
|
||||
|
||||
private readonly _onAddTextureAtlasCanvas = this.register(new EventEmitter<HTMLCanvasElement>());
|
||||
public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event;
|
||||
|
||||
constructor(
|
||||
private readonly _terminal: Terminal,
|
||||
private _container: HTMLElement,
|
||||
id: string,
|
||||
zIndex: number,
|
||||
private _alpha: boolean,
|
||||
protected readonly _themeService: IThemeService,
|
||||
protected readonly _bufferService: IBufferService,
|
||||
protected readonly _optionsService: IOptionsService,
|
||||
protected readonly _decorationService: IDecorationService,
|
||||
protected readonly _coreBrowserService: ICoreBrowserService
|
||||
) {
|
||||
super();
|
||||
this._cellColorResolver = new CellColorResolver(this._terminal, this._optionsService, this._selectionModel, this._decorationService, this._coreBrowserService, this._themeService);
|
||||
this._canvas = this._coreBrowserService.mainDocument.createElement('canvas');
|
||||
this._canvas.classList.add(`xterm-${id}-layer`);
|
||||
this._canvas.style.zIndex = zIndex.toString();
|
||||
this._initCanvas();
|
||||
this._container.appendChild(this._canvas);
|
||||
this._refreshCharAtlas(this._themeService.colors);
|
||||
this.register(this._themeService.onChangeColors(e => {
|
||||
this._refreshCharAtlas(e);
|
||||
this.reset();
|
||||
// Trigger selection changed as it's handled separately to regular rendering
|
||||
this.handleSelectionChanged(this._selectionModel.selectionStart, this._selectionModel.selectionEnd, this._selectionModel.columnSelectMode);
|
||||
}));
|
||||
|
||||
this.register(toDisposable(() => {
|
||||
this._canvas.remove();
|
||||
}));
|
||||
}
|
||||
|
||||
private _initCanvas(): void {
|
||||
this._ctx = throwIfFalsy(this._canvas.getContext('2d', { alpha: this._alpha }));
|
||||
// Draw the background if this is an opaque layer
|
||||
if (!this._alpha) {
|
||||
this._clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
public handleBlur(): void {}
|
||||
public handleFocus(): void {}
|
||||
public handleCursorMove(): void {}
|
||||
public handleGridChanged(startRow: number, endRow: number): void {}
|
||||
|
||||
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean = false): void {
|
||||
this._selectionModel.update((this._terminal as any)._core, start, end, columnSelectMode);
|
||||
}
|
||||
|
||||
protected _setTransparency(alpha: boolean): void {
|
||||
// Do nothing when alpha doesn't change
|
||||
if (alpha === this._alpha) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new canvas and replace old one
|
||||
const oldCanvas = this._canvas;
|
||||
this._alpha = alpha;
|
||||
// Cloning preserves properties
|
||||
this._canvas = this._canvas.cloneNode() as HTMLCanvasElement;
|
||||
this._initCanvas();
|
||||
this._container.replaceChild(this._canvas, oldCanvas);
|
||||
|
||||
// Regenerate char atlas and force a full redraw
|
||||
this._refreshCharAtlas(this._themeService.colors);
|
||||
this.handleGridChanged(0, this._bufferService.rows - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the char atlas, aquiring a new one if necessary.
|
||||
* @param colorSet The color set to use for the char atlas.
|
||||
*/
|
||||
private _refreshCharAtlas(colorSet: ReadonlyColorSet): void {
|
||||
if (this._deviceCharWidth <= 0 && this._deviceCharHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
this._charAtlas = acquireTextureAtlas(this._terminal, this._optionsService.rawOptions, colorSet, this._deviceCellWidth, this._deviceCellHeight, this._deviceCharWidth, this._deviceCharHeight, this._coreBrowserService.dpr);
|
||||
this._charAtlasDisposable.value = forwardEvent(this._charAtlas.onAddTextureAtlasCanvas, this._onAddTextureAtlasCanvas);
|
||||
this._charAtlas.warmUp();
|
||||
for (let i = 0; i < this._charAtlas.pages.length; i++) {
|
||||
this._bitmapGenerator[i] = new BitmapGenerator(this._charAtlas.pages[i].canvas);
|
||||
}
|
||||
}
|
||||
|
||||
public resize(dim: IRenderDimensions): void {
|
||||
this._deviceCellWidth = dim.device.cell.width;
|
||||
this._deviceCellHeight = dim.device.cell.height;
|
||||
this._deviceCharWidth = dim.device.char.width;
|
||||
this._deviceCharHeight = dim.device.char.height;
|
||||
this._deviceCharLeft = dim.device.char.left;
|
||||
this._deviceCharTop = dim.device.char.top;
|
||||
this._canvas.width = dim.device.canvas.width;
|
||||
this._canvas.height = dim.device.canvas.height;
|
||||
this._canvas.style.width = `${dim.css.canvas.width}px`;
|
||||
this._canvas.style.height = `${dim.css.canvas.height}px`;
|
||||
|
||||
// Draw the background if this is an opaque layer
|
||||
if (!this._alpha) {
|
||||
this._clearAll();
|
||||
}
|
||||
|
||||
this._refreshCharAtlas(this._themeService.colors);
|
||||
}
|
||||
|
||||
public abstract reset(): void;
|
||||
|
||||
public clearTextureAtlas(): void {
|
||||
this._charAtlas?.clearTexture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills 1+ cells completely. This uses the existing fillStyle on the context.
|
||||
* @param x The column to start at.
|
||||
* @param y The row to start at
|
||||
* @param width The number of columns to fill.
|
||||
* @param height The number of rows to fill.
|
||||
*/
|
||||
protected _fillCells(x: number, y: number, width: number, height: number): void {
|
||||
this._ctx.fillRect(
|
||||
x * this._deviceCellWidth,
|
||||
y * this._deviceCellHeight,
|
||||
width * this._deviceCellWidth,
|
||||
height * this._deviceCellHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills a 1px line (2px on HDPI) at the middle of the cell. This uses the
|
||||
* existing fillStyle on the context.
|
||||
* @param x The column to fill.
|
||||
* @param y The row to fill.
|
||||
*/
|
||||
protected _fillMiddleLineAtCells(x: number, y: number, width: number = 1): void {
|
||||
const cellOffset = Math.ceil(this._deviceCellHeight * 0.5);
|
||||
this._ctx.fillRect(
|
||||
x * this._deviceCellWidth,
|
||||
(y + 1) * this._deviceCellHeight - cellOffset - this._coreBrowserService.dpr,
|
||||
width * this._deviceCellWidth,
|
||||
this._coreBrowserService.dpr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills a 1px line (2px on HDPI) at the bottom of the cell. This uses the
|
||||
* existing fillStyle on the context.
|
||||
* @param x The column to fill.
|
||||
* @param y The row to fill.
|
||||
*/
|
||||
protected _fillBottomLineAtCells(x: number, y: number, width: number = 1, pixelOffset: number = 0): void {
|
||||
this._ctx.fillRect(
|
||||
x * this._deviceCellWidth,
|
||||
(y + 1) * this._deviceCellHeight + pixelOffset - this._coreBrowserService.dpr - 1 /* Ensure it's drawn within the cell */,
|
||||
width * this._deviceCellWidth,
|
||||
this._coreBrowserService.dpr);
|
||||
}
|
||||
|
||||
protected _curlyUnderlineAtCell(x: number, y: number, width: number = 1): void {
|
||||
this._ctx.save();
|
||||
this._ctx.beginPath();
|
||||
this._ctx.strokeStyle = this._ctx.fillStyle;
|
||||
const lineWidth = this._coreBrowserService.dpr;
|
||||
this._ctx.lineWidth = lineWidth;
|
||||
for (let xOffset = 0; xOffset < width; xOffset++) {
|
||||
const xLeft = (x + xOffset) * this._deviceCellWidth;
|
||||
const xMid = (x + xOffset + 0.5) * this._deviceCellWidth;
|
||||
const xRight = (x + xOffset + 1) * this._deviceCellWidth;
|
||||
const yMid = (y + 1) * this._deviceCellHeight - lineWidth - 1;
|
||||
const yMidBot = yMid - lineWidth;
|
||||
const yMidTop = yMid + lineWidth;
|
||||
this._ctx.moveTo(xLeft, yMid);
|
||||
this._ctx.bezierCurveTo(
|
||||
xLeft, yMidBot,
|
||||
xMid, yMidBot,
|
||||
xMid, yMid
|
||||
);
|
||||
this._ctx.bezierCurveTo(
|
||||
xMid, yMidTop,
|
||||
xRight, yMidTop,
|
||||
xRight, yMid
|
||||
);
|
||||
}
|
||||
this._ctx.stroke();
|
||||
this._ctx.restore();
|
||||
}
|
||||
|
||||
protected _dottedUnderlineAtCell(x: number, y: number, width: number = 1): void {
|
||||
this._ctx.save();
|
||||
this._ctx.beginPath();
|
||||
this._ctx.strokeStyle = this._ctx.fillStyle;
|
||||
const lineWidth = this._coreBrowserService.dpr;
|
||||
this._ctx.lineWidth = lineWidth;
|
||||
this._ctx.setLineDash([lineWidth * 2, lineWidth]);
|
||||
const xLeft = x * this._deviceCellWidth;
|
||||
const yMid = (y + 1) * this._deviceCellHeight - lineWidth - 1;
|
||||
this._ctx.moveTo(xLeft, yMid);
|
||||
for (let xOffset = 0; xOffset < width; xOffset++) {
|
||||
// const xLeft = x * this._deviceCellWidth;
|
||||
const xRight = (x + width + xOffset) * this._deviceCellWidth;
|
||||
this._ctx.lineTo(xRight, yMid);
|
||||
}
|
||||
this._ctx.stroke();
|
||||
this._ctx.closePath();
|
||||
this._ctx.restore();
|
||||
}
|
||||
|
||||
protected _dashedUnderlineAtCell(x: number, y: number, width: number = 1): void {
|
||||
this._ctx.save();
|
||||
this._ctx.beginPath();
|
||||
this._ctx.strokeStyle = this._ctx.fillStyle;
|
||||
const lineWidth = this._coreBrowserService.dpr;
|
||||
this._ctx.lineWidth = lineWidth;
|
||||
this._ctx.setLineDash([lineWidth * 4, lineWidth * 3]);
|
||||
const xLeft = x * this._deviceCellWidth;
|
||||
const xRight = (x + width) * this._deviceCellWidth;
|
||||
const yMid = (y + 1) * this._deviceCellHeight - lineWidth - 1;
|
||||
this._ctx.moveTo(xLeft, yMid);
|
||||
this._ctx.lineTo(xRight, yMid);
|
||||
this._ctx.stroke();
|
||||
this._ctx.closePath();
|
||||
this._ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills a 1px line (2px on HDPI) at the left of the cell. This uses the
|
||||
* existing fillStyle on the context.
|
||||
* @param x The column to fill.
|
||||
* @param y The row to fill.
|
||||
*/
|
||||
protected _fillLeftLineAtCell(x: number, y: number, width: number): void {
|
||||
this._ctx.fillRect(
|
||||
x * this._deviceCellWidth,
|
||||
y * this._deviceCellHeight,
|
||||
this._coreBrowserService.dpr * width,
|
||||
this._deviceCellHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strokes a 1px rectangle (2px on HDPI) around a cell. This uses the existing
|
||||
* strokeStyle on the context.
|
||||
* @param x The column to fill.
|
||||
* @param y The row to fill.
|
||||
*/
|
||||
protected _strokeRectAtCell(x: number, y: number, width: number, height: number): void {
|
||||
const lineWidth = this._coreBrowserService.dpr;
|
||||
this._ctx.lineWidth = lineWidth;
|
||||
this._ctx.strokeRect(
|
||||
x * this._deviceCellWidth + lineWidth / 2,
|
||||
y * this._deviceCellHeight + (lineWidth / 2),
|
||||
width * this._deviceCellWidth - lineWidth,
|
||||
(height * this._deviceCellHeight) - lineWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the entire canvas.
|
||||
*/
|
||||
protected _clearAll(): void {
|
||||
if (this._alpha) {
|
||||
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
} else {
|
||||
this._ctx.fillStyle = this._themeService.colors.background.css;
|
||||
this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears 1+ cells completely.
|
||||
* @param x The column to start at.
|
||||
* @param y The row to start at.
|
||||
* @param width The number of columns to clear.
|
||||
* @param height The number of rows to clear.
|
||||
*/
|
||||
protected _clearCells(x: number, y: number, width: number, height: number): void {
|
||||
if (this._alpha) {
|
||||
this._ctx.clearRect(
|
||||
x * this._deviceCellWidth,
|
||||
y * this._deviceCellHeight,
|
||||
width * this._deviceCellWidth,
|
||||
height * this._deviceCellHeight);
|
||||
} else {
|
||||
this._ctx.fillStyle = this._themeService.colors.background.css;
|
||||
this._ctx.fillRect(
|
||||
x * this._deviceCellWidth,
|
||||
y * this._deviceCellHeight,
|
||||
width * this._deviceCellWidth,
|
||||
height * this._deviceCellHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a truecolor character at the cell. The character will be clipped to
|
||||
* ensure that it fits with the cell, including the cell to the right if it's
|
||||
* a wide character. This uses the existing fillStyle on the context.
|
||||
* @param cell The cell data for the character to draw.
|
||||
* @param x The column to draw at.
|
||||
* @param y The row to draw at.
|
||||
*/
|
||||
protected _fillCharTrueColor(cell: CellData, x: number, y: number): void {
|
||||
this._ctx.font = this._getFont(false, false);
|
||||
this._ctx.textBaseline = TEXT_BASELINE;
|
||||
this._clipRow(y);
|
||||
|
||||
// Draw custom characters if applicable
|
||||
let drawSuccess = false;
|
||||
if (this._optionsService.rawOptions.customGlyphs !== false) {
|
||||
drawSuccess = tryDrawCustomChar(this._ctx, cell.getChars(), x * this._deviceCellWidth, y * this._deviceCellHeight, this._deviceCellWidth, this._deviceCellHeight, this._optionsService.rawOptions.fontSize, this._coreBrowserService.dpr);
|
||||
}
|
||||
|
||||
// Draw the character
|
||||
if (!drawSuccess) {
|
||||
this._ctx.fillText(
|
||||
cell.getChars(),
|
||||
x * this._deviceCellWidth + this._deviceCharLeft,
|
||||
y * this._deviceCellHeight + this._deviceCharTop + this._deviceCharHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws one or more characters at a cell. If possible this will draw using
|
||||
* the character atlas to reduce draw time.
|
||||
*/
|
||||
protected _drawChars(cell: ICellData, x: number, y: number): void {
|
||||
const chars = cell.getChars();
|
||||
this._cellColorResolver.resolve(cell, x, this._bufferService.buffer.ydisp + y, this._deviceCellWidth);
|
||||
|
||||
if (!this._charAtlas) {
|
||||
return;
|
||||
}
|
||||
|
||||
let glyph: IRasterizedGlyph;
|
||||
if (chars && chars.length > 1) {
|
||||
glyph = this._charAtlas.getRasterizedGlyphCombinedChar(chars, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, true);
|
||||
} else {
|
||||
glyph = this._charAtlas.getRasterizedGlyph(cell.getCode() || WHITESPACE_CELL_CODE, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, true);
|
||||
}
|
||||
if (!glyph.size.x || !glyph.size.y) {
|
||||
return;
|
||||
}
|
||||
this._ctx.save();
|
||||
this._clipRow(y);
|
||||
|
||||
// Draw the image, use the bitmap if it's available
|
||||
|
||||
// HACK: If the canvas doesn't match, delete the generator. It's not clear how this happens but
|
||||
// something is wrong with either the lifecycle of _bitmapGenerator or the page canvases are
|
||||
// swapped out unexpectedly
|
||||
if (this._bitmapGenerator[glyph.texturePage] && this._charAtlas.pages[glyph.texturePage].canvas !== this._bitmapGenerator[glyph.texturePage]!.canvas) {
|
||||
this._bitmapGenerator[glyph.texturePage]?.bitmap?.close();
|
||||
delete this._bitmapGenerator[glyph.texturePage];
|
||||
}
|
||||
|
||||
if (this._charAtlas.pages[glyph.texturePage].version !== this._bitmapGenerator[glyph.texturePage]?.version) {
|
||||
if (!this._bitmapGenerator[glyph.texturePage]) {
|
||||
this._bitmapGenerator[glyph.texturePage] = new BitmapGenerator(this._charAtlas.pages[glyph.texturePage].canvas);
|
||||
}
|
||||
this._bitmapGenerator[glyph.texturePage]!.refresh();
|
||||
this._bitmapGenerator[glyph.texturePage]!.version = this._charAtlas.pages[glyph.texturePage].version;
|
||||
}
|
||||
this._ctx.drawImage(
|
||||
this._bitmapGenerator[glyph.texturePage]?.bitmap || this._charAtlas!.pages[glyph.texturePage].canvas,
|
||||
glyph.texturePosition.x,
|
||||
glyph.texturePosition.y,
|
||||
glyph.size.x,
|
||||
glyph.size.y,
|
||||
x * this._deviceCellWidth + this._deviceCharLeft - glyph.offset.x,
|
||||
y * this._deviceCellHeight + this._deviceCharTop - glyph.offset.y,
|
||||
glyph.size.x,
|
||||
glyph.size.y
|
||||
);
|
||||
this._ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clips a row to ensure no pixels will be drawn outside the cells in the row.
|
||||
* @param y The row to clip.
|
||||
*/
|
||||
private _clipRow(y: number): void {
|
||||
this._ctx.beginPath();
|
||||
this._ctx.rect(
|
||||
0,
|
||||
y * this._deviceCellHeight,
|
||||
this._bufferService.cols * this._deviceCellWidth,
|
||||
this._deviceCellHeight);
|
||||
this._ctx.clip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current font.
|
||||
* @param isBold If we should use the bold fontWeight.
|
||||
*/
|
||||
protected _getFont(isBold: boolean, isItalic: boolean): string {
|
||||
const fontWeight = isBold ? this._optionsService.rawOptions.fontWeightBold : this._optionsService.rawOptions.fontWeight;
|
||||
const fontStyle = isItalic ? 'italic' : '';
|
||||
|
||||
return `${fontStyle} ${fontWeight} ${this._optionsService.rawOptions.fontSize * this._coreBrowserService.dpr}px ${this._optionsService.rawOptions.fontFamily}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of milliseconds to wait before generating the ImageBitmap, this is to debounce/batch
|
||||
* the operation as window.createImageBitmap is asynchronous.
|
||||
*/
|
||||
const GLYPH_BITMAP_COMMIT_DELAY = 100;
|
||||
|
||||
const enum BitmapGeneratorState {
|
||||
IDLE = 0,
|
||||
GENERATING = 1,
|
||||
GENERATING_INVALID = 2
|
||||
}
|
||||
|
||||
class BitmapGenerator {
|
||||
private _state: BitmapGeneratorState = BitmapGeneratorState.IDLE;
|
||||
private _commitTimeout: number | undefined = undefined;
|
||||
private _bitmap: ImageBitmap | undefined = undefined;
|
||||
public get bitmap(): ImageBitmap | undefined { return this._bitmap; }
|
||||
public version: number = -1;
|
||||
|
||||
constructor(public readonly canvas: HTMLCanvasElement) {
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
// Clear the bitmap immediately as it's stale
|
||||
this._bitmap?.close();
|
||||
this._bitmap = undefined;
|
||||
// Disable ImageBitmaps on Safari because of https://bugs.webkit.org/show_bug.cgi?id=149990
|
||||
if (isSafari) {
|
||||
return;
|
||||
}
|
||||
if (this._commitTimeout === undefined) {
|
||||
this._commitTimeout = window.setTimeout(() => this._generate(), GLYPH_BITMAP_COMMIT_DELAY);
|
||||
}
|
||||
if (this._state === BitmapGeneratorState.GENERATING) {
|
||||
this._state = BitmapGeneratorState.GENERATING_INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
private _generate(): void {
|
||||
if (this._state === BitmapGeneratorState.IDLE) {
|
||||
this._bitmap?.close();
|
||||
this._bitmap = undefined;
|
||||
this._state = BitmapGeneratorState.GENERATING;
|
||||
window.createImageBitmap(this.canvas).then(bitmap => {
|
||||
if (this._state === BitmapGeneratorState.GENERATING_INVALID) {
|
||||
this.refresh();
|
||||
} else {
|
||||
this._bitmap = bitmap;
|
||||
}
|
||||
this._state = BitmapGeneratorState.IDLE;
|
||||
});
|
||||
if (this._commitTimeout) {
|
||||
this._commitTimeout = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||
import type { CanvasAddon as ICanvasApi } from '@xterm/addon-canvas';
|
||||
import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||
import { ITerminal } from 'browser/Types';
|
||||
import { EventEmitter, forwardEvent } from 'common/EventEmitter';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { setTraceLogger } from 'common/services/LogService';
|
||||
import { IBufferService, IDecorationService, ILogService } from 'common/services/Services';
|
||||
import { CanvasRenderer } from './CanvasRenderer';
|
||||
|
||||
export class CanvasAddon extends Disposable implements ITerminalAddon , ICanvasApi {
|
||||
private _terminal?: Terminal;
|
||||
private _renderer?: CanvasRenderer;
|
||||
|
||||
private readonly _onChangeTextureAtlas = this.register(new EventEmitter<HTMLCanvasElement>());
|
||||
public readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event;
|
||||
private readonly _onAddTextureAtlasCanvas = this.register(new EventEmitter<HTMLCanvasElement>());
|
||||
public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event;
|
||||
|
||||
public get textureAtlas(): HTMLCanvasElement | undefined {
|
||||
return this._renderer?.textureAtlas;
|
||||
}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
const core = (terminal as any)._core as ITerminal;
|
||||
if (!terminal.element) {
|
||||
this.register(core.onWillOpen(() => this.activate(terminal)));
|
||||
return;
|
||||
}
|
||||
|
||||
this._terminal = terminal;
|
||||
const coreService = core.coreService;
|
||||
const optionsService = core.optionsService;
|
||||
const screenElement = core.screenElement!;
|
||||
const linkifier = core.linkifier!;
|
||||
|
||||
const unsafeCore = core as any;
|
||||
const bufferService: IBufferService = unsafeCore._bufferService;
|
||||
const renderService: IRenderService = unsafeCore._renderService;
|
||||
const characterJoinerService: ICharacterJoinerService = unsafeCore._characterJoinerService;
|
||||
const charSizeService: ICharSizeService = unsafeCore._charSizeService;
|
||||
const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService;
|
||||
const decorationService: IDecorationService = unsafeCore._decorationService;
|
||||
const logService: ILogService = unsafeCore._logService;
|
||||
const themeService: IThemeService = unsafeCore._themeService;
|
||||
|
||||
// Set trace logger just in case it hasn't been yet which could happen when the addon is
|
||||
// bundled separately to the core module
|
||||
setTraceLogger(logService);
|
||||
|
||||
this._renderer = new CanvasRenderer(terminal, screenElement, linkifier, bufferService, charSizeService, optionsService, characterJoinerService, coreService, coreBrowserService, decorationService, themeService);
|
||||
this.register(forwardEvent(this._renderer.onChangeTextureAtlas, this._onChangeTextureAtlas));
|
||||
this.register(forwardEvent(this._renderer.onAddTextureAtlasCanvas, this._onAddTextureAtlasCanvas));
|
||||
renderService.setRenderer(this._renderer);
|
||||
renderService.handleResize(bufferService.cols, bufferService.rows);
|
||||
|
||||
this.register(toDisposable(() => {
|
||||
renderService.setRenderer((this._terminal as any)._core._createRenderer());
|
||||
renderService.handleResize(terminal.cols, terminal.rows);
|
||||
this._renderer?.dispose();
|
||||
this._renderer = undefined;
|
||||
}));
|
||||
}
|
||||
|
||||
public clearTextureAtlas(): void {
|
||||
this._renderer?.clearTextureAtlas();
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ILinkifier2 } from 'browser/Types';
|
||||
import { removeTerminalFromCache } from 'browser/renderer/shared/CharAtlasCache';
|
||||
import { observeDevicePixelDimensions } from 'browser/renderer/shared/DevicePixelObserver';
|
||||
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
|
||||
import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types';
|
||||
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
||||
import { EventEmitter, forwardEvent } from 'common/EventEmitter';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { CursorRenderLayer } from './CursorRenderLayer';
|
||||
import { LinkRenderLayer } from './LinkRenderLayer';
|
||||
import { SelectionRenderLayer } from './SelectionRenderLayer';
|
||||
import { TextRenderLayer } from './TextRenderLayer';
|
||||
import { IRenderLayer } from './Types';
|
||||
|
||||
export class CanvasRenderer extends Disposable implements IRenderer {
|
||||
private _renderLayers: IRenderLayer[];
|
||||
private _devicePixelRatio: number;
|
||||
private _observerDisposable = this.register(new MutableDisposable());
|
||||
|
||||
public dimensions: IRenderDimensions;
|
||||
|
||||
private readonly _onRequestRedraw = this.register(new EventEmitter<IRequestRedrawEvent>());
|
||||
public readonly onRequestRedraw = this._onRequestRedraw.event;
|
||||
private readonly _onChangeTextureAtlas = this.register(new EventEmitter<HTMLCanvasElement>());
|
||||
public readonly onChangeTextureAtlas = this._onChangeTextureAtlas.event;
|
||||
private readonly _onAddTextureAtlasCanvas = this.register(new EventEmitter<HTMLCanvasElement>());
|
||||
public readonly onAddTextureAtlasCanvas = this._onAddTextureAtlasCanvas.event;
|
||||
|
||||
constructor(
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _screenElement: HTMLElement,
|
||||
linkifier2: ILinkifier2,
|
||||
private readonly _bufferService: IBufferService,
|
||||
private readonly _charSizeService: ICharSizeService,
|
||||
private readonly _optionsService: IOptionsService,
|
||||
characterJoinerService: ICharacterJoinerService,
|
||||
coreService: ICoreService,
|
||||
private readonly _coreBrowserService: ICoreBrowserService,
|
||||
decorationService: IDecorationService,
|
||||
private readonly _themeService: IThemeService
|
||||
) {
|
||||
super();
|
||||
const allowTransparency = this._optionsService.rawOptions.allowTransparency;
|
||||
this._renderLayers = [
|
||||
new TextRenderLayer(this._terminal, this._screenElement, 0, allowTransparency, this._bufferService, this._optionsService, characterJoinerService, decorationService, this._coreBrowserService, _themeService),
|
||||
new SelectionRenderLayer(this._terminal, this._screenElement, 1, this._bufferService, this._coreBrowserService, decorationService, this._optionsService, _themeService),
|
||||
new LinkRenderLayer(this._terminal, this._screenElement, 2, linkifier2, this._bufferService, this._optionsService, decorationService, this._coreBrowserService, _themeService),
|
||||
new CursorRenderLayer(this._terminal, this._screenElement, 3, this._onRequestRedraw, this._bufferService, this._optionsService, coreService, this._coreBrowserService, decorationService, _themeService)
|
||||
];
|
||||
for (const layer of this._renderLayers) {
|
||||
forwardEvent(layer.onAddTextureAtlasCanvas, this._onAddTextureAtlasCanvas);
|
||||
}
|
||||
this.dimensions = createRenderDimensions();
|
||||
this._devicePixelRatio = this._coreBrowserService.dpr;
|
||||
this._updateDimensions();
|
||||
|
||||
this._observerDisposable.value = observeDevicePixelDimensions(this._renderLayers[0].canvas, this._coreBrowserService.window, (w, h) => this._setCanvasDevicePixelDimensions(w, h));
|
||||
this.register(this._coreBrowserService.onWindowChange(w => {
|
||||
this._observerDisposable.value = observeDevicePixelDimensions(this._renderLayers[0].canvas, w, (w, h) => this._setCanvasDevicePixelDimensions(w, h));
|
||||
}));
|
||||
|
||||
this.register(toDisposable(() => {
|
||||
for (const l of this._renderLayers) {
|
||||
l.dispose();
|
||||
}
|
||||
removeTerminalFromCache(this._terminal);
|
||||
}));
|
||||
}
|
||||
|
||||
public get textureAtlas(): HTMLCanvasElement | undefined {
|
||||
return this._renderLayers[0].cacheCanvas;
|
||||
}
|
||||
|
||||
public handleDevicePixelRatioChange(): void {
|
||||
// If the device pixel ratio changed, the char atlas needs to be regenerated
|
||||
// and the terminal needs to refreshed
|
||||
if (this._devicePixelRatio !== this._coreBrowserService.dpr) {
|
||||
this._devicePixelRatio = this._coreBrowserService.dpr;
|
||||
this.handleResize(this._bufferService.cols, this._bufferService.rows);
|
||||
}
|
||||
}
|
||||
|
||||
public handleResize(cols: number, rows: number): void {
|
||||
// Update character and canvas dimensions
|
||||
this._updateDimensions();
|
||||
|
||||
// Resize all render layers
|
||||
for (const l of this._renderLayers) {
|
||||
l.resize(this.dimensions);
|
||||
}
|
||||
|
||||
// Resize the screen
|
||||
this._screenElement.style.width = `${this.dimensions.css.canvas.width}px`;
|
||||
this._screenElement.style.height = `${this.dimensions.css.canvas.height}px`;
|
||||
}
|
||||
|
||||
public handleCharSizeChanged(): void {
|
||||
this.handleResize(this._bufferService.cols, this._bufferService.rows);
|
||||
}
|
||||
|
||||
public handleBlur(): void {
|
||||
this._runOperation(l => l.handleBlur());
|
||||
}
|
||||
|
||||
public handleFocus(): void {
|
||||
this._runOperation(l => l.handleFocus());
|
||||
}
|
||||
|
||||
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean = false): void {
|
||||
this._runOperation(l => l.handleSelectionChanged(start, end, columnSelectMode));
|
||||
// Selection foreground requires a full re-render
|
||||
if (this._themeService.colors.selectionForeground) {
|
||||
this._onRequestRedraw.fire({ start: 0, end: this._bufferService.rows - 1 });
|
||||
}
|
||||
}
|
||||
|
||||
public handleCursorMove(): void {
|
||||
this._runOperation(l => l.handleCursorMove());
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._runOperation(l => l.reset());
|
||||
}
|
||||
|
||||
private _runOperation(operation: (layer: IRenderLayer) => void): void {
|
||||
for (const l of this._renderLayers) {
|
||||
operation(l);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the refresh loop callback, calling refresh only if a refresh is
|
||||
* necessary before queueing up the next one.
|
||||
*/
|
||||
public renderRows(start: number, end: number): void {
|
||||
for (const l of this._renderLayers) {
|
||||
l.handleGridChanged(start, end);
|
||||
}
|
||||
}
|
||||
|
||||
public clearTextureAtlas(): void {
|
||||
for (const layer of this._renderLayers) {
|
||||
layer.clearTextureAtlas();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculates the character and canvas dimensions.
|
||||
*/
|
||||
private _updateDimensions(): void {
|
||||
if (!this._charSizeService.hasValidSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
// See the WebGL renderer for an explanation of this section.
|
||||
const dpr = this._coreBrowserService.dpr;
|
||||
this.dimensions.device.char.width = Math.floor(this._charSizeService.width * dpr);
|
||||
this.dimensions.device.char.height = Math.ceil(this._charSizeService.height * dpr);
|
||||
this.dimensions.device.cell.height = Math.floor(this.dimensions.device.char.height * this._optionsService.rawOptions.lineHeight);
|
||||
this.dimensions.device.char.top = this._optionsService.rawOptions.lineHeight === 1 ? 0 : Math.round((this.dimensions.device.cell.height - this.dimensions.device.char.height) / 2);
|
||||
this.dimensions.device.cell.width = this.dimensions.device.char.width + Math.round(this._optionsService.rawOptions.letterSpacing);
|
||||
this.dimensions.device.char.left = Math.floor(this._optionsService.rawOptions.letterSpacing / 2);
|
||||
this.dimensions.device.canvas.height = this._bufferService.rows * this.dimensions.device.cell.height;
|
||||
this.dimensions.device.canvas.width = this._bufferService.cols * this.dimensions.device.cell.width;
|
||||
this.dimensions.css.canvas.height = Math.round(this.dimensions.device.canvas.height / dpr);
|
||||
this.dimensions.css.canvas.width = Math.round(this.dimensions.device.canvas.width / dpr);
|
||||
this.dimensions.css.cell.height = this.dimensions.css.canvas.height / this._bufferService.rows;
|
||||
this.dimensions.css.cell.width = this.dimensions.css.canvas.width / this._bufferService.cols;
|
||||
}
|
||||
|
||||
private _setCanvasDevicePixelDimensions(width: number, height: number): void {
|
||||
this.dimensions.device.canvas.height = height;
|
||||
this.dimensions.device.canvas.width = width;
|
||||
// Resize all render layers
|
||||
for (const l of this._renderLayers) {
|
||||
l.resize(this.dimensions);
|
||||
}
|
||||
this._requestRedrawViewport();
|
||||
}
|
||||
|
||||
private _requestRedrawViewport(): void {
|
||||
this._onRequestRedraw.fire({ start: 0, end: this._bufferService.rows - 1 });
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { CursorBlinkStateManager } from 'browser/renderer/shared/CursorBlinkStateManager';
|
||||
import { IRenderDimensions, IRequestRedrawEvent } from 'browser/renderer/shared/Types';
|
||||
import { ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
||||
import { IEventEmitter } from 'common/EventEmitter';
|
||||
import { MutableDisposable } from 'common/Lifecycle';
|
||||
import { isFirefox } from 'common/Platform';
|
||||
import { ICellData } from 'common/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { BaseRenderLayer } from './BaseRenderLayer';
|
||||
|
||||
interface ICursorState {
|
||||
x: number;
|
||||
y: number;
|
||||
isFocused: boolean;
|
||||
style: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export class CursorRenderLayer extends BaseRenderLayer {
|
||||
private _state: ICursorState;
|
||||
private _cursorRenderers: {[key: string]: (x: number, y: number, cell: ICellData) => void};
|
||||
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this.register(new MutableDisposable());
|
||||
private _cell: ICellData = new CellData();
|
||||
|
||||
constructor(
|
||||
terminal: Terminal,
|
||||
container: HTMLElement,
|
||||
zIndex: number,
|
||||
private readonly _onRequestRedraw: IEventEmitter<IRequestRedrawEvent>,
|
||||
bufferService: IBufferService,
|
||||
optionsService: IOptionsService,
|
||||
private readonly _coreService: ICoreService,
|
||||
coreBrowserService: ICoreBrowserService,
|
||||
decorationService: IDecorationService,
|
||||
themeService: IThemeService
|
||||
) {
|
||||
super(terminal, container, 'cursor', zIndex, true, themeService, bufferService, optionsService, decorationService, coreBrowserService);
|
||||
this._state = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
isFocused: false,
|
||||
style: '',
|
||||
width: 0
|
||||
};
|
||||
this._cursorRenderers = {
|
||||
'bar': this._renderBarCursor.bind(this),
|
||||
'block': this._renderBlockCursor.bind(this),
|
||||
'underline': this._renderUnderlineCursor.bind(this),
|
||||
'outline': this._renderOutlineCursor.bind(this)
|
||||
};
|
||||
this.register(optionsService.onOptionChange(() => this._handleOptionsChanged()));
|
||||
this._handleOptionsChanged();
|
||||
}
|
||||
|
||||
public resize(dim: IRenderDimensions): void {
|
||||
super.resize(dim);
|
||||
// Resizing the canvas discards the contents of the canvas so clear state
|
||||
this._state = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
isFocused: false,
|
||||
style: '',
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._clearCursor();
|
||||
this._cursorBlinkStateManager.value?.restartBlinkAnimation();
|
||||
this._handleOptionsChanged();
|
||||
}
|
||||
|
||||
public handleBlur(): void {
|
||||
this._cursorBlinkStateManager.value?.pause();
|
||||
this._onRequestRedraw.fire({ start: this._bufferService.buffer.y, end: this._bufferService.buffer.y });
|
||||
}
|
||||
|
||||
public handleFocus(): void {
|
||||
this._cursorBlinkStateManager.value?.resume();
|
||||
this._onRequestRedraw.fire({ start: this._bufferService.buffer.y, end: this._bufferService.buffer.y });
|
||||
}
|
||||
|
||||
private _handleOptionsChanged(): void {
|
||||
if (this._optionsService.rawOptions.cursorBlink) {
|
||||
if (!this._cursorBlinkStateManager.value) {
|
||||
this._cursorBlinkStateManager.value = new CursorBlinkStateManager(() => this._render(true), this._coreBrowserService);
|
||||
}
|
||||
} else {
|
||||
this._cursorBlinkStateManager.clear();
|
||||
}
|
||||
// Request a refresh from the terminal as management of rendering is being
|
||||
// moved back to the terminal
|
||||
this._onRequestRedraw.fire({ start: this._bufferService.buffer.y, end: this._bufferService.buffer.y });
|
||||
}
|
||||
|
||||
public handleCursorMove(): void {
|
||||
this._cursorBlinkStateManager.value?.restartBlinkAnimation();
|
||||
}
|
||||
|
||||
public handleGridChanged(startRow: number, endRow: number): void {
|
||||
if (!this._cursorBlinkStateManager.value || this._cursorBlinkStateManager.value.isPaused) {
|
||||
this._render(false);
|
||||
} else {
|
||||
this._cursorBlinkStateManager.value.restartBlinkAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
private _render(triggeredByAnimationFrame: boolean): void {
|
||||
// Don't draw the cursor if it's hidden
|
||||
if (!this._coreService.isCursorInitialized || this._coreService.isCursorHidden) {
|
||||
this._clearCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorY = this._bufferService.buffer.ybase + this._bufferService.buffer.y;
|
||||
const viewportRelativeCursorY = cursorY - this._bufferService.buffer.ydisp;
|
||||
|
||||
// Don't draw the cursor if it's off-screen
|
||||
if (viewportRelativeCursorY < 0 || viewportRelativeCursorY >= this._bufferService.rows) {
|
||||
this._clearCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
// in case cursor.x == cols adjust visual cursor to cols - 1
|
||||
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
|
||||
this._bufferService.buffer.lines.get(cursorY)!.loadCell(cursorX, this._cell);
|
||||
if (this._cell.content === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._coreBrowserService.isFocused) {
|
||||
this._clearCursor();
|
||||
this._ctx.save();
|
||||
this._ctx.fillStyle = this._themeService.colors.cursor.css;
|
||||
const cursorStyle = this._optionsService.rawOptions.cursorStyle;
|
||||
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
|
||||
if (cursorInactiveStyle && cursorInactiveStyle !== 'none') {
|
||||
this._cursorRenderers[cursorInactiveStyle](cursorX, viewportRelativeCursorY, this._cell);
|
||||
}
|
||||
this._ctx.restore();
|
||||
this._state.x = cursorX;
|
||||
this._state.y = viewportRelativeCursorY;
|
||||
this._state.isFocused = false;
|
||||
this._state.style = cursorStyle;
|
||||
this._state.width = this._cell.getWidth();
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't draw the cursor if it's blinking
|
||||
if (this._cursorBlinkStateManager.value && !this._cursorBlinkStateManager.value.isCursorVisible) {
|
||||
this._clearCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._state) {
|
||||
// The cursor is already in the correct spot, don't redraw
|
||||
if (this._state.x === cursorX &&
|
||||
this._state.y === viewportRelativeCursorY &&
|
||||
this._state.isFocused === this._coreBrowserService.isFocused &&
|
||||
this._state.style === this._optionsService.rawOptions.cursorStyle &&
|
||||
this._state.width === this._cell.getWidth()) {
|
||||
return;
|
||||
}
|
||||
this._clearCursor();
|
||||
}
|
||||
|
||||
this._ctx.save();
|
||||
this._cursorRenderers[this._optionsService.rawOptions.cursorStyle || 'block'](cursorX, viewportRelativeCursorY, this._cell);
|
||||
this._ctx.restore();
|
||||
|
||||
this._state.x = cursorX;
|
||||
this._state.y = viewportRelativeCursorY;
|
||||
this._state.isFocused = false;
|
||||
this._state.style = this._optionsService.rawOptions.cursorStyle;
|
||||
this._state.width = this._cell.getWidth();
|
||||
}
|
||||
|
||||
private _clearCursor(): void {
|
||||
if (this._state) {
|
||||
// Avoid potential rounding errors when browser is Firefox (#4487) or device pixel ratio is
|
||||
// less than 1
|
||||
if (isFirefox || this._coreBrowserService.dpr < 1) {
|
||||
this._clearAll();
|
||||
} else {
|
||||
this._clearCells(this._state.x, this._state.y, this._state.width, 1);
|
||||
}
|
||||
this._state = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
isFocused: false,
|
||||
style: '',
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _renderBarCursor(x: number, y: number, cell: ICellData): void {
|
||||
this._ctx.save();
|
||||
this._ctx.fillStyle = this._themeService.colors.cursor.css;
|
||||
this._fillLeftLineAtCell(x, y, this._optionsService.rawOptions.cursorWidth);
|
||||
this._ctx.restore();
|
||||
}
|
||||
|
||||
private _renderBlockCursor(x: number, y: number, cell: ICellData): void {
|
||||
this._ctx.save();
|
||||
this._ctx.fillStyle = this._themeService.colors.cursor.css;
|
||||
this._fillCells(x, y, cell.getWidth(), 1);
|
||||
this._ctx.fillStyle = this._themeService.colors.cursorAccent.css;
|
||||
this._fillCharTrueColor(cell, x, y);
|
||||
this._ctx.restore();
|
||||
}
|
||||
|
||||
private _renderUnderlineCursor(x: number, y: number, cell: ICellData): void {
|
||||
this._ctx.save();
|
||||
this._ctx.fillStyle = this._themeService.colors.cursor.css;
|
||||
this._fillBottomLineAtCells(x, y);
|
||||
this._ctx.restore();
|
||||
}
|
||||
|
||||
private _renderOutlineCursor(x: number, y: number, cell: ICellData): void {
|
||||
this._ctx.save();
|
||||
this._ctx.strokeStyle = this._themeService.colors.cursor.css;
|
||||
this._strokeRectAtCell(x, y, cell.getWidth(), 1);
|
||||
this._ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { GridCache } from './GridCache';
|
||||
|
||||
describe('GridCache', () => {
|
||||
let grid: GridCache<number>;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = new GridCache<number>();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create an empty cache', () => {
|
||||
assert.equal(grid.cache.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize', () => {
|
||||
it('should fill all new elements with null', () => {
|
||||
grid.resize(2, 2);
|
||||
assert.equal(grid.cache.length, 2);
|
||||
assert.equal(grid.cache[0].length, 2);
|
||||
assert.equal(grid.cache[0][0], null);
|
||||
assert.equal(grid.cache[0][1], null);
|
||||
assert.equal(grid.cache[1].length, 2);
|
||||
assert.equal(grid.cache[1][0], null);
|
||||
assert.equal(grid.cache[1][1], null);
|
||||
grid.resize(3, 2);
|
||||
assert.equal(grid.cache.length, 3);
|
||||
assert.equal(grid.cache[2][0], null);
|
||||
assert.equal(grid.cache[2][1], null);
|
||||
});
|
||||
|
||||
it('should remove rows/cols from the cache when reduced', () => {
|
||||
grid.resize(2, 2);
|
||||
grid.resize(1, 1);
|
||||
assert.equal(grid.cache.length, 1);
|
||||
assert.equal(grid.cache[0].length, 1);
|
||||
});
|
||||
|
||||
it('should not touch existing cache entries if they fit in the new cache', () => {
|
||||
grid.resize(1, 1);
|
||||
assert.equal(grid.cache[0][0], null);
|
||||
grid.cache[0][0] = 1;
|
||||
grid.resize(2, 1);
|
||||
assert.equal(grid.cache[0][0], 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should make all entries null', () => {
|
||||
grid.resize(1, 1);
|
||||
grid.cache[0][0] = 1;
|
||||
grid.clear();
|
||||
assert.equal(grid.cache[0][0], null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
export class GridCache<T> {
|
||||
public cache: (T | undefined)[][];
|
||||
|
||||
public constructor() {
|
||||
this.cache = [];
|
||||
}
|
||||
|
||||
public resize(width: number, height: number): void {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (this.cache.length <= x) {
|
||||
this.cache.push([]);
|
||||
}
|
||||
for (let y = this.cache[x].length; y < height; y++) {
|
||||
this.cache[x].push(undefined);
|
||||
}
|
||||
this.cache[x].length = height;
|
||||
}
|
||||
this.cache.length = width;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (let x = 0; x < this.cache.length; x++) {
|
||||
for (let y = 0; y < this.cache[x].length; y++) {
|
||||
this.cache[x][y] = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ILinkifier2, ILinkifierEvent } from 'browser/Types';
|
||||
import { is256Color } from 'browser/renderer/shared/CharAtlasUtils';
|
||||
import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/shared/Constants';
|
||||
import { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
import { ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
||||
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { BaseRenderLayer } from './BaseRenderLayer';
|
||||
|
||||
export class LinkRenderLayer extends BaseRenderLayer {
|
||||
private _state: ILinkifierEvent | undefined;
|
||||
|
||||
constructor(
|
||||
terminal: Terminal,
|
||||
container: HTMLElement,
|
||||
zIndex: number,
|
||||
linkifier2: ILinkifier2,
|
||||
bufferService: IBufferService,
|
||||
optionsService: IOptionsService,
|
||||
decorationService: IDecorationService,
|
||||
coreBrowserService: ICoreBrowserService,
|
||||
themeService: IThemeService
|
||||
) {
|
||||
super(terminal, container, 'link', zIndex, true, themeService, bufferService, optionsService, decorationService, coreBrowserService);
|
||||
|
||||
this.register(linkifier2.onShowLinkUnderline(e => this._handleShowLinkUnderline(e)));
|
||||
this.register(linkifier2.onHideLinkUnderline(e => this._handleHideLinkUnderline(e)));
|
||||
}
|
||||
|
||||
public resize(dim: IRenderDimensions): void {
|
||||
super.resize(dim);
|
||||
// Resizing the canvas discards the contents of the canvas so clear state
|
||||
this._state = undefined;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._clearCurrentLink();
|
||||
}
|
||||
|
||||
private _clearCurrentLink(): void {
|
||||
if (this._state) {
|
||||
this._clearCells(this._state.x1, this._state.y1, this._state.cols - this._state.x1, 1);
|
||||
const middleRowCount = this._state.y2 - this._state.y1 - 1;
|
||||
if (middleRowCount > 0) {
|
||||
this._clearCells(0, this._state.y1 + 1, this._state.cols, middleRowCount);
|
||||
}
|
||||
this._clearCells(0, this._state.y2, this._state.x2, 1);
|
||||
this._state = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleShowLinkUnderline(e: ILinkifierEvent): void {
|
||||
if (e.fg === INVERTED_DEFAULT_COLOR) {
|
||||
this._ctx.fillStyle = this._themeService.colors.background.css;
|
||||
} else if (e.fg && is256Color(e.fg)) {
|
||||
// 256 color support
|
||||
this._ctx.fillStyle = this._themeService.colors.ansi[e.fg].css;
|
||||
} else {
|
||||
this._ctx.fillStyle = this._themeService.colors.foreground.css;
|
||||
}
|
||||
|
||||
if (e.y1 === e.y2) {
|
||||
// Single line link
|
||||
this._fillBottomLineAtCells(e.x1, e.y1, e.x2 - e.x1);
|
||||
} else {
|
||||
// Multi-line link
|
||||
this._fillBottomLineAtCells(e.x1, e.y1, e.cols - e.x1);
|
||||
for (let y = e.y1 + 1; y < e.y2; y++) {
|
||||
this._fillBottomLineAtCells(0, y, e.cols);
|
||||
}
|
||||
this._fillBottomLineAtCells(0, e.y2, e.x2);
|
||||
}
|
||||
this._state = e;
|
||||
}
|
||||
|
||||
private _handleHideLinkUnderline(e: ILinkifierEvent): void {
|
||||
this._clearCurrentLink();
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
import { BaseRenderLayer } from './BaseRenderLayer';
|
||||
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
|
||||
interface ISelectionState {
|
||||
start?: [number, number];
|
||||
end?: [number, number];
|
||||
columnSelectMode?: boolean;
|
||||
ydisp?: number;
|
||||
}
|
||||
|
||||
export class SelectionRenderLayer extends BaseRenderLayer {
|
||||
private _state!: ISelectionState;
|
||||
|
||||
constructor(
|
||||
terminal: Terminal,
|
||||
container: HTMLElement,
|
||||
zIndex: number,
|
||||
bufferService: IBufferService,
|
||||
coreBrowserService: ICoreBrowserService,
|
||||
decorationService: IDecorationService,
|
||||
optionsService: IOptionsService,
|
||||
themeService: IThemeService
|
||||
) {
|
||||
super(terminal, container, 'selection', zIndex, true, themeService, bufferService, optionsService, decorationService, coreBrowserService);
|
||||
this._clearState();
|
||||
}
|
||||
|
||||
private _clearState(): void {
|
||||
this._state = {
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
columnSelectMode: undefined,
|
||||
ydisp: undefined
|
||||
};
|
||||
}
|
||||
|
||||
public resize(dim: IRenderDimensions): void {
|
||||
super.resize(dim);
|
||||
// On resize use the base render layer's cached selection values since resize clears _state
|
||||
// inside reset.
|
||||
if (this._selectionModel.selectionStart && this._selectionModel.selectionEnd) {
|
||||
this._clearState();
|
||||
this._redrawSelection(this._selectionModel.selectionStart, this._selectionModel.selectionEnd, this._selectionModel.columnSelectMode);
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
if (this._state.start && this._state.end) {
|
||||
this._clearState();
|
||||
this._clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
public handleBlur(): void {
|
||||
this.reset();
|
||||
this._redrawSelection(this._selectionModel.selectionStart, this._selectionModel.selectionEnd, this._selectionModel.columnSelectMode);
|
||||
}
|
||||
|
||||
public handleFocus(): void {
|
||||
this.reset();
|
||||
this._redrawSelection(this._selectionModel.selectionStart, this._selectionModel.selectionEnd, this._selectionModel.columnSelectMode);
|
||||
}
|
||||
|
||||
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
|
||||
super.handleSelectionChanged(start, end, columnSelectMode);
|
||||
this._redrawSelection(start, end, columnSelectMode);
|
||||
}
|
||||
|
||||
private _redrawSelection(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
|
||||
// Selection has not changed
|
||||
if (!this._didStateChange(start, end, columnSelectMode, this._bufferService.buffer.ydisp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all selections
|
||||
this._clearAll();
|
||||
|
||||
// Selection does not exist
|
||||
if (!start || !end) {
|
||||
this._clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Translate from buffer position to viewport position
|
||||
const viewportStartRow = start[1] - this._bufferService.buffer.ydisp;
|
||||
const viewportEndRow = end[1] - this._bufferService.buffer.ydisp;
|
||||
const viewportCappedStartRow = Math.max(viewportStartRow, 0);
|
||||
const viewportCappedEndRow = Math.min(viewportEndRow, this._bufferService.rows - 1);
|
||||
|
||||
// No need to draw the selection
|
||||
if (viewportCappedStartRow >= this._bufferService.rows || viewportCappedEndRow < 0) {
|
||||
this._state.ydisp = this._bufferService.buffer.ydisp;
|
||||
return;
|
||||
}
|
||||
|
||||
this._ctx.fillStyle = (this._coreBrowserService.isFocused
|
||||
? this._themeService.colors.selectionBackgroundTransparent
|
||||
: this._themeService.colors.selectionInactiveBackgroundTransparent).css;
|
||||
|
||||
if (columnSelectMode) {
|
||||
const startCol = start[0];
|
||||
const width = end[0] - startCol;
|
||||
const height = viewportCappedEndRow - viewportCappedStartRow + 1;
|
||||
this._fillCells(startCol, viewportCappedStartRow, width, height);
|
||||
} else {
|
||||
// Draw first row
|
||||
const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
|
||||
const startRowEndCol = viewportCappedStartRow === viewportEndRow ? end[0] : this._bufferService.cols;
|
||||
this._fillCells(startCol, viewportCappedStartRow, startRowEndCol - startCol, 1);
|
||||
|
||||
// Draw middle rows
|
||||
const middleRowsCount = Math.max(viewportCappedEndRow - viewportCappedStartRow - 1, 0);
|
||||
this._fillCells(0, viewportCappedStartRow + 1, this._bufferService.cols, middleRowsCount);
|
||||
|
||||
// Draw final row
|
||||
if (viewportCappedStartRow !== viewportCappedEndRow) {
|
||||
// Only draw viewportEndRow if it's not the same as viewportStartRow
|
||||
const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._bufferService.cols;
|
||||
this._fillCells(0, viewportCappedEndRow, endCol, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Save state for next render
|
||||
this._state.start = [start[0], start[1]];
|
||||
this._state.end = [end[0], end[1]];
|
||||
this._state.columnSelectMode = columnSelectMode;
|
||||
this._state.ydisp = this._bufferService.buffer.ydisp;
|
||||
}
|
||||
|
||||
private _didStateChange(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean, ydisp: number): boolean {
|
||||
return !this._areCoordinatesEqual(start, this._state.start) ||
|
||||
!this._areCoordinatesEqual(end, this._state.end) ||
|
||||
columnSelectMode !== this._state.columnSelectMode ||
|
||||
ydisp !== this._state.ydisp;
|
||||
}
|
||||
|
||||
private _areCoordinatesEqual(coord1: [number, number] | undefined, coord2: [number, number] | undefined): boolean {
|
||||
if (!coord1 || !coord2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return coord1[0] === coord2[0] && coord1[1] === coord2[1];
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
import { JoinedCellData } from 'browser/services/CharacterJoinerService';
|
||||
import { ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
||||
import { CharData, ICellData } from 'common/Types';
|
||||
import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { Content, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { BaseRenderLayer } from './BaseRenderLayer';
|
||||
import { GridCache } from './GridCache';
|
||||
|
||||
/**
|
||||
* This CharData looks like a null character, which will forc a clear and render
|
||||
* when the character changes (a regular space ' ' character may not as it's
|
||||
* drawn state is a cleared cell).
|
||||
*/
|
||||
// const OVERLAP_OWNED_CHAR_DATA: CharData = [null, '', 0, -1];
|
||||
|
||||
export class TextRenderLayer extends BaseRenderLayer {
|
||||
private _state: GridCache<CharData>;
|
||||
private _characterWidth: number = 0;
|
||||
private _characterFont: string = '';
|
||||
private _characterOverlapCache: { [key: string]: boolean } = {};
|
||||
private _workCell = new CellData();
|
||||
|
||||
constructor(
|
||||
terminal: Terminal,
|
||||
container: HTMLElement,
|
||||
zIndex: number,
|
||||
alpha: boolean,
|
||||
bufferService: IBufferService,
|
||||
optionsService: IOptionsService,
|
||||
private readonly _characterJoinerService: ICharacterJoinerService,
|
||||
decorationService: IDecorationService,
|
||||
coreBrowserService: ICoreBrowserService,
|
||||
themeService: IThemeService
|
||||
) {
|
||||
super(terminal, container, 'text', zIndex, alpha, themeService, bufferService, optionsService, decorationService, coreBrowserService);
|
||||
this._state = new GridCache<CharData>();
|
||||
this.register(optionsService.onSpecificOptionChange('allowTransparency', value => this._setTransparency(value)));
|
||||
}
|
||||
|
||||
public resize(dim: IRenderDimensions): void {
|
||||
super.resize(dim);
|
||||
|
||||
// Clear the character width cache if the font or width has changed
|
||||
const terminalFont = this._getFont(false, false);
|
||||
if (this._characterWidth !== dim.device.char.width || this._characterFont !== terminalFont) {
|
||||
this._characterWidth = dim.device.char.width;
|
||||
this._characterFont = terminalFont;
|
||||
this._characterOverlapCache = {};
|
||||
}
|
||||
// Resizing the canvas discards the contents of the canvas so clear state
|
||||
this._state.clear();
|
||||
this._state.resize(this._bufferService.cols, this._bufferService.rows);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._state.clear();
|
||||
this._clearAll();
|
||||
}
|
||||
|
||||
private _forEachCell(
|
||||
firstRow: number,
|
||||
lastRow: number,
|
||||
callback: (
|
||||
cell: ICellData,
|
||||
x: number,
|
||||
y: number
|
||||
) => void
|
||||
): void {
|
||||
for (let y = firstRow; y <= lastRow; y++) {
|
||||
const row = y + this._bufferService.buffer.ydisp;
|
||||
const line = this._bufferService.buffer.lines.get(row);
|
||||
const joinedRanges = this._characterJoinerService.getJoinedCharacters(row);
|
||||
for (let x = 0; x < this._bufferService.cols; x++) {
|
||||
line!.loadCell(x, this._workCell);
|
||||
let cell = this._workCell;
|
||||
|
||||
// If true, indicates that the current character(s) to draw were joined.
|
||||
let isJoined = false;
|
||||
let lastCharX = x;
|
||||
|
||||
// The character to the left is a wide character, drawing is owned by
|
||||
// the char at x-1
|
||||
if (cell.getWidth() === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// exit early for NULL and SP
|
||||
// NOTE: commented out due to #4120 (needs a more clever patch to keep things performant)
|
||||
// const code = cell.getCode();
|
||||
// if (code === 0 || code === 32) {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// Process any joined character ranges as needed. Because of how the
|
||||
// ranges are produced, we know that they are valid for the characters
|
||||
// and attributes of our input.
|
||||
if (joinedRanges.length > 0 && x === joinedRanges[0][0]) {
|
||||
isJoined = true;
|
||||
const range = joinedRanges.shift()!;
|
||||
|
||||
// We already know the exact start and end column of the joined range,
|
||||
// so we get the string and width representing it directly
|
||||
cell = new JoinedCellData(
|
||||
this._workCell,
|
||||
line!.translateToString(true, range[0], range[1]),
|
||||
range[1] - range[0]
|
||||
);
|
||||
|
||||
// Skip over the cells occupied by this range in the loop
|
||||
lastCharX = range[1] - 1;
|
||||
}
|
||||
|
||||
// If the character is an overlapping char and the character to the
|
||||
// right is a space, take ownership of the cell to the right. We skip
|
||||
// this check for joined characters because their rendering likely won't
|
||||
// yield the same result as rendering the last character individually.
|
||||
if (!isJoined && this._isOverlapping(cell)) {
|
||||
// If the character is overlapping, we want to force a re-render on every
|
||||
// frame. This is specifically to work around the case where two
|
||||
// overlaping chars `a` and `b` are adjacent, the cursor is moved to b and a
|
||||
// space is added. Without this, the first half of `b` would never
|
||||
// get removed, and `a` would not re-render because it thinks it's
|
||||
// already in the correct state.
|
||||
// this._state.cache[x][y] = OVERLAP_OWNED_CHAR_DATA;
|
||||
if (lastCharX < line!.length - 1 && line!.getCodePoint(lastCharX + 1) === NULL_CELL_CODE) {
|
||||
// patch width to 2
|
||||
cell.content &= ~Content.WIDTH_MASK;
|
||||
cell.content |= 2 << Content.WIDTH_SHIFT;
|
||||
// this._clearChar(x + 1, y);
|
||||
// The overlapping char's char data will force a clear and render when the
|
||||
// overlapping char is no longer to the left of the character and also when
|
||||
// the space changes to another character.
|
||||
// this._state.cache[x + 1][y] = OVERLAP_OWNED_CHAR_DATA;
|
||||
}
|
||||
}
|
||||
|
||||
callback(
|
||||
cell,
|
||||
x,
|
||||
y
|
||||
);
|
||||
|
||||
x = lastCharX;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the background for a specified range of columns. Tries to batch adjacent cells of the
|
||||
* same color together to reduce draw calls.
|
||||
*/
|
||||
private _drawBackground(firstRow: number, lastRow: number): void {
|
||||
const ctx = this._ctx;
|
||||
const cols = this._bufferService.cols;
|
||||
let startX: number = 0;
|
||||
let startY: number = 0;
|
||||
let prevFillStyle: string | null = null;
|
||||
|
||||
ctx.save();
|
||||
|
||||
this._forEachCell(firstRow, lastRow, (cell, x, y) => {
|
||||
// libvte and xterm both draw the background (but not foreground) of invisible characters,
|
||||
// so we should too.
|
||||
let nextFillStyle = null; // null represents default background color
|
||||
|
||||
if (cell.isInverse()) {
|
||||
if (cell.isFgDefault()) {
|
||||
nextFillStyle = this._themeService.colors.foreground.css;
|
||||
} else if (cell.isFgRGB()) {
|
||||
nextFillStyle = `rgb(${AttributeData.toColorRGB(cell.getFgColor()).join(',')})`;
|
||||
} else {
|
||||
nextFillStyle = this._themeService.colors.ansi[cell.getFgColor()].css;
|
||||
}
|
||||
} else if (cell.isBgRGB()) {
|
||||
nextFillStyle = `rgb(${AttributeData.toColorRGB(cell.getBgColor()).join(',')})`;
|
||||
} else if (cell.isBgPalette()) {
|
||||
nextFillStyle = this._themeService.colors.ansi[cell.getBgColor()].css;
|
||||
}
|
||||
|
||||
// Get any decoration foreground/background overrides, this must be fetched before the early
|
||||
// exist but applied after inverse
|
||||
let isTop = false;
|
||||
this._decorationService.forEachDecorationAtCell(x, this._bufferService.buffer.ydisp + y, undefined, d => {
|
||||
if (d.options.layer !== 'top' && isTop) {
|
||||
return;
|
||||
}
|
||||
if (d.backgroundColorRGB) {
|
||||
nextFillStyle = d.backgroundColorRGB.css;
|
||||
}
|
||||
isTop = d.options.layer === 'top';
|
||||
});
|
||||
|
||||
if (prevFillStyle === null) {
|
||||
// This is either the first iteration, or the default background was set. Either way, we
|
||||
// don't need to draw anything.
|
||||
startX = x;
|
||||
startY = y;
|
||||
}
|
||||
|
||||
if (y !== startY) {
|
||||
// our row changed, draw the previous row
|
||||
ctx.fillStyle = prevFillStyle || '';
|
||||
this._fillCells(startX, startY, cols - startX, 1);
|
||||
startX = x;
|
||||
startY = y;
|
||||
} else if (prevFillStyle !== nextFillStyle) {
|
||||
// our color changed, draw the previous characters in this row
|
||||
ctx.fillStyle = prevFillStyle || '';
|
||||
this._fillCells(startX, startY, x - startX, 1);
|
||||
startX = x;
|
||||
startY = y;
|
||||
}
|
||||
|
||||
prevFillStyle = nextFillStyle;
|
||||
});
|
||||
|
||||
// flush the last color we encountered
|
||||
if (prevFillStyle !== null) {
|
||||
ctx.fillStyle = prevFillStyle;
|
||||
this._fillCells(startX, startY, cols - startX, 1);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _drawForeground(firstRow: number, lastRow: number): void {
|
||||
this._forEachCell(firstRow, lastRow, (cell, x, y) => this._drawChars(cell, x, y));
|
||||
}
|
||||
|
||||
public handleGridChanged(firstRow: number, lastRow: number): void {
|
||||
// Resize has not been called yet
|
||||
if (this._state.cache.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._charAtlas) {
|
||||
this._charAtlas.beginFrame();
|
||||
}
|
||||
|
||||
this._clearCells(0, firstRow, this._bufferService.cols, lastRow - firstRow + 1);
|
||||
this._drawBackground(firstRow, lastRow);
|
||||
this._drawForeground(firstRow, lastRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a character is overlapping to the next cell.
|
||||
*/
|
||||
private _isOverlapping(cell: ICellData): boolean {
|
||||
// Only single cell characters can be overlapping, rendering issues can
|
||||
// occur without this check
|
||||
if (cell.getWidth() !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We assume that any ascii character will not overlap
|
||||
if (cell.getCode() < 256) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chars = cell.getChars();
|
||||
|
||||
// Deliver from cache if available
|
||||
if (this._characterOverlapCache.hasOwnProperty(chars)) {
|
||||
return this._characterOverlapCache[chars];
|
||||
}
|
||||
|
||||
// Setup the font
|
||||
this._ctx.save();
|
||||
this._ctx.font = this._characterFont;
|
||||
|
||||
// Measure the width of the character, but Math.floor it
|
||||
// because that is what the renderer does when it calculates
|
||||
// the character dimensions we are comparing against
|
||||
const overlaps = Math.floor(this._ctx.measureText(chars).width) > this._characterWidth;
|
||||
|
||||
// Restore the original context
|
||||
this._ctx.restore();
|
||||
|
||||
// Cache and return
|
||||
this._characterOverlapCache[chars] = overlaps;
|
||||
return overlaps;
|
||||
}
|
||||
}
|
||||
Vendored
-86
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IDisposable } from 'common/Types';
|
||||
import { IEvent } from 'common/EventEmitter';
|
||||
import { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
|
||||
export interface IRequestRedrawEvent {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note that IRenderer implementations should emit the refresh event after
|
||||
* rendering rows to the screen.
|
||||
*/
|
||||
export interface IRenderer extends IDisposable {
|
||||
readonly dimensions: IRenderDimensions;
|
||||
|
||||
/**
|
||||
* Fires when the renderer is requesting to be redrawn on the next animation
|
||||
* frame but is _not_ a result of content changing (eg. selection changes).
|
||||
*/
|
||||
readonly onRequestRedraw: IEvent<IRequestRedrawEvent>;
|
||||
|
||||
handleDevicePixelRatioChange(): void;
|
||||
handleResize(cols: number, rows: number): void;
|
||||
handleCharSizeChanged(): void;
|
||||
handleBlur(): void;
|
||||
handleFocus(): void;
|
||||
handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void;
|
||||
handleCursorMove(): void;
|
||||
handleOptionsChanged(): void;
|
||||
clear(): void;
|
||||
renderRows(start: number, end: number): void;
|
||||
clearTextureAtlas?(): void;
|
||||
}
|
||||
|
||||
export interface IRenderLayer extends IDisposable {
|
||||
readonly canvas: HTMLCanvasElement;
|
||||
readonly cacheCanvas: HTMLCanvasElement;
|
||||
|
||||
readonly onAddTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
|
||||
/**
|
||||
* Called when the terminal loses focus.
|
||||
*/
|
||||
handleBlur(): void;
|
||||
|
||||
/**
|
||||
* Called when the terminal gets focus.
|
||||
*/
|
||||
handleFocus(): void;
|
||||
|
||||
/**
|
||||
* Called when the cursor is moved.
|
||||
*/
|
||||
handleCursorMove(): void;
|
||||
|
||||
/**
|
||||
* Called when the data in the grid has changed (or needs to be rendered
|
||||
* again).
|
||||
*/
|
||||
handleGridChanged(startRow: number, endRow: number): void;
|
||||
|
||||
/**
|
||||
* Calls when the selection changes.
|
||||
*/
|
||||
handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void;
|
||||
|
||||
/**
|
||||
* Resize the render layer.
|
||||
*/
|
||||
resize(dim: IRenderDimensions): void;
|
||||
|
||||
/**
|
||||
* Clear the state of the render layer.
|
||||
*/
|
||||
reset(): void;
|
||||
|
||||
/**
|
||||
* Clears the texture atlas.
|
||||
*/
|
||||
clearTextureAtlas(): void;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import test from '@playwright/test';
|
||||
import { ISharedRendererTestContext, injectSharedRendererTests, injectSharedRendererTestsStandalone } from '../../../out-test/playwright/SharedRendererTests';
|
||||
import { ITestContext, createTestContext, openTerminal } from '../../../out-test/playwright/TestUtils';
|
||||
|
||||
let ctx: ITestContext;
|
||||
const ctxWrapper: ISharedRendererTestContext = {
|
||||
value: undefined,
|
||||
skipCanvasExceptions: true
|
||||
} as any;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await createTestContext(browser);
|
||||
await openTerminal(ctx);
|
||||
ctxWrapper.value = ctx;
|
||||
await ctx.page.evaluate(`
|
||||
window.addon = new window.CanvasAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
`);
|
||||
});
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
|
||||
test.describe('Canvas Renderer Integration Tests', () => {
|
||||
// HACK: The tests fail for an unknown reason
|
||||
test.skip(({ browserName }) => browserName === 'webkit');
|
||||
|
||||
injectSharedRendererTests(ctxWrapper);
|
||||
injectSharedRendererTestsStandalone(ctxWrapper, async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.addon = new window.CanvasAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2015",
|
||||
"lib": [
|
||||
"es6",
|
||||
],
|
||||
"rootDir": ".",
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/node",
|
||||
"../../../node_modules/@lunapaint/png-codec",
|
||||
"../../../out-test/playwright/TestUtils",
|
||||
"../../../out-test/playwright/SharedRendererTests"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../../typings/xterm.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../src/common"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/browser"
|
||||
}
|
||||
]
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
|
||||
|
||||
declare module '@xterm/addon-canvas' {
|
||||
/**
|
||||
* An xterm.js addon that provides search functionality.
|
||||
*/
|
||||
export class CanvasAddon implements ITerminalAddon {
|
||||
public textureAtlas?: HTMLCanvasElement;
|
||||
|
||||
/**
|
||||
* An event that is fired when the texture atlas of the renderer changes.
|
||||
*/
|
||||
public readonly onChangeTextureAtlas: IEvent<HTMLCanvasElement>;
|
||||
|
||||
/**
|
||||
* An event that is fired when the a new page is added to the texture atlas.
|
||||
*/
|
||||
public readonly onAddTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
|
||||
|
||||
constructor();
|
||||
|
||||
/**
|
||||
* Activates the addon.
|
||||
* @param terminal The terminal the addon is being loaded in.
|
||||
*/
|
||||
public activate(terminal: Terminal): void;
|
||||
|
||||
/**
|
||||
* Disposes the addon.
|
||||
*/
|
||||
public dispose(): void;
|
||||
|
||||
/**
|
||||
* Clears the terminal's texture atlas and triggers a redraw.
|
||||
*/
|
||||
public clearTextureAtlas(): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
lib
|
||||
node_modules
|
||||
@@ -8,6 +8,9 @@
|
||||
!lib/**/*.js
|
||||
!lib/**/*.js.map
|
||||
|
||||
!lib/**/*.mjs
|
||||
!lib/**/*.mjs.map
|
||||
|
||||
!lib/**/*.css
|
||||
|
||||
# Whitelist - src/
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2017, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
||||
Copyright (c) 2023, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -0,0 +1,53 @@
|
||||
## @xterm/addon-clipboard
|
||||
|
||||
An addon for [xterm.js](https://github.com/xtermjs/xterm.js) that enables
|
||||
accessing the system clipboard. This addon requires xterm.js v4+.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install --save @xterm/addon-clipboard
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
import { Terminal } from 'xterm';
|
||||
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
|
||||
const terminal = new Terminal();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
```
|
||||
|
||||
To use a custom clipboard provider
|
||||
|
||||
```ts
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { ClipboardAddon, IClipboardProvider, ClipboardSelectionType } from '@xterm/addon-clipboard';
|
||||
|
||||
function b64Encode(data: string): string {
|
||||
// Base64 encode impl
|
||||
}
|
||||
|
||||
function b64Decode(data: string): string {
|
||||
// Base64 decode impl
|
||||
}
|
||||
|
||||
class MyCustomClipboardProvider implements IClipboardProvider {
|
||||
private _data: string
|
||||
public readText(selection: ClipboardSelectionType): Promise<string> {
|
||||
return Promise.resolve(b64Encode(this._data));
|
||||
}
|
||||
public writeText(selection: ClipboardSelectionType, data: string): Promise<void> {
|
||||
this._data = b64Decode(data);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const terminal = new Terminal();
|
||||
const clipboardAddon = new ClipboardAddon(new MyCustomClipboardProvider());
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
```
|
||||
|
||||
See the full [API](https://github.com/xtermjs/xterm.js/blob/master/addons/addon-clipboard/typings/addon-clipboard.d.ts) for more advanced usage.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@xterm/addon-clipboard",
|
||||
"version": "0.2.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
},
|
||||
"main": "lib/addon-clipboard.js",
|
||||
"module": "lib/addon-clipboard.mjs",
|
||||
"types": "typings/addon-clipboard.d.ts",
|
||||
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-clipboard",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"terminal",
|
||||
"xterm",
|
||||
"xterm.js"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "../../node_modules/.bin/tsgo -p .",
|
||||
"prepackage": "npm run build",
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard';
|
||||
|
||||
export class ClipboardAddon implements ITerminalAddon {
|
||||
private _terminal?: Terminal;
|
||||
private _disposable?: IDisposable;
|
||||
|
||||
constructor(
|
||||
private _base64: IBase64 = new Base64(),
|
||||
private _provider: IClipboardProvider = new BrowserClipboardProvider()
|
||||
) {}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal;
|
||||
this._disposable = terminal.parser.registerOscHandler(52, data => this._setOrReportClipboard(data));
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
return this._disposable?.dispose();
|
||||
}
|
||||
|
||||
private _readText(sel: string, data: string): void {
|
||||
const b64 = this._base64.encodeText(data);
|
||||
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
|
||||
}
|
||||
|
||||
private _setOrReportClipboard(data: string): boolean | Promise<boolean> {
|
||||
const args = data.split(';');
|
||||
if (args.length < 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pc = args[0];
|
||||
const pd = args[1];
|
||||
if (pd === '?') {
|
||||
const text = this._provider.readText(pc);
|
||||
|
||||
// Report clipboard
|
||||
if (text instanceof Promise) {
|
||||
return text.then((data) => {
|
||||
this._readText(pc, data);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
this._readText(pc, text);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clear clipboard if text is not a base64 encoded string.
|
||||
let text = '';
|
||||
try {
|
||||
text = this._base64.decodeText(pd);
|
||||
} catch {}
|
||||
|
||||
|
||||
const result = this._provider.writeText(pc, text);
|
||||
if (result instanceof Promise) {
|
||||
return result.then(() => true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserClipboardProvider implements IClipboardProvider {
|
||||
public readText(selection: string): Promise<string> {
|
||||
return navigator.clipboard.readText();
|
||||
}
|
||||
|
||||
public writeText(selection: string, text: string): Promise<void> {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
|
||||
export class Base64 implements IBase64 {
|
||||
public encodeText(data: string): string {
|
||||
return btoa(data);
|
||||
}
|
||||
public decodeText(data: string): string {
|
||||
try {
|
||||
return atob(data);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2021",
|
||||
"lib": [
|
||||
"dom",
|
||||
"es2015"
|
||||
],
|
||||
"rootDir": ".",
|
||||
"outDir": "../out",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha"
|
||||
],
|
||||
"paths": {
|
||||
"target-browser/*": [
|
||||
"../../../src/target-browser/*"
|
||||
],
|
||||
"@xterm/addon-clipboard": [
|
||||
"../typings/addon-clipboard.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../../typings/xterm.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../src/target-browser"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import test from '@playwright/test';
|
||||
import { deepEqual, ok, strictEqual } from 'assert';
|
||||
import { ITestContext, createTestContext, launchBrowser, openTerminal, timeout } from '../../../test/playwright/TestUtils';
|
||||
|
||||
let ctx: ITestContext;
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
ctx = await createTestContext(browser);
|
||||
await openTerminal(ctx);
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await ctx.page.close();
|
||||
});
|
||||
|
||||
test.describe('ClipboardAddon', () => {
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
// DEBT: This test doesn't work since the migration to @playwright/test
|
||||
if (ctx.browser.browserType().name() === 'webkit') {
|
||||
testInfo.skip();
|
||||
return;
|
||||
}
|
||||
if (ctx.browser.browserType().name() === 'chromium') {
|
||||
// Enable clipboard access in chromium without user gesture
|
||||
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
}
|
||||
await ctx.page.evaluate(`
|
||||
window.term.reset()
|
||||
window.clipboard?.dispose();
|
||||
window.clipboard = new ClipboardAddon();
|
||||
window.term.loadAddon(window.clipboard);
|
||||
`);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await ctx.proxy.reset();
|
||||
});
|
||||
|
||||
const testDataEncoded = 'aGVsbG8gd29ybGQ=';
|
||||
const testDataDecoded = 'hello world';
|
||||
|
||||
test.describe('write data', async function (): Promise<any> {
|
||||
test('simple string', async () => {
|
||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
|
||||
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 () => {
|
||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`);
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
|
||||
});
|
||||
test('empty string', async () => {
|
||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
|
||||
await ctx.proxy.write(`\x1b]52;c;\x07`);
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('read data', async function (): Promise<any> {
|
||||
test.beforeAll(async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.data = [];
|
||||
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.proxy.write(`\x1b]52;c;?\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 () => {
|
||||
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()), '');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: '.',
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'FirefoxStable',
|
||||
use: {
|
||||
browserName: 'firefox'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'WebKit',
|
||||
use: {
|
||||
browserName: 'webkit'
|
||||
}
|
||||
}
|
||||
],
|
||||
reporter: 'list',
|
||||
webServer: {
|
||||
command: 'npm run start',
|
||||
port: 3000,
|
||||
timeout: 120000,
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"es2021",
|
||||
],
|
||||
"rootDir": ".",
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/primitives/*",
|
||||
"../../../src/runtime/*",
|
||||
],
|
||||
"target-browser/*": [
|
||||
"../../../src/target-browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../../typings/xterm.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../src"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/target-browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../test/playwright"
|
||||
}
|
||||
]
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Terminal, ITerminalAddon } from '@xterm/xterm';
|
||||
|
||||
declare module '@xterm/addon-clipboard' {
|
||||
/**
|
||||
* An xterm.js addon that enables accessing the system clipboard from
|
||||
* xterm.js.
|
||||
*/
|
||||
export class ClipboardAddon implements ITerminalAddon {
|
||||
/**
|
||||
* Creates a new clipboard addon.
|
||||
* @param base64 An optional base64 encoder/decoder.
|
||||
* @param provider An optional clipboard provider.
|
||||
*/
|
||||
constructor(base64?: IBase64, provider?: IClipboardProvider);
|
||||
|
||||
/**
|
||||
* Activates the addon
|
||||
* @param terminal The terminal the addon is being loaded in.
|
||||
*/
|
||||
public activate(terminal: Terminal): void;
|
||||
|
||||
/**
|
||||
* Disposes the addon.
|
||||
*/
|
||||
public dispose(): void
|
||||
}
|
||||
|
||||
export interface IBase64 {
|
||||
/**
|
||||
* Converts a utf-8 string to a base64 string.
|
||||
* @param data The utf-8 string to convert to base64 string.
|
||||
*/
|
||||
encodeText(data: string): string;
|
||||
|
||||
/**
|
||||
* Converts a base64 string to a utf-8 string.
|
||||
* @param data The base64 string to convert to utf-8 string.
|
||||
* @throws An error if the input is not valid base64.
|
||||
*/
|
||||
decodeText(data: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A default Base64 encoding and decoding type.
|
||||
**/
|
||||
export class Base64 implements IBase64 {
|
||||
/**
|
||||
* Converts a utf-8 string to a base64 string.
|
||||
* @param data The utf-8 string to convert to base64 string.
|
||||
*/
|
||||
public encodeText(data: string): string;
|
||||
|
||||
/**
|
||||
* Converts a base64 string to a utf-8 string.
|
||||
* @param data The base64 string to convert to utf-8 string.
|
||||
* @throws An error if the input is not valid base64.
|
||||
*/
|
||||
public decodeText(data: string): string;
|
||||
}
|
||||
|
||||
export interface IClipboardProvider {
|
||||
/**
|
||||
* Gets the clipboard content.
|
||||
* @param selection The clipboard selection to read (see OSC 52 spec of xterm).
|
||||
* @returns A promise that resolves with clipboard selection data.
|
||||
*/
|
||||
readText(selection: string): string | Promise<string>;
|
||||
|
||||
/**
|
||||
* Sets the clipboard content.
|
||||
* @param selection The clipboard selection to set (see OSC 52 spec of xterm).
|
||||
* @param data The clipboard text to write.
|
||||
*/
|
||||
writeText(selection: string, text: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clipboard provider interface that enables xterm.js to access the browser clipboard.
|
||||
*
|
||||
* Note this this clipboard provider does not implement own cut buffers as xterm does,
|
||||
* but redirect the selection parameter always to navigator.clipboard.
|
||||
*/
|
||||
export class BrowserClipboardProvider implements IClipboardProvider{
|
||||
/**
|
||||
* Reads text from the clipboard.
|
||||
* @param selection The selection type to read from (always refers to navigator.clipboard).
|
||||
* @returns A promise that resolves with the text from the clipboard.
|
||||
*/
|
||||
public readText(selection: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Writes text to the clipboard.
|
||||
* @param selection The selection type to write to (always refers to navigator.clipboard).
|
||||
* @param data The text to write to the clipboard.
|
||||
* @returns A promise that resolves when the text has been written to the clipboard.
|
||||
*/
|
||||
public writeText(selection: string, data: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const addonName = 'CanvasAddon';
|
||||
const mainFile = 'addon-canvas.js';
|
||||
const addonName = 'ClipboardAddon';
|
||||
const mainFile = 'addon-clipboard.js';
|
||||
|
||||
module.exports = {
|
||||
entry: `./out/${addonName}.js`,
|
||||
@@ -21,14 +21,6 @@ module.exports = {
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
modules: ['./node_modules'],
|
||||
extensions: [ '.js' ],
|
||||
alias: {
|
||||
common: path.resolve('../../out/common'),
|
||||
browser: path.resolve('../../out/browser')
|
||||
}
|
||||
},
|
||||
output: {
|
||||
filename: mainFile,
|
||||
path: path.resolve('./lib'),
|
||||
@@ -8,6 +8,9 @@
|
||||
!lib/**/*.js
|
||||
!lib/**/*.js.map
|
||||
|
||||
!lib/**/*.mjs
|
||||
!lib/**/*.mjs.map
|
||||
|
||||
!lib/**/*.css
|
||||
|
||||
# Whitelist - src/
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@xterm/addon-fit",
|
||||
"version": "0.9.0",
|
||||
"version": "0.11.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
},
|
||||
"main": "lib/addon-fit.js",
|
||||
"module": "lib/addon-fit.mjs",
|
||||
"types": "typings/addon-fit.d.ts",
|
||||
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit",
|
||||
"license": "MIT",
|
||||
@@ -15,12 +16,10 @@
|
||||
"xterm.js"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "../../node_modules/.bin/tsc -p .",
|
||||
"build": "../../node_modules/.bin/tsgo -p .",
|
||||
"prepackage": "npm run build",
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, ITerminalAddon } from '@xterm/xterm';
|
||||
import type { Terminal, ITerminalAddon, IRenderDimensions } from '@xterm/xterm';
|
||||
import type { FitAddon as IFitApi } from '@xterm/addon-fit';
|
||||
import { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
import { ViewportConstants } from 'target-browser/shared/Constants';
|
||||
|
||||
interface ITerminalDimensions {
|
||||
/**
|
||||
@@ -19,10 +19,23 @@ interface ITerminalDimensions {
|
||||
cols: number;
|
||||
}
|
||||
|
||||
const MINIMUM_COLS = 2;
|
||||
const MINIMUM_ROWS = 1;
|
||||
const enum Constants {
|
||||
MINIMUM_COLS = 2,
|
||||
MINIMUM_ROWS = 1
|
||||
}
|
||||
|
||||
export class FitAddon implements ITerminalAddon , IFitApi {
|
||||
function getWindow(e: Node): Window {
|
||||
if (e?.ownerDocument?.defaultView) {
|
||||
return e.ownerDocument.defaultView;
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
function _getComputedStyle(el: HTMLElement): CSSStyleDeclaration {
|
||||
return getWindow(el).getComputedStyle(el, null);
|
||||
}
|
||||
|
||||
export class FitAddon implements ITerminalAddon, IFitApi {
|
||||
private _terminal: Terminal | undefined;
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
@@ -37,14 +50,7 @@ export class FitAddon implements ITerminalAddon , IFitApi {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Remove reliance on private API
|
||||
const core = (this._terminal as any)._core;
|
||||
|
||||
// Force a full render
|
||||
if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
|
||||
core._renderService.clear();
|
||||
this._terminal.resize(dims.cols, dims.rows);
|
||||
}
|
||||
this._terminal.resize(dims.cols, dims.rows);
|
||||
}
|
||||
|
||||
public proposeDimensions(): ITerminalDimensions | undefined {
|
||||
@@ -56,21 +62,21 @@ export class FitAddon implements ITerminalAddon , IFitApi {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// TODO: Remove reliance on private API
|
||||
const core = (this._terminal as any)._core;
|
||||
const dims: IRenderDimensions = core._renderService.dimensions;
|
||||
const dims: IRenderDimensions | undefined = this._terminal.dimensions;
|
||||
|
||||
if (dims.css.cell.width === 0 || dims.css.cell.height === 0) {
|
||||
if (!dims || dims.css.cell.width === 0 || dims.css.cell.height === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scrollbarWidth = this._terminal.options.scrollback === 0 ?
|
||||
0 : core.viewport.scrollBarWidth;
|
||||
const showScrollbar = this._terminal.options.scrollbar?.showScrollbar ?? true;
|
||||
const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar
|
||||
? 0
|
||||
: (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
|
||||
|
||||
const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement);
|
||||
const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement);
|
||||
const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
|
||||
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
|
||||
const elementStyle = window.getComputedStyle(this._terminal.element);
|
||||
const elementStyle = _getComputedStyle(this._terminal.element);
|
||||
const elementPadding = {
|
||||
top: parseInt(elementStyle.getPropertyValue('padding-top')),
|
||||
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
|
||||
@@ -82,8 +88,8 @@ export class FitAddon implements ITerminalAddon , IFitApi {
|
||||
const availableHeight = parentElementHeight - elementPaddingVer;
|
||||
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
|
||||
const geometry = {
|
||||
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
|
||||
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
|
||||
cols: Math.max(Constants.MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
|
||||
rows: Math.max(Constants.MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
|
||||
};
|
||||
return geometry;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"../../../node_modules/@types/mocha"
|
||||
],
|
||||
"paths": {
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
"target-browser/*": [
|
||||
"../../../src/target-browser/*"
|
||||
],
|
||||
"@xterm/addon-fit": [
|
||||
"../typings/addon-fit.d.ts"
|
||||
@@ -29,7 +29,7 @@
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../src/browser"
|
||||
"path": "../../../src/target-browser"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { openTerminal, launchBrowser, timeout } from '../../../out-test/api/TestUtils';
|
||||
import { Browser, Page } from '@playwright/test';
|
||||
|
||||
const APP = 'http://127.0.0.1:3001/test';
|
||||
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
const width = 1024;
|
||||
const height = 768;
|
||||
|
||||
describe('FitAddon', () => {
|
||||
before(async function(): Promise<any> {
|
||||
browser = await launchBrowser();
|
||||
page = await (await browser.newContext()).newPage();
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(APP);
|
||||
});
|
||||
|
||||
beforeEach(async function(): Promise<any> {
|
||||
await page.evaluate(`document.querySelector('#terminal-container').style.display=''`);
|
||||
await openTerminal(page);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
afterEach(async function(): Promise<any> {
|
||||
await page.evaluate(`window.term.dispose()`);
|
||||
});
|
||||
|
||||
it('no terminal', async function(): Promise<any> {
|
||||
await page.evaluate(`window.fit = new FitAddon();`);
|
||||
assert.equal(await page.evaluate(`window.fit.proposeDimensions()`), undefined);
|
||||
});
|
||||
|
||||
describe('proposeDimensions', () => {
|
||||
afterEach(() => unloadFit());
|
||||
|
||||
it('default', async function(): Promise<any> {
|
||||
await loadFit();
|
||||
const dimensions: {cols: number, rows: number} = await page.evaluate(`window.fit.proposeDimensions()`);
|
||||
assert.isAbove(dimensions.cols, 85);
|
||||
assert.isBelow(dimensions.cols, 88);
|
||||
assert.isAbove(dimensions.rows, 24);
|
||||
assert.isBelow(dimensions.rows, 29);
|
||||
});
|
||||
|
||||
it('width', async function(): Promise<any> {
|
||||
await loadFit(1008);
|
||||
const dimensions: {cols: number, rows: number} = await page.evaluate(`window.fit.proposeDimensions()`);
|
||||
assert.isAbove(dimensions.cols, 108);
|
||||
assert.isBelow(dimensions.cols, 111);
|
||||
assert.isAbove(dimensions.rows, 24);
|
||||
assert.isBelow(dimensions.rows, 29);
|
||||
});
|
||||
|
||||
it('small', async function(): Promise<any> {
|
||||
await loadFit(1, 1);
|
||||
assert.deepEqual(await page.evaluate(`window.fit.proposeDimensions()`), {
|
||||
cols: 2,
|
||||
rows: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('hidden', async function(): Promise<any> {
|
||||
await page.evaluate(`window.term.dispose()`);
|
||||
await page.evaluate(`document.querySelector('#terminal-container').style.display='none'`);
|
||||
await page.evaluate(`window.term = new Terminal()`);
|
||||
await page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`);
|
||||
await loadFit();
|
||||
const dimensions: { cols: number, rows: number } | undefined = await page.evaluate(`window.fit.proposeDimensions()`);
|
||||
// The value of dims will be undefined if the char measure strategy falls back to the DOM
|
||||
// method, so only assert if it's not undefined.
|
||||
if (dimensions) {
|
||||
assert.isAbove(dimensions.cols, 85);
|
||||
assert.isBelow(dimensions.cols, 88);
|
||||
assert.isAbove(dimensions.rows, 24);
|
||||
assert.isBelow(dimensions.rows, 29);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fit', () => {
|
||||
afterEach(() => unloadFit());
|
||||
|
||||
it('default', async function(): Promise<any> {
|
||||
await loadFit();
|
||||
await page.evaluate(`window.fit.fit()`);
|
||||
const cols: number = await page.evaluate(`window.term.cols`);
|
||||
const rows: number = await page.evaluate(`window.term.rows`);
|
||||
assert.isAbove(cols, 85);
|
||||
assert.isBelow(cols, 88);
|
||||
assert.isAbove(rows, 24);
|
||||
assert.isBelow(rows, 29);
|
||||
});
|
||||
|
||||
it('width', async function(): Promise<any> {
|
||||
await loadFit(1008);
|
||||
await page.evaluate(`window.fit.fit()`);
|
||||
const cols: number = await page.evaluate(`window.term.cols`);
|
||||
const rows: number = await page.evaluate(`window.term.rows`);
|
||||
assert.isAbove(cols, 108);
|
||||
assert.isBelow(cols, 111);
|
||||
assert.isAbove(rows, 24);
|
||||
assert.isBelow(rows, 29);
|
||||
});
|
||||
|
||||
it('small', async function(): Promise<any> {
|
||||
await loadFit(1, 1);
|
||||
await page.evaluate(`window.fit.fit()`);
|
||||
assert.equal(await page.evaluate(`window.term.cols`), 2);
|
||||
assert.equal(await page.evaluate(`window.term.rows`), 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function loadFit(width: number = 800, height: number = 450): Promise<void> {
|
||||
await page.evaluate(`
|
||||
window.fit = new FitAddon();
|
||||
window.term.loadAddon(window.fit);
|
||||
document.querySelector('#terminal-container').style.width='${width}px';
|
||||
document.querySelector('#terminal-container').style.height='${height}px';
|
||||
`);
|
||||
}
|
||||
|
||||
async function unloadFit(): Promise<void> {
|
||||
await page.evaluate(`window.fit.dispose();`);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import test from '@playwright/test';
|
||||
import { deepEqual, ok, strictEqual } from 'assert';
|
||||
import { ITestContext, createTestContext, openTerminal, timeout } from '../../../test/playwright/TestUtils';
|
||||
|
||||
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('FitAddon', () => {
|
||||
test.beforeEach(async function(): Promise<any> {
|
||||
await ctx.page.evaluate(`document.querySelector('#terminal-container').style.display=''`);
|
||||
await ctx.page.evaluate(`
|
||||
window.term.reset()
|
||||
window.fit?.dispose();
|
||||
window.fit = new FitAddon();
|
||||
window.term.loadAddon(window.fit);
|
||||
`);
|
||||
});
|
||||
|
||||
// test.beforeEach(async function(): Promise<any> {
|
||||
// await ctx.page.evaluate(`document.querySelector('#terminal-container').style.display=''`);
|
||||
// await openTerminal(page);
|
||||
// });
|
||||
|
||||
// after(async () => {
|
||||
// await browser.close();
|
||||
// });
|
||||
|
||||
// afterEach(async function(): Promise<any> {
|
||||
// await ctx.proxy.dispose();
|
||||
// });
|
||||
|
||||
test('no terminal', async function(): Promise<any> {
|
||||
await ctx.page.evaluate(`window.fit2 = new FitAddon();`);
|
||||
strictEqual(await ctx.page.evaluate(`window.fit2.proposeDimensions()`), undefined);
|
||||
await ctx.page.evaluate(`window.fit2.dispose();`);
|
||||
});
|
||||
|
||||
test.describe('proposeDimensions', () => {
|
||||
test('default', async function(): Promise<any> {
|
||||
await setDimensions();
|
||||
const dimensions: {cols: number, rows: number} = await ctx.page.evaluate(`window.fit.proposeDimensions()`);
|
||||
ok(dimensions.cols > 85);
|
||||
ok(dimensions.cols < 88);
|
||||
ok(dimensions.rows > 24);
|
||||
ok(dimensions.rows < 29);
|
||||
});
|
||||
|
||||
test('width', async function(): Promise<any> {
|
||||
await setDimensions(1008);
|
||||
const dimensions: {cols: number, rows: number} = await ctx.page.evaluate(`window.fit.proposeDimensions()`);
|
||||
ok(dimensions.cols > 108);
|
||||
ok(dimensions.cols < 111);
|
||||
ok(dimensions.rows > 24);
|
||||
ok(dimensions.rows < 29);
|
||||
});
|
||||
|
||||
test('small', async function(): Promise<any> {
|
||||
await setDimensions(1, 1);
|
||||
deepEqual(await ctx.page.evaluate(`window.fit.proposeDimensions()`), {
|
||||
cols: 2,
|
||||
rows: 1
|
||||
});
|
||||
});
|
||||
|
||||
test('hidden', async function(): Promise<any> {
|
||||
await ctx.proxy.dispose();
|
||||
await ctx.page.evaluate(`document.querySelector('#terminal-container').style.display='none'`);
|
||||
await ctx.page.evaluate(`window.term = new Terminal()`);
|
||||
await ctx.page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`);
|
||||
await setDimensions();
|
||||
const dimensions: { cols: number, rows: number } | undefined = await ctx.page.evaluate(`window.fit.proposeDimensions()`);
|
||||
// The value of dims will be undefined if the char measure strategy falls back to the DOM
|
||||
// method, so only assert if it's not undefined.
|
||||
if (dimensions) {
|
||||
ok(dimensions.cols > 85);
|
||||
ok(dimensions.cols < 88);
|
||||
ok(dimensions.rows > 24);
|
||||
ok(dimensions.rows < 29);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('fit', () => {
|
||||
test('default', 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;
|
||||
ok(cols > 85);
|
||||
ok(cols < 88);
|
||||
ok(rows > 24);
|
||||
ok(rows < 29);
|
||||
});
|
||||
|
||||
test('width', async function(): Promise<any> {
|
||||
await setDimensions(1008);
|
||||
await ctx.page.evaluate(`window.fit.fit()`);
|
||||
const cols: number = await ctx.proxy.cols;
|
||||
const rows: number = await ctx.proxy.rows;
|
||||
ok(cols > 108);
|
||||
ok(cols < 111);
|
||||
ok(rows > 24);
|
||||
ok(rows < 29);
|
||||
});
|
||||
|
||||
test('small', async function(): Promise<any> {
|
||||
await setDimensions(1, 1);
|
||||
await ctx.page.evaluate(`window.fit.fit()`);
|
||||
strictEqual(await ctx.proxy.cols, 2);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function setDimensions(width: number = 800, height: number = 450): Promise<void> {
|
||||
await ctx.page.evaluate(`
|
||||
document.querySelector('#terminal-container').style.width='${width}px';
|
||||
document.querySelector('#terminal-container').style.height='${height}px';
|
||||
document.querySelector('#terminal-container').style.display='';
|
||||
`);
|
||||
// HACK: Await a short period as hiding #terminal-container can mess with other tests
|
||||
await timeout(500);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: '.',
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'FirefoxStable',
|
||||
use: {
|
||||
browserName: 'firefox'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'WebKit',
|
||||
use: {
|
||||
browserName: 'webkit'
|
||||
}
|
||||
}
|
||||
],
|
||||
reporter: 'list',
|
||||
webServer: {
|
||||
command: 'npm run start',
|
||||
port: 3000,
|
||||
timeout: 120000,
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
@@ -1,23 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2021",
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"es2015"
|
||||
"es2021",
|
||||
],
|
||||
"rootDir": ".",
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/primitives/*",
|
||||
"../../../src/runtime/*",
|
||||
],
|
||||
"target-browser/*": [
|
||||
"../../../src/target-browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha",
|
||||
"../../../node_modules/@types/node",
|
||||
"../../../out-test/api/TestUtils"
|
||||
"../../../node_modules/@types/node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../../typings/xterm.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../src"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/target-browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../test/playwright"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ declare module '@xterm/addon-fit' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprepresents the dimensions of a terminal.
|
||||
* Represents the dimensions of a terminal.
|
||||
*/
|
||||
export interface ITerminalDimensions {
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,9 @@ module.exports = {
|
||||
filename: mainFile,
|
||||
path: path.resolve('./lib'),
|
||||
library: addonName,
|
||||
libraryTarget: 'umd'
|
||||
libraryTarget: 'umd',
|
||||
// Force usage of globalThis instead of global / self. (This is cross-env compatible)
|
||||
globalObject: 'globalThis',
|
||||
},
|
||||
mode: 'production'
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ npm-debug.log
|
||||
.env
|
||||
build/
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Keep bundled code out of Git
|
||||
|
||||
@@ -6,5 +6,7 @@ tsconfig.json
|
||||
webpack.config.js
|
||||
test
|
||||
out-test
|
||||
out-esbuild
|
||||
out-esbuild-test
|
||||
inwasm-sdks
|
||||
inwasm-builds
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
## @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,12 +26,14 @@ const customSettings: IImageAddonOptions = {
|
||||
pixelLimit: 16777216, // max. pixel size of a single image
|
||||
sixelSupport: true, // enable sixel support
|
||||
sixelScrolling: true, // whether to scroll on image output
|
||||
sixelPaletteLimit: 256, // initial sixel palette size
|
||||
sixelSizeLimit: 25000000, // size limit of a single sixel sequence
|
||||
sixelPaletteLimit: 4096, // initial sixel palette size
|
||||
sixelSizeLimit: 33554432, // size limit of a single sixel sequence
|
||||
storageLimit: 128, // FIFO storage limit in MB
|
||||
showPlaceholder: true, // whether to show a placeholder for evicted images
|
||||
iipSupport: true, // enable iTerm IIP support
|
||||
iipSizeLimit: 20000000 // size limit of a single IIP sequence
|
||||
iipSizeLimit: 33554432, // size limit of a single IIP sequence
|
||||
kittySupport: true, // enable Kitty graphics support
|
||||
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
|
||||
}
|
||||
|
||||
// initialization
|
||||
@@ -41,9 +44,6 @@ terminal.loadAddon(imageAddon);
|
||||
|
||||
### 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:
|
||||
- getWinSizePixels (CSI 14 t)
|
||||
- getCellSizePixels (CSI 16 t)
|
||||
@@ -71,7 +71,7 @@ terminal.loadAddon(imageAddon);
|
||||
|
||||
- **Cursor Positioning**
|
||||
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**
|
||||
By default the addon limits the palette size to 256 registers (as demanded by the DEC specification).
|
||||
@@ -98,17 +98,24 @@ terminal.loadAddon(imageAddon);
|
||||
Set by default, change it with `{iipSupport: true}`.
|
||||
|
||||
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
|
||||
- Supported formats: PNG, JPEG and GIF
|
||||
- Supported formats: PNG, JPEG, GIF and QOI
|
||||
- No animation support.
|
||||
- Image type hinting is not supported (always deducted from data header).
|
||||
- File download is not supported.
|
||||
- Filename gets parsed but not used.
|
||||
- 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 pixel size is restricted by `pixelLimit` (pre- and post resizing).
|
||||
- Size parameter is restricted by `iipSizeLimit`.
|
||||
- Cursor positioning behaves the same as for sixel (see above).
|
||||
- Playload size is restricted by `iipSizeLimit`.
|
||||
- size parameter is not mandatory anymore.
|
||||
- Cursor gets positioned at the next cell of the last image line.
|
||||
- ReportCellSize sequence is supported.
|
||||
- Hi-res display support is currently limited (image gets blurred).
|
||||
|
||||
- **Kitty Graphics Support (TGP)**
|
||||
Set by default, change it with `{kittySupport: true}`.
|
||||
|
||||
Note that the kitty graphics support is still WIP.
|
||||
|
||||
|
||||
### Storage and Drawing Settings
|
||||
@@ -125,6 +132,7 @@ The addon exposes two properties to interact with the storage limits at runtime:
|
||||
By default the addon will show a placeholder pattern for evicted images that are still part
|
||||
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
|
||||
|
||||
|
||||
### Image Data Retrieval
|
||||
|
||||
The addon provides the following API endpoints to retrieve raw image data as canvas:
|
||||
@@ -138,6 +146,7 @@ The addon provides the following API endpoints to retrieve raw image data as can
|
||||
The buffer position is the 0-based absolute index (including scrollback at top).
|
||||
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
|
||||
|
||||
|
||||
### 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:
|
||||
@@ -178,8 +187,8 @@ _How can I adjust the memory usage?_
|
||||
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page.
|
||||
- `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.
|
||||
- `sixelSizeLimit`
|
||||
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.
|
||||
- `sixelSizeLimit | iipSizeLimit | kittySizeLimit`
|
||||
Constructor settings. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken image sequences at an earlier phase, thus may stop the invoked decoders from entering memory intensive tasks for potentially invalid data.
|
||||
|
||||
|
||||
### Terminal Interaction
|
||||
@@ -209,23 +218,5 @@ _How can I adjust the memory usage?_
|
||||
|
||||
### Status
|
||||
|
||||
- Sixel support and image handling in xterm.js is considered beta quality.
|
||||
- IIP support is in alpha stage. Please file a bug for any awkwardities.
|
||||
|
||||
|
||||
### 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
|
||||
- Sixel and IIP support and image handling in xterm.js is considered beta quality.
|
||||
- Kitty support is in alpha stage. Please file a bug for any awkwardities or missing features.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
Binary file not shown.
|
After Width: | Height: | Size: 449 B |
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@xterm/addon-image",
|
||||
"version": "0.7.0",
|
||||
"version": "0.9.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
},
|
||||
"main": "lib/addon-image.js",
|
||||
"module": "lib/addon-image.mjs",
|
||||
"types": "typings/addon-image.d.ts",
|
||||
"repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image",
|
||||
"license": "MIT",
|
||||
@@ -17,15 +18,13 @@
|
||||
"xterm.js"
|
||||
],
|
||||
"scripts": {
|
||||
"prepackage": "../../node_modules/.bin/tsc -p .",
|
||||
"prepackage": "../../node_modules/.bin/tsgo -p .",
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.2.0"
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sixel": "^0.16.0",
|
||||
"xterm-wasm-parts": "^0.1.0"
|
||||
"xterm-wasm-parts": "^0.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,26 @@
|
||||
*/
|
||||
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { IIPImageStorage } from './IIPImageStorage';
|
||||
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
||||
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState, SequenceType } from './IIPHeaderParser';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const Buffer: any;
|
||||
|
||||
// limit hold memory in base64 decoder
|
||||
const KEEP_DATA = 4194304;
|
||||
// Local const enum mirror - esbuild can't inline const enums from external packages
|
||||
const enum DecoderConst {
|
||||
// Limit held memory in base64 decoder (encoded bytes).
|
||||
KEEP_DATA = 4194304,
|
||||
// Initial buffer allocation for the decoder.
|
||||
INITIAL_DATA = 1048576,
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
OK = 0
|
||||
}
|
||||
|
||||
// default IIP header values
|
||||
const DEFAULT_HEADER: IHeaderFields = {
|
||||
type: SequenceType.INVALID,
|
||||
name: 'Unnamed file',
|
||||
size: 0,
|
||||
width: 'auto',
|
||||
@@ -31,21 +37,32 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
private _aborted = false;
|
||||
private _hp = new HeaderParser();
|
||||
private _header: IHeaderFields = DEFAULT_HEADER;
|
||||
private _dec = new Base64Decoder(KEEP_DATA);
|
||||
private _dec: Base64Decoder;
|
||||
private _qoiDec: QoiDecoder;
|
||||
private _metrics = UNSUPPORTED_TYPE;
|
||||
private _isMultipart = false;
|
||||
private _abortMulti = false;
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _storage: ImageStorage,
|
||||
private readonly _storage: IIPImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {}
|
||||
) {
|
||||
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
||||
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
||||
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
|
||||
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
|
||||
}
|
||||
|
||||
public reset(): void {}
|
||||
public reset(): void {
|
||||
this._hp.reset();
|
||||
this._dec.release();
|
||||
this._qoiDec.release();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this._aborted = false;
|
||||
this._header = DEFAULT_HEADER;
|
||||
this._metrics = UNSUPPORTED_TYPE;
|
||||
this._hp.reset();
|
||||
}
|
||||
@@ -54,7 +71,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
if (this._aborted) return;
|
||||
|
||||
if (this._hp.state === HeaderState.END) {
|
||||
if (this._dec.put(data, start, end)) {
|
||||
if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
}
|
||||
@@ -65,15 +82,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return;
|
||||
}
|
||||
if (dataPos > 0) {
|
||||
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||
if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) {
|
||||
const seqType = this._hp.fields.type;
|
||||
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;
|
||||
return;
|
||||
}
|
||||
this._dec.init(this._header.size);
|
||||
if (this._dec.put(data, dataPos, end)) {
|
||||
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
if (this._isMultipart) this._abortMulti = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,11 +111,49 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
public end(success: boolean): boolean | Promise<boolean> {
|
||||
if (this._aborted) return true;
|
||||
|
||||
if (this._hp.state !== HeaderState.END) {
|
||||
if (this._hp.end()) return true;
|
||||
}
|
||||
const seqType = this._hp.fields.type;
|
||||
|
||||
if (seqType === SequenceType.FILEPART) return true;
|
||||
|
||||
if (seqType === SequenceType.REPORTCELLSIZE) {
|
||||
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
|
||||
let width = CELL_SIZE_DEFAULT.width;
|
||||
let height = CELL_SIZE_DEFAULT.height;
|
||||
if (this._renderer.dimensions) {
|
||||
width = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
|
||||
height = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
|
||||
}
|
||||
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
|
||||
const report = `\x1b]1337;ReportCellSize=${height.toFixed(3)};${width.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
|
||||
this._coreTerminal.input(report, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (seqType === SequenceType.MULTIPARTFILE) {
|
||||
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
|
||||
this._isMultipart = true;
|
||||
this._abortMulti = false;
|
||||
this._dec.release();
|
||||
this._dec.init();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (seqType === SequenceType.FILEEND) {
|
||||
if (!this._isMultipart) return true;
|
||||
this._isMultipart = false;
|
||||
if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;
|
||||
}
|
||||
|
||||
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
|
||||
// early exit condition chain
|
||||
let cond: number | boolean = true;
|
||||
let cond: number | boolean;
|
||||
if (cond = success) {
|
||||
if (cond = !this._dec.end()) {
|
||||
this._metrics = imageType(this._dec.data8);
|
||||
@@ -105,26 +172,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
const blob = new Blob([this._dec.data8], { type: this._metrics.mime });
|
||||
this._dec.release();
|
||||
|
||||
if (!window.createImageBitmap) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
return new Promise<boolean>(r => {
|
||||
img.addEventListener('load', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = ImageRenderer.createCanvas(window.document, w, h);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
|
||||
this._storage.addImage(canvas);
|
||||
r(true);
|
||||
});
|
||||
img.src = url;
|
||||
// sanity measure to avoid terminal blocking from dangling promise
|
||||
// happens from corrupt data (onload never gets fired)
|
||||
setTimeout(() => r(true), 1000);
|
||||
});
|
||||
let blob: Blob | ImageData;
|
||||
if (this._metrics.mime === 'image/qoi') {
|
||||
const data = this._qoiDec.decode(this._dec.data8);
|
||||
blob = new ImageData(
|
||||
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
||||
this._qoiDec.width,
|
||||
this._qoiDec.height
|
||||
);
|
||||
this._qoiDec.release();
|
||||
if (w === this._qoiDec.width && h === this._qoiDec.height) {
|
||||
// use fast-path if we don't need to rescale
|
||||
this._dec.release();
|
||||
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
|
||||
canvas.getContext('2d')?.putImageData(blob, 0, 0);
|
||||
this._storage.addImage(canvas);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([this._dec.data8], { type: this._metrics.mime });
|
||||
}
|
||||
this._dec.release();
|
||||
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
||||
.then(bm => {
|
||||
this._storage.addImage(bm);
|
||||
|
||||
@@ -4,19 +4,20 @@
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser';
|
||||
import { HeaderParser, HeaderState, IHeaderFields, SequenceType } from './IIPHeaderParser';
|
||||
|
||||
|
||||
const CASES: [string, IHeaderFields][] = [
|
||||
['File=size=123456;name=dGVzdA==:', {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}],
|
||||
['File=size=123456;name=dGVzdA:', {type: SequenceType.FILE, name: 'test', size: 123456}],
|
||||
// utf-8 encoding in name
|
||||
['File=size=123456;name=w7xtbMOkdXTDnw==:', {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}],
|
||||
['File=size=123456;name=w7xtbMOkdXTDnw:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
|
||||
// full header spec
|
||||
[
|
||||
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||
{
|
||||
type: SequenceType.FILE,
|
||||
inline: 1,
|
||||
width: '10px',
|
||||
height: '20%',
|
||||
@@ -28,6 +29,7 @@ const CASES: [string, IHeaderFields][] = [
|
||||
[
|
||||
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
|
||||
{
|
||||
type: SequenceType.FILE,
|
||||
inline: 1,
|
||||
width: 'auto',
|
||||
height: '20',
|
||||
@@ -101,7 +103,7 @@ describe('IIPHeaderParser', () => {
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
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();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
@@ -115,7 +117,7 @@ describe('IIPHeaderParser', () => {
|
||||
let res = hp.parse(inp, 0, inp.length);
|
||||
assert.strictEqual(res, inp.length);
|
||||
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();
|
||||
inp = fromBs(CASES[0][0]);
|
||||
res = hp.parse(inp, 0, inp.length);
|
||||
@@ -135,4 +137,124 @@ describe('IIPHeaderParser', () => {
|
||||
assert.strictEqual(hp.state, HeaderState.END);
|
||||
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,8 +6,27 @@
|
||||
// eslint-disable-next-line
|
||||
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 {
|
||||
[key: string]: number | string | Uint32Array | null | undefined;
|
||||
// sequence type
|
||||
type: SequenceType;
|
||||
// base-64 encoded filename. Defaults to "Unnamed file".
|
||||
name: string;
|
||||
// File size in bytes. The file transfer will be canceled if this size is exceeded.
|
||||
@@ -28,14 +47,6 @@ export interface IHeaderFields {
|
||||
inline?: number;
|
||||
}
|
||||
|
||||
export const enum HeaderState {
|
||||
START = 0,
|
||||
ABORT = 1,
|
||||
KEY = 2,
|
||||
VALUE = 3,
|
||||
END = 4
|
||||
}
|
||||
|
||||
// field value decoders
|
||||
|
||||
// ASCII bytes to string
|
||||
@@ -81,7 +92,7 @@ function toName(data: Uint32Array): string {
|
||||
return new TextDecoder().decode(b);
|
||||
}
|
||||
|
||||
const DECODERS: {[key: string]: (v: Uint32Array) => any} = {
|
||||
const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
|
||||
inline: toInt,
|
||||
size: toInt,
|
||||
name: toName,
|
||||
@@ -91,7 +102,19 @@ const DECODERS: {[key: string]: (v: Uint32Array) => any} = {
|
||||
};
|
||||
|
||||
|
||||
// sequence type markers
|
||||
// File
|
||||
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;
|
||||
|
||||
|
||||
@@ -100,7 +123,7 @@ export class HeaderParser {
|
||||
private _buffer = new Uint32Array(MAX_FIELDCHARS);
|
||||
private _position = 0;
|
||||
private _key = '';
|
||||
public fields: {[key: string]: any} = {};
|
||||
public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};
|
||||
|
||||
public reset(): void {
|
||||
this._buffer.fill(0);
|
||||
@@ -110,12 +133,43 @@ export class HeaderParser {
|
||||
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 {
|
||||
let state = this.state;
|
||||
let pos = this._position;
|
||||
const buffer = this._buffer;
|
||||
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) {
|
||||
const c = data[i];
|
||||
switch (c) {
|
||||
@@ -126,8 +180,29 @@ export class HeaderParser {
|
||||
break;
|
||||
case 61: // =
|
||||
if (state === HeaderState.START) {
|
||||
for (let k = 0; k < FILE_MARKER.length; ++k) {
|
||||
if (buffer[k] !== FILE_MARKER[k]) return this._a();
|
||||
if (buffer[0] === 70) {
|
||||
// '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;
|
||||
pos = 0;
|
||||
@@ -157,6 +232,7 @@ export class HeaderParser {
|
||||
}
|
||||
|
||||
private _a(): number {
|
||||
this.fields.type = SequenceType.INVALID;
|
||||
this.state = HeaderState.ABORT;
|
||||
return -1;
|
||||
}
|
||||
@@ -176,7 +252,7 @@ export class HeaderParser {
|
||||
try {
|
||||
const v = this._buffer.slice(0, pos);
|
||||
this.fields[this._key] = DECODERS[this._key] ? DECODERS[this._key](v) : v;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,13 @@ const TEST_IMAGES: [string, IMetrics][] = [
|
||||
['w3c_home_gray.png', { mime: 'image/png', width: 72, height: 48 }],
|
||||
['w3c_home.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
||||
['w3c_home.png', { mime: 'image/png', width: 72, height: 48 }],
|
||||
['w3c_home_noexif.jpg', { mime: 'image/jpeg', width: 72, height: 48 }],
|
||||
['spinfox.png', { mime: 'image/png', width: 148, height: 148 }],
|
||||
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
|
||||
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
|
||||
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
|
||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }]
|
||||
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
|
||||
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
|
||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
|
||||
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
|
||||
|
||||
export interface IMetrics {
|
||||
mime: ImageType;
|
||||
@@ -32,13 +32,8 @@ export function imageType(d: Uint8Array): IMetrics {
|
||||
height: d[20] << 24 | d[21] << 16 | d[22] << 8 | d[23]
|
||||
};
|
||||
}
|
||||
// JPEG: FF D8 FF E0 xx xx JFIF or FF D8 FF E1 xx xx Exif 00 00
|
||||
if ((d32[0] === 0xE0FFD8FF || d32[0] === 0xE1FFD8FF)
|
||||
&& (
|
||||
(d[6] === 0x4a && d[7] === 0x46 && d[8] === 0x49 && d[9] === 0x46)
|
||||
|| (d[6] === 0x45 && d[7] === 0x78 && d[8] === 0x69 && d[9] === 0x66)
|
||||
)
|
||||
) {
|
||||
// JPEG: FF D8 FF
|
||||
if (d[0] === 0xFF && d[1] === 0xD8 && d[2] === 0xFF) {
|
||||
const [width, height] = jpgSize(d);
|
||||
return { mime: 'image/jpeg', width, height };
|
||||
}
|
||||
@@ -50,6 +45,14 @@ export function imageType(d: Uint8Array): IMetrics {
|
||||
height: d[9] << 8 | d[8]
|
||||
};
|
||||
}
|
||||
// QOI: qoif
|
||||
if (d32[0] === 0x66696F71) {
|
||||
return {
|
||||
mime: 'image/qoi',
|
||||
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
|
||||
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
|
||||
};
|
||||
}
|
||||
return UNSUPPORTED_TYPE;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,24 +5,72 @@
|
||||
|
||||
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
|
||||
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
|
||||
import { Emitter, type IEvent } from 'common/Event';
|
||||
import { IIPHandler } from './IIPHandler';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
|
||||
import { KittyImageStorage } from './kitty/KittyImageStorage';
|
||||
import { SixelHandler } from './SixelHandler';
|
||||
import { SixelImageStorage } from './SixelImageStorage';
|
||||
import { IIPImageStorage } from './IIPImageStorage';
|
||||
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
|
||||
const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
enableSizeReports: true,
|
||||
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
|
||||
sixelSupport: true,
|
||||
sixelScrolling: true,
|
||||
sixelPaletteLimit: 256,
|
||||
sixelSizeLimit: 25000000,
|
||||
sixelPaletteLimit: 4096,
|
||||
sixelSizeLimit: 33554432,
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000
|
||||
iipSizeLimit: 33554432,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 33554432
|
||||
};
|
||||
|
||||
// max palette size supported by the sixel lib (compile time setting)
|
||||
@@ -48,7 +96,7 @@ const enum GaStatus {
|
||||
}
|
||||
|
||||
|
||||
export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
private _opts: IImageAddonOptions;
|
||||
private _defaultOpts: IImageAddonOptions;
|
||||
private _storage: ImageStorage | undefined;
|
||||
@@ -56,6 +104,8 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
private _disposables: IDisposable[] = [];
|
||||
private _terminal: ITerminalExt | undefined;
|
||||
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>) {
|
||||
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
|
||||
@@ -68,6 +118,7 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
}
|
||||
this._disposables.length = 0;
|
||||
this._handlers.clear();
|
||||
this._onImageAdded.dispose();
|
||||
}
|
||||
|
||||
private _disposeLater(...args: IDisposable[]): void {
|
||||
@@ -82,15 +133,11 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
// internal data structures
|
||||
this._renderer = new ImageRenderer(terminal);
|
||||
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
|
||||
this._storage.onImageAdded = () => this._onImageAdded.fire();
|
||||
|
||||
// enable size reports
|
||||
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.getCellSizePixels = true;
|
||||
windowOps.getWinSizeChars = true;
|
||||
@@ -129,7 +176,8 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
|
||||
// SIXEL handler
|
||||
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._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
|
||||
@@ -138,12 +186,25 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
|
||||
// iTerm IIP handler
|
||||
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._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||
);
|
||||
}
|
||||
|
||||
// Kitty graphics handler
|
||||
if (this._opts.kittySupport) {
|
||||
const kittyStorage = new KittyImageStorage(this._storage!);
|
||||
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
|
||||
this._handlers.set('kitty', kittyHandler);
|
||||
this._disposeLater(
|
||||
kittyStorage,
|
||||
kittyHandler,
|
||||
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
|
||||
@@ -194,7 +255,7 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
}
|
||||
|
||||
private _report(s: string): void {
|
||||
this._terminal?._core.coreService.triggerDataEvent(s);
|
||||
this._terminal?._core.input(s, false);
|
||||
}
|
||||
|
||||
private _decset(params: (number | number[])[]): boolean {
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
|
||||
import { toRGBA8888 } from 'sixel/lib/Colors';
|
||||
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 'common/Lifecycle';
|
||||
|
||||
|
||||
const PLACEHOLDER_LENGTH = 4096;
|
||||
const PLACEHOLDER_HEIGHT = 24;
|
||||
const enum Constants {
|
||||
PLACEHOLDER_LENGTH = 4096,
|
||||
PLACEHOLDER_HEIGHT = 24
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageRenderer - terminal frontend extension:
|
||||
@@ -19,11 +20,12 @@ const PLACEHOLDER_HEIGHT = 24;
|
||||
* - draw image tiles onRender
|
||||
*/
|
||||
export class ImageRenderer extends Disposable implements IDisposable {
|
||||
public canvas: HTMLCanvasElement | undefined;
|
||||
private _ctx: CanvasRenderingContext2D | null | undefined;
|
||||
/** @deprecated Kept for backward compat — points to top layer canvas. */
|
||||
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
|
||||
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
|
||||
private _placeholder: HTMLCanvasElement | undefined;
|
||||
private _placeholderBitmap: ImageBitmap | undefined;
|
||||
private _optionsRefresh = this.register(new MutableDisposable());
|
||||
private _optionsRefresh = this._register(new MutableDisposable());
|
||||
private _oldOpen: ((parent: HTMLElement) => void) | undefined;
|
||||
private _renderService: IRenderService | undefined;
|
||||
private _oldSetRenderer: ((renderer: any) => void) | undefined;
|
||||
@@ -39,7 +41,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
* Only the DOM output canvas should be on the terminal's document,
|
||||
* which gets explicitly checked in `insertLayerToDom`.
|
||||
*/
|
||||
const canvas = (localDocument || document).createElement('canvas');
|
||||
const canvas = (localDocument ?? document).createElement('canvas');
|
||||
canvas.width = width | 0;
|
||||
canvas.height = height | 0;
|
||||
return canvas;
|
||||
@@ -85,8 +87,9 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
this._renderService?.refreshRows(0, this._terminal.rows);
|
||||
}
|
||||
});
|
||||
this.register(toDisposable(() => {
|
||||
this._register(toDisposable(() => {
|
||||
this.removeLayerFromDom();
|
||||
this.removeLayerFromDom('bottom');
|
||||
if (this._terminal._core && this._oldOpen) {
|
||||
this._terminal._core.open = this._oldOpen;
|
||||
this._oldOpen = undefined;
|
||||
@@ -96,8 +99,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
this._oldSetRenderer = undefined;
|
||||
}
|
||||
this._renderService = undefined;
|
||||
this.canvas = undefined;
|
||||
this._ctx = undefined;
|
||||
this._layers.clear();
|
||||
this._placeholderBitmap?.close();
|
||||
this._placeholderBitmap = undefined;
|
||||
this._placeholder = undefined;
|
||||
@@ -110,7 +112,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
public showPlaceholder(value: boolean): void {
|
||||
if (value) {
|
||||
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 {
|
||||
this._placeholderBitmap?.close();
|
||||
@@ -125,7 +127,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
* Forwarded from internal render service.
|
||||
*/
|
||||
public get dimensions(): IRenderDimensions | undefined {
|
||||
return this._renderService?.dimensions;
|
||||
return this._terminal.dimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,27 +143,38 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
/**
|
||||
* Clear a region of the image layer canvas.
|
||||
*/
|
||||
public clearLines(start: number, end: number): void {
|
||||
this._ctx?.clearRect(
|
||||
0,
|
||||
start * (this.dimensions?.css.cell.height || 0),
|
||||
this.dimensions?.css.canvas.width || 0,
|
||||
(++end - start) * (this.dimensions?.css.cell.height || 0)
|
||||
);
|
||||
public clearLines(start: number, end: number, layer?: ImageLayer): void {
|
||||
const y = start * (this.dimensions?.css.cell.height || 0);
|
||||
const w = this.dimensions?.css.canvas.width || 0;
|
||||
const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);
|
||||
if (!layer || layer === 'top') {
|
||||
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.
|
||||
*/
|
||||
public clearAll(): void {
|
||||
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
|
||||
public clearAll(layer?: ImageLayer): void {
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
const { width, height } = this.cellSize;
|
||||
@@ -188,7 +201,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
// 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).
|
||||
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
|
||||
this._ctx.drawImage(
|
||||
ctx.drawImage(
|
||||
img,
|
||||
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
|
||||
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
|
||||
@@ -228,7 +241,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
* Draw a line with placeholder on the image layer canvas.
|
||||
*/
|
||||
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;
|
||||
|
||||
// Don't try to draw anything, if we cannot get valid renderer metrics.
|
||||
@@ -237,13 +251,13 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
}
|
||||
|
||||
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) {
|
||||
this._createPlaceHolder(height + 1);
|
||||
}
|
||||
if (!this._placeholder) return;
|
||||
this._ctx.drawImage(
|
||||
this._placeholderBitmap || this._placeholder!,
|
||||
ctx.drawImage(
|
||||
this._placeholderBitmap ?? this._placeholder!,
|
||||
col * width,
|
||||
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
|
||||
width * count,
|
||||
@@ -261,12 +275,13 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
* Checked once from `ImageStorage.render`.
|
||||
*/
|
||||
public rescaleCanvas(): void {
|
||||
if (!this.canvas) {
|
||||
return;
|
||||
}
|
||||
if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
|
||||
this.canvas.width = this.dimensions!.css.canvas.width || 0;
|
||||
this.canvas.height = this.dimensions!.css.canvas.height || 0;
|
||||
const w = this.dimensions?.css.canvas.width || 0;
|
||||
const h = this.dimensions?.css.canvas.height || 0;
|
||||
for (const ctx of this._layers.values()) {
|
||||
if (ctx.canvas.width !== w || ctx.canvas.height !== h) {
|
||||
ctx.canvas.width = w;
|
||||
ctx.canvas.height = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,38 +320,68 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
this._renderService = this._terminal._core._renderService;
|
||||
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
|
||||
this._renderService.setRenderer = (renderer: any) => {
|
||||
this.removeLayerFromDom();
|
||||
for (const key of [...this._layers.keys()]) {
|
||||
this.removeLayerFromDom(key);
|
||||
}
|
||||
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
|
||||
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 {
|
||||
if (!this.document || !this._terminal._core.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 {
|
||||
if (this.canvas) {
|
||||
this._ctx = undefined;
|
||||
this.canvas.remove();
|
||||
this.canvas = undefined;
|
||||
}
|
||||
public hasLayer(layer: ImageLayer): boolean {
|
||||
return this._layers.has(layer);
|
||||
}
|
||||
|
||||
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
|
||||
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
|
||||
this._placeholderBitmap?.close();
|
||||
this._placeholderBitmap = undefined;
|
||||
|
||||
@@ -360,7 +405,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
// 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);
|
||||
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
|
||||
if (!ctx2) {
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
|
||||
import {
|
||||
ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,
|
||||
IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,
|
||||
UnderlineStyle, IAddImageOpts
|
||||
} from './Types';
|
||||
|
||||
|
||||
// fallback default cell size
|
||||
@@ -124,6 +128,8 @@ export class ImageStorage implements IDisposable {
|
||||
private _pixelLimit: number = 2500000;
|
||||
|
||||
private _viewportMetrics: { cols: number, rows: number };
|
||||
public onImageAdded: (() => void) | undefined;
|
||||
public onImageDeleted: ((storageId: number) => void) | undefined;
|
||||
|
||||
constructor(
|
||||
private _terminal: ITerminalExt,
|
||||
@@ -132,8 +138,10 @@ export class ImageStorage implements IDisposable {
|
||||
) {
|
||||
try {
|
||||
this.setLimit(this._opts.storageLimit);
|
||||
} catch (e: any) {
|
||||
console.error(e.message);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof Error) {
|
||||
console.error(e.message);
|
||||
}
|
||||
console.warn(`storageLimit is set to ${this.getLimit()} MB`);
|
||||
}
|
||||
this._viewportMetrics = {
|
||||
@@ -187,11 +195,13 @@ export class ImageStorage implements IDisposable {
|
||||
|
||||
private _delImg(id: number): void {
|
||||
const spec = this._images.get(id);
|
||||
if (!spec) return;
|
||||
this._images.delete(id);
|
||||
// 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();
|
||||
}
|
||||
this.onImageDeleted?.(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,27 +225,29 @@ export class ImageStorage implements IDisposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Delete an image by its internal storage ID.
|
||||
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
||||
*/
|
||||
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();
|
||||
}
|
||||
public deleteImage(id: number): void {
|
||||
const spec = this._images.get(id);
|
||||
if (spec) {
|
||||
spec.marker?.dispose();
|
||||
this._delImg(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to add an image to the storage.
|
||||
* @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): void {
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
|
||||
// never allow storage to exceed memory limit
|
||||
this._evictOldest(img.width * img.height);
|
||||
|
||||
@@ -257,7 +269,7 @@ export class ImageStorage implements IDisposable {
|
||||
let offset = originX;
|
||||
let tileCount = 0;
|
||||
|
||||
if (!this._opts.sixelScrolling) {
|
||||
if (!opts.scrolling) {
|
||||
buffer.x = 0;
|
||||
buffer.y = 0;
|
||||
offset = 0;
|
||||
@@ -271,7 +283,7 @@ export class ImageStorage implements IDisposable {
|
||||
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
|
||||
tileCount++;
|
||||
}
|
||||
if (this._opts.sixelScrolling) {
|
||||
if (opts.scrolling) {
|
||||
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
|
||||
} else {
|
||||
if (++buffer.y >= termRows) break;
|
||||
@@ -281,8 +293,12 @@ export class ImageStorage implements IDisposable {
|
||||
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
|
||||
|
||||
// cursor positioning modes
|
||||
if (this._opts.sixelScrolling) {
|
||||
buffer.x = offset;
|
||||
if (opts.scrolling) {
|
||||
if (opts.cursorPos === 'iip') {
|
||||
buffer.x = Math.min(offset + cols, termCols);
|
||||
} else {
|
||||
buffer.x = offset;
|
||||
}
|
||||
} else {
|
||||
buffer.x = originX;
|
||||
buffer.y = originY;
|
||||
@@ -324,11 +340,15 @@ export class ImageStorage implements IDisposable {
|
||||
actualCellSize: { ...cellSize }, // clone needed, since later modified
|
||||
marker: endMarker || undefined,
|
||||
tileCount,
|
||||
bufferType: this._terminal.buffer.active.type
|
||||
bufferType: this._terminal.buffer.active.type,
|
||||
layer: opts.layer,
|
||||
zIndex: opts.zIndex
|
||||
};
|
||||
|
||||
// finally add the image
|
||||
this._images.set(imageId, imgSpec);
|
||||
this.onImageAdded?.();
|
||||
return imageId;
|
||||
}
|
||||
|
||||
|
||||
@@ -338,16 +358,30 @@ export class ImageStorage implements IDisposable {
|
||||
*/
|
||||
// TODO: Should we move this to the ImageRenderer?
|
||||
public render(range: { start: number, end: number }): void {
|
||||
// setup image canvas in case we have none yet, but have images in store
|
||||
if (!this._renderer.canvas && this._images.size) {
|
||||
this._renderer.insertLayerToDom();
|
||||
// safety measure - in case we cannot spawn a canvas at all, just exit
|
||||
if (!this._renderer.canvas) {
|
||||
return;
|
||||
// Determine which layers have images
|
||||
let hasTopImages = false;
|
||||
let hasBottomImages = false;
|
||||
for (const spec of this._images.values()) {
|
||||
if (spec.layer === 'bottom') {
|
||||
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
|
||||
this._renderer.rescaleCanvas();
|
||||
|
||||
// exit early if we dont have any images to test for
|
||||
if (!this._images.size) {
|
||||
if (!this._fullyCleared) {
|
||||
@@ -355,12 +389,25 @@ export class ImageStorage implements IDisposable {
|
||||
this._fullyCleared = true;
|
||||
this._needsFullClear = false;
|
||||
}
|
||||
if (this._renderer.canvas) {
|
||||
this._renderer.removeLayerFromDom();
|
||||
if (this._renderer.hasLayer('top')) {
|
||||
this._renderer.removeLayerFromDom('top');
|
||||
}
|
||||
if (this._renderer.hasLayer('bottom')) {
|
||||
this._renderer.removeLayerFromDom('bottom');
|
||||
}
|
||||
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
|
||||
if (this._needsFullClear) {
|
||||
this._renderer.clearAll();
|
||||
@@ -375,50 +422,76 @@ export class ImageStorage implements IDisposable {
|
||||
// clear drawing area
|
||||
this._renderer.clearLines(start, end);
|
||||
|
||||
// walk all cells in viewport and draw tiles found
|
||||
// Collect draw calls so we can sort by z-index (lower z drawn first).
|
||||
const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];
|
||||
const placeholderCalls: { col: number, row: number, count: number }[] = [];
|
||||
|
||||
// walk all cells in viewport and collect tiles found
|
||||
// Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag)
|
||||
// because text writes clear the BG flag but leave image tile data intact.
|
||||
// This lets top-layer images survive text overwrites (kitty C=1 behavior).
|
||||
for (let row = start; row <= end; ++row) {
|
||||
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
|
||||
if (!line) return;
|
||||
for (let col = 0; col < cols; ++col) {
|
||||
let e: IExtendedAttrsImage;
|
||||
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
|
||||
let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS;
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
e = line._extendedAttrs[col] ?? EMPTY_ATTRS;
|
||||
} else {
|
||||
const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
|
||||
if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) {
|
||||
continue;
|
||||
}
|
||||
const imgSpec = this._images.get(imageId);
|
||||
if (e.tileId !== -1) {
|
||||
const startTile = e.tileId;
|
||||
const startCol = col;
|
||||
let count = 1;
|
||||
/**
|
||||
* merge tiles to the right into a single draw call, if:
|
||||
* - not at end of line
|
||||
* - cell has same image id
|
||||
* - cell has consecutive tile id
|
||||
*/
|
||||
while (
|
||||
++col < cols
|
||||
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
|
||||
&& (e = line._extendedAttrs[col] || EMPTY_ATTRS)
|
||||
&& (e.imageId === imageId)
|
||||
&& (e.tileId === startTile + count)
|
||||
) {
|
||||
count++;
|
||||
e = maybeImg;
|
||||
}
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
}
|
||||
const imgSpec = this._images.get(imageId);
|
||||
if (e.tileId !== -1) {
|
||||
const startTile = e.tileId;
|
||||
const startCol = col;
|
||||
let count = 1;
|
||||
/**
|
||||
* merge tiles to the right into a single draw call, if:
|
||||
* - not at end of line
|
||||
* - cell has same image id
|
||||
* - cell has consecutive tile id
|
||||
* Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED.
|
||||
*/
|
||||
while (++col < cols) {
|
||||
const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
|
||||
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
|
||||
break;
|
||||
}
|
||||
col--;
|
||||
if (imgSpec) {
|
||||
if (imgSpec.actual) {
|
||||
this._renderer.draw(imgSpec, startTile, startCol, row, count);
|
||||
}
|
||||
} else if (this._opts.showPlaceholder) {
|
||||
this._renderer.drawPlaceholder(startCol, row, count);
|
||||
}
|
||||
this._fullyCleared = false;
|
||||
count++;
|
||||
}
|
||||
col--;
|
||||
if (imgSpec) {
|
||||
if (imgSpec.actual) {
|
||||
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
|
||||
}
|
||||
} else if (this._opts.showPlaceholder) {
|
||||
placeholderCalls.push({ col: startCol, row, count });
|
||||
}
|
||||
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 {
|
||||
@@ -442,7 +515,7 @@ export class ImageStorage implements IDisposable {
|
||||
for (let row = 0; row < rows; ++row) {
|
||||
const line = buffer.lines.get(row) as IBufferLineExt;
|
||||
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] || EMPTY_ATTRS;
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
@@ -487,7 +560,7 @@ export class ImageStorage implements IDisposable {
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
|
||||
if (e.imageId && e.imageId !== -1) {
|
||||
const orig = this._images.get(e.imageId)?.orig;
|
||||
if (window.ImageBitmap && orig instanceof ImageBitmap) {
|
||||
@@ -507,7 +580,7 @@ export class ImageStorage implements IDisposable {
|
||||
const buffer = this._terminal._core.buffer;
|
||||
const line = buffer.lines.get(y) as IBufferLineExt;
|
||||
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
|
||||
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
|
||||
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
|
||||
const spec = this._images.get(e.imageId);
|
||||
if (spec) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ImageStorage } from './ImageStorage';
|
||||
import { SixelImageStorage } from './SixelImageStorage';
|
||||
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 { RGBA8888 } from 'sixel/lib/Types';
|
||||
@@ -26,7 +26,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _storage: ImageStorage,
|
||||
private readonly _storage: SixelImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {
|
||||
DecoderAsync({
|
||||
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
||||
const height = this._dec.height;
|
||||
|
||||
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
||||
if (!width || ! height) {
|
||||
if (!width || !height) {
|
||||
if (height) {
|
||||
this._storage.advanceCursor(height);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
||||
}
|
||||
|
||||
const canvas = ImageRenderer.createCanvas(undefined, width, height);
|
||||
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8, width, height), 0, 0);
|
||||
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0);
|
||||
if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
|
||||
this._dec.release();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
|
||||
// private imports from base repo we build against
|
||||
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import type { AttributeData } from 'common/buffer/AttributeData';
|
||||
import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
|
||||
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||
import type { ITerminal, ReadonlyColorSet } from 'target-browser/Types';
|
||||
import type { IRenderDimensions } from 'target-browser/renderer/shared/Types';
|
||||
import type { ICoreBrowserService, IRenderService, IThemeService } from 'target-browser/services/Services';
|
||||
|
||||
export const enum Cell {
|
||||
CONTENT = 0, // codepoint and wcwidth information (enum Content)
|
||||
@@ -22,7 +22,7 @@ export const enum Cell {
|
||||
}
|
||||
|
||||
// export some privates for local usage
|
||||
export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
||||
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
||||
|
||||
/**
|
||||
* Plugin ctor options.
|
||||
@@ -38,6 +38,8 @@ export interface IImageAddonOptions {
|
||||
sixelSizeLimit: number;
|
||||
iipSupport: boolean;
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
}
|
||||
|
||||
export interface IResetHandler {
|
||||
@@ -97,6 +99,16 @@ export interface ICellSize {
|
||||
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 {
|
||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||
origCellSize: ICellSize;
|
||||
@@ -105,4 +117,6 @@ export interface IImageSpec {
|
||||
marker: IMarker | undefined;
|
||||
tileCount: number;
|
||||
bufferType: 'alternate' | 'normal';
|
||||
layer: ImageLayer;
|
||||
zIndex: number;
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
|
||||
import { ImageRenderer } from '../ImageRenderer';
|
||||
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
|
||||
import { KittyImageStorage } from './KittyImageStorage';
|
||||
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import {
|
||||
KittyAction,
|
||||
KittyFormat,
|
||||
KittyCompression,
|
||||
IKittyCommand,
|
||||
IPendingTransmission,
|
||||
IKittyImageData,
|
||||
KittyPixelConstants,
|
||||
parseKittyCommand
|
||||
} from './KittyGraphicsTypes';
|
||||
|
||||
const enum Constants {
|
||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||
DECODER_KEEP_DATA = 4194304,
|
||||
DECODER_INITIAL_DATA = 4194304, // 4MB
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
DECODER_OK = 0,
|
||||
// Maximum control data size
|
||||
MAX_CONTROL_DATA_SIZE = 512,
|
||||
// Semicolon codepoint
|
||||
SEMICOLON = 0x3B
|
||||
}
|
||||
|
||||
const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
|
||||
|
||||
// Kitty graphics protocol handler with streaming base64 decoding.
|
||||
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
|
||||
private _aborted = false;
|
||||
private _decodeError = false;
|
||||
|
||||
private _activeDecoder: Base64Decoder | null = null;
|
||||
private readonly _maxEncodedBytes: number;
|
||||
private readonly _initialEncodedBytes: number;
|
||||
|
||||
// Streaming related states
|
||||
|
||||
// True while receiving control data (before semicolon).
|
||||
private _inControlData = true;
|
||||
|
||||
// Buffer for control data.
|
||||
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
|
||||
private _controlLength = 0;
|
||||
|
||||
// Pre-calculated encoded size limit
|
||||
private _encodedSizeLimit = 0;
|
||||
private _totalEncodedSize = 0;
|
||||
|
||||
// Parsed command. These are the control data before semicolon.
|
||||
private _parsedCommand: IKittyCommand | null = null;
|
||||
|
||||
// Storage related states
|
||||
|
||||
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
|
||||
// Tracks the pending key of the most recently started chunked upload.
|
||||
// Per spec, subsequent chunks only need m= (and optionally q=), without i=.
|
||||
// When a chunk arrives with no i=, this key is used to find the pending upload.
|
||||
private _lastPendingKey: number | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _kittyStorage: KittyImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {
|
||||
// Convert decoded size limit -> max encoded bytes.
|
||||
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
||||
// ensure we preallocate more than configured limit while using 4mb initial size.
|
||||
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._cleanupAllPending();
|
||||
if (this._activeDecoder) {
|
||||
this._activeDecoder.release();
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
this._kittyStorage.reset();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private _removePendingEntry(key: number): void {
|
||||
this._pendingTransmissions.delete(key);
|
||||
if (this._lastPendingKey === key) {
|
||||
this._lastPendingKey = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _cleanupAllPending(): void {
|
||||
for (const pending of this._pendingTransmissions.values()) {
|
||||
pending.decoder.release();
|
||||
}
|
||||
this._pendingTransmissions.clear();
|
||||
this._lastPendingKey = undefined;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this._aborted = false;
|
||||
this._decodeError = false;
|
||||
this._inControlData = true;
|
||||
this._controlLength = 0;
|
||||
this._parsedCommand = null;
|
||||
// Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
|
||||
this._encodedSizeLimit = this._maxEncodedBytes;
|
||||
this._totalEncodedSize = 0;
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
if (this._aborted) return;
|
||||
|
||||
if (!this._inControlData) {
|
||||
this._streamPayload(data, start, end);
|
||||
} else {
|
||||
// Scan for semicolon
|
||||
let controlEnd = end;
|
||||
for (let i = start; i < end; i++) {
|
||||
if (data[i] === Constants.SEMICOLON) {
|
||||
this._inControlData = false;
|
||||
controlEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy control data
|
||||
const copyLength = controlEnd - start;
|
||||
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
|
||||
this._controlLength += copyLength;
|
||||
|
||||
if (!this._inControlData) {
|
||||
// Found semicolon - parse control data early for validation
|
||||
this._parsedCommand = parseKittyCommand(this._parseControlDataString());
|
||||
|
||||
// Early validation: i+I conflict
|
||||
if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
|
||||
this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete action doesn't need payload - skip streaming
|
||||
if (this._parsedCommand.action === KittyAction.DELETE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream remaining as payload
|
||||
const payloadStart = controlEnd + 1;
|
||||
if (payloadStart < end) {
|
||||
this._streamPayload(data, payloadStart, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream payload bytes into the base64 decoder.
|
||||
private _streamPayload(data: Uint32Array, start: number, end: number): void {
|
||||
if (this._aborted) return;
|
||||
|
||||
// Check size limit (compare encoded bytes against pre-calculated limit)
|
||||
// Include cumulative size from pending transmission for multi-chunk images.
|
||||
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
|
||||
const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;
|
||||
const pending = this._pendingTransmissions.get(pendingKey);
|
||||
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
|
||||
this._totalEncodedSize += end - start;
|
||||
const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
|
||||
if (cumulativeEncodedSize > this._encodedSizeLimit) {
|
||||
const decoderToRelease = this._activeDecoder ?? pending?.decoder;
|
||||
if (decoderToRelease) {
|
||||
decoderToRelease.release();
|
||||
}
|
||||
this._activeDecoder = null;
|
||||
if (pending) {
|
||||
this._removePendingEntry(pendingKey);
|
||||
}
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._decodeError) return;
|
||||
|
||||
if (pending?.decoder && !this._activeDecoder) {
|
||||
this._activeDecoder = pending.decoder;
|
||||
}
|
||||
if (!this._activeDecoder) {
|
||||
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||
this._activeDecoder.init();
|
||||
}
|
||||
|
||||
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
|
||||
this._activeDecoder.release();
|
||||
this._activeDecoder = null;
|
||||
this._decodeError = true;
|
||||
if (pending) {
|
||||
this._removePendingEntry(pendingKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public end(success: boolean): boolean | Promise<boolean> {
|
||||
if (this._aborted || !success) {
|
||||
if (this._activeDecoder) {
|
||||
this._activeDecoder.release();
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// No semicolon = no payload (delete, capability query)
|
||||
if (this._inControlData) {
|
||||
return this._handleNoPayloadCommand();
|
||||
}
|
||||
|
||||
// Use command parsed early in put() - i+I already validated there
|
||||
const cmd = this._parsedCommand!;
|
||||
|
||||
// Delete action was handled by skipping payload - just execute
|
||||
if (cmd.action === KittyAction.DELETE) {
|
||||
return this._handleDelete(cmd);
|
||||
}
|
||||
|
||||
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
|
||||
const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;
|
||||
const isMoreComing = cmd.more === 1;
|
||||
const pending = this._pendingTransmissions.get(pendingKey);
|
||||
|
||||
if (isMoreComing) {
|
||||
if (this._activeDecoder) {
|
||||
if (pending) {
|
||||
pending.totalEncodedSize += this._totalEncodedSize;
|
||||
pending.decodeError = pending.decodeError || this._decodeError;
|
||||
} else {
|
||||
this._pendingTransmissions.set(pendingKey, {
|
||||
cmd: { ...cmd },
|
||||
decoder: this._activeDecoder,
|
||||
totalEncodedSize: this._totalEncodedSize,
|
||||
decodeError: this._decodeError
|
||||
});
|
||||
}
|
||||
this._lastPendingKey = pendingKey;
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Final chunk received — clear the last pending key
|
||||
if (pending) {
|
||||
this._lastPendingKey = undefined;
|
||||
}
|
||||
|
||||
let decodeError = this._decodeError;
|
||||
let finalCmd = cmd;
|
||||
let decoder = this._activeDecoder;
|
||||
|
||||
if (pending) {
|
||||
finalCmd = pending.cmd;
|
||||
decoder = pending.decoder;
|
||||
decodeError = decodeError || pending.decodeError;
|
||||
this._pendingTransmissions.delete(pendingKey);
|
||||
}
|
||||
|
||||
let imageBytes = new Uint8Array(0);
|
||||
if (decoder) {
|
||||
if (decoder.end() !== DECODER_OK) {
|
||||
decodeError = true;
|
||||
}
|
||||
imageBytes = decoder.data8;
|
||||
}
|
||||
this._activeDecoder = null;
|
||||
|
||||
// Handle command first — handlers create Blob/ImageData from imageBytes,
|
||||
// which copies the data. Only then is it safe to release the decoder's
|
||||
// wasm memory that imageBytes points into.
|
||||
const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
|
||||
if (decoder) {
|
||||
decoder.release();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Command handling
|
||||
|
||||
private _parseControlDataString(): string {
|
||||
let str = '';
|
||||
for (let i = 0; i < this._controlLength; i++) {
|
||||
str += String.fromCodePoint(this._controlData[i]);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
private _handleNoPayloadCommand(): boolean | Promise<boolean> {
|
||||
const cmd = parseKittyCommand(this._parseControlDataString());
|
||||
|
||||
// Per spec: specifying both i and I is an error
|
||||
if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
const action = cmd.action ?? 't';
|
||||
|
||||
switch (action) {
|
||||
case KittyAction.DELETE:
|
||||
return this._handleDelete(cmd);
|
||||
case KittyAction.QUERY:
|
||||
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
|
||||
return true;
|
||||
case KittyAction.PLACEMENT:
|
||||
return this._handlePlacement(cmd);
|
||||
default:
|
||||
// TODO: Implement remaining actions when needed:
|
||||
// - a=f (frame): animation frame operations
|
||||
// - a=a (animation): animation control
|
||||
// - a=c (compose): compose images
|
||||
if (cmd.id !== undefined) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
||||
const action = cmd.action ?? 't';
|
||||
|
||||
switch (action) {
|
||||
case KittyAction.TRANSMIT: {
|
||||
const result = this._handleTransmit(cmd, bytes, decodeError);
|
||||
// Only send response when _handleTransmit didn't already respond
|
||||
// (it handles unsupported transmission medium responses internally)
|
||||
if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {
|
||||
if (decodeError) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
|
||||
} else if (bytes.length > 0) {
|
||||
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case KittyAction.TRANSMIT_DISPLAY:
|
||||
return this._handleTransmitDisplay(cmd, bytes, decodeError);
|
||||
case KittyAction.QUERY:
|
||||
return this._handleQuery(cmd, bytes, decodeError);
|
||||
case KittyAction.PLACEMENT:
|
||||
// a=p ignores any payload — image data was already transmitted
|
||||
return this._handlePlacement(cmd);
|
||||
default:
|
||||
// TODO: Implement remaining actions when needed:
|
||||
// - a=f (frame): animation frame operations
|
||||
// - a=a (animation): animation control
|
||||
// - a=c (compose): compose images
|
||||
if (cmd.id !== undefined) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _handlePlacement(cmd: IKittyCommand): boolean | Promise<boolean> {
|
||||
if (cmd.id === undefined) {
|
||||
return true;
|
||||
}
|
||||
const id = cmd.id;
|
||||
const image = this._kittyStorage.getImage(id);
|
||||
if (!image) {
|
||||
this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
|
||||
return true;
|
||||
}
|
||||
const result = this._displayImage(image, cmd);
|
||||
return result.then(success => {
|
||||
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
||||
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
|
||||
// Currently only supports direct transmission (t=d, the default).
|
||||
// - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
|
||||
// - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
|
||||
// - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
|
||||
// These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
|
||||
// 1. Check cmd.transmission (t key) before treating bytes as image data
|
||||
// 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
|
||||
// 3. For t=d: treat bytes as image data (current behavior)
|
||||
// When implementing, also update _handleQuery to accept these transmission mediums.
|
||||
const transmission = cmd.transmission ?? 'd';
|
||||
if (transmission !== 'd') {
|
||||
if (cmd.id !== undefined) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (decodeError || bytes.length === 0) return true;
|
||||
|
||||
this._kittyStorage.storeImage(cmd.id, {
|
||||
data: new Blob([bytes as BlobPart]),
|
||||
width: cmd.width ?? 0,
|
||||
height: cmd.height ?? 0,
|
||||
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
|
||||
compression: cmd.compression ?? ''
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
||||
if (decodeError) {
|
||||
if (cmd.id !== undefined) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this._handleTransmit(cmd, bytes, decodeError);
|
||||
|
||||
const id = cmd.id ?? this._kittyStorage.lastImageId;
|
||||
const image = this._kittyStorage.getImage(id);
|
||||
if (image) {
|
||||
const result = this._displayImage(image, cmd);
|
||||
if (cmd.id !== undefined) {
|
||||
return result.then(success => {
|
||||
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return result.then(() => true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
||||
const id = cmd.id ?? 0;
|
||||
const quiet = cmd.quiet ?? 0;
|
||||
|
||||
// Per spec: reject unsupported transmission mediums (only t=d is supported atm)
|
||||
// TODO: When filesystem support is added (Node.js/Electron), update this to accept
|
||||
// t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.
|
||||
const transmission = cmd.transmission ?? 'd';
|
||||
if (transmission !== 'd') {
|
||||
this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check decode error first (invalid base64)
|
||||
if (decodeError) {
|
||||
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Capability query (no payload) - just respond OK
|
||||
if (bytes.length === 0) {
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
const format = cmd.format ?? KittyFormat.RGBA;
|
||||
|
||||
if (format === KittyFormat.PNG) {
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
} else {
|
||||
const width = cmd.width ?? 0;
|
||||
const height = cmd.height ?? 0;
|
||||
|
||||
if (!width || !height) {
|
||||
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleDelete(cmd: IKittyCommand): boolean {
|
||||
// Per spec: default delete selector is 'a' (delete all visible placements)
|
||||
const selector = cmd.deleteSelector ?? 'a';
|
||||
|
||||
// TODO: Distinguish lowercase (delete placements only) from uppercase
|
||||
// (delete placements + free stored image data). Currently both variants
|
||||
// free everything since we don't separate stored data from placements.
|
||||
switch (selector) {
|
||||
case 'a':
|
||||
case 'A':
|
||||
this._cleanupAllPending();
|
||||
this._kittyStorage.deleteAll();
|
||||
break;
|
||||
case 'i':
|
||||
case 'I':
|
||||
// TODO: When placement id tracking is implemented (see TODO in
|
||||
// KittyImageStorage), d=i with p=<pid> should delete only that
|
||||
// specific placement, while d=i without p should delete all
|
||||
// placements for the image.
|
||||
if (cmd.id !== undefined) {
|
||||
const pending = this._pendingTransmissions.get(cmd.id);
|
||||
if (pending) {
|
||||
pending.decoder.release();
|
||||
}
|
||||
this._removePendingEntry(cmd.id);
|
||||
this._kittyStorage.deleteById(cmd.id);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
|
||||
const isOk = message === 'OK';
|
||||
if (isOk && quiet >= 1) return;
|
||||
if (!isOk && quiet >= 2) return;
|
||||
|
||||
const pPart = placementId ? `,p=${placementId}` : '';
|
||||
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
|
||||
this._coreTerminal._core.coreService.triggerDataEvent(response);
|
||||
}
|
||||
|
||||
// Image display
|
||||
|
||||
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
|
||||
return this._decodeAndDisplay(image, cmd)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
|
||||
|
||||
try {
|
||||
const cropX = Math.max(0, cmd.x ?? 0);
|
||||
const cropY = Math.max(0, cmd.y ?? 0);
|
||||
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
|
||||
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
|
||||
|
||||
const maxCropW = Math.max(0, bitmap.width - cropX);
|
||||
const maxCropH = Math.max(0, bitmap.height - cropY);
|
||||
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
|
||||
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
|
||||
|
||||
if (finalCropW === 0 || finalCropH === 0) {
|
||||
throw new Error('invalid source rectangle');
|
||||
}
|
||||
|
||||
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
|
||||
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
|
||||
bitmap.close();
|
||||
bitmap = cropped;
|
||||
}
|
||||
|
||||
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
|
||||
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
|
||||
|
||||
// Per spec: c/r default to image's natural cell dimensions.
|
||||
// If only one of c/r is specified, compute the other from image aspect ratio.
|
||||
let imgCols: number;
|
||||
let imgRows: number;
|
||||
if (cmd.columns !== undefined && cmd.rows !== undefined) {
|
||||
imgCols = cmd.columns;
|
||||
imgRows = cmd.rows;
|
||||
} else if (cmd.columns !== undefined) {
|
||||
imgCols = cmd.columns;
|
||||
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
|
||||
} else if (cmd.rows !== undefined) {
|
||||
imgRows = cmd.rows;
|
||||
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
|
||||
} else {
|
||||
imgCols = Math.ceil(bitmap.width / cw);
|
||||
imgRows = Math.ceil(bitmap.height / ch);
|
||||
}
|
||||
|
||||
let w = bitmap.width;
|
||||
let h = bitmap.height;
|
||||
|
||||
// Scale bitmap to fit placement rectangle when c/r are specified
|
||||
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
||||
w = Math.round(imgCols * cw);
|
||||
h = Math.round(imgRows * ch);
|
||||
}
|
||||
|
||||
if (w * h > this._opts.pixelLimit) {
|
||||
throw new Error('image exceeds pixel limit');
|
||||
}
|
||||
|
||||
// Save cursor position before addImage modifies it
|
||||
const buffer = this._coreTerminal._core.buffer;
|
||||
const savedX = buffer.x;
|
||||
const savedY = buffer.y;
|
||||
const savedYbase = buffer.ybase;
|
||||
|
||||
// Determine layer based on z-index: negative = behind text, 0+ = on top.
|
||||
// When z<0 we always use the bottom layer even without allowTransparency —
|
||||
// the image will simply be hidden behind the opaque text background, which
|
||||
// is the correct behavior (client asked for "behind text").
|
||||
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
|
||||
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
|
||||
|
||||
if (w !== bitmap.width || h !== bitmap.height) {
|
||||
const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||
bitmap.close();
|
||||
bitmap = scaled;
|
||||
}
|
||||
|
||||
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
|
||||
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
|
||||
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
|
||||
if (xOffset !== 0 || yOffset !== 0) {
|
||||
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
|
||||
// total placement area remains c*cw × r*ch pixels and the offset image
|
||||
// is clipped to fit. When c/r are unset, the padded canvas determines
|
||||
// the natural cell dimensions.
|
||||
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
|
||||
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
|
||||
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
|
||||
const offsetCtx = offsetCanvas.getContext('2d');
|
||||
if (!offsetCtx) {
|
||||
throw new Error('Failed to create offset canvas context');
|
||||
}
|
||||
offsetCtx.drawImage(bitmap, xOffset, yOffset);
|
||||
|
||||
const offsetBitmap = await createImageBitmap(offsetCanvas);
|
||||
offsetCanvas.width = offsetCanvas.height = 0;
|
||||
bitmap.close();
|
||||
bitmap = offsetBitmap;
|
||||
w = bitmap.width;
|
||||
h = bitmap.height;
|
||||
if (w * h > this._opts.pixelLimit) {
|
||||
throw new Error('image exceeds pixel limit');
|
||||
}
|
||||
if (cmd.columns === undefined) {
|
||||
imgCols = Math.ceil(bitmap.width / cw);
|
||||
}
|
||||
if (cmd.rows === undefined) {
|
||||
imgRows = Math.ceil(bitmap.height / ch);
|
||||
}
|
||||
}
|
||||
|
||||
const zIndex = cmd.zIndex ?? 0;
|
||||
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
|
||||
bitmap = undefined; // ownership transferred to storage
|
||||
|
||||
// Kitty cursor movement
|
||||
// Per spec: cursor placed at first column after last image column,
|
||||
// on the last row of the image. C=1 means don't move cursor.
|
||||
if (cmd.cursorMovement === 1) {
|
||||
// C=1: restore cursor to position before image was placed
|
||||
const scrolled = buffer.ybase - savedYbase;
|
||||
buffer.x = savedX;
|
||||
// Can't restore cursor to scrollback?
|
||||
buffer.y = Math.max(savedY - scrolled, 0);
|
||||
} else {
|
||||
// Default (C=0): advance cursor horizontally past the image
|
||||
// addImage already positioned cursor on the last row via lineFeeds
|
||||
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
|
||||
}
|
||||
} catch (e) {
|
||||
bitmap?.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Create ImageBitmap from already-decoded image data.
|
||||
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
|
||||
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
|
||||
|
||||
if (image.compression === KittyCompression.ZLIB) {
|
||||
bytes = await this._decompressZlib(bytes);
|
||||
}
|
||||
|
||||
if (image.format === KittyFormat.PNG) {
|
||||
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
|
||||
if (!window.createImageBitmap) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
return new Promise<ImageBitmap>((resolve, reject) => {
|
||||
img.addEventListener('load', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
||||
createImageBitmap(canvas).then(resolve).catch(reject);
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
});
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
return createImageBitmap(blob);
|
||||
}
|
||||
|
||||
// Raw pixel data
|
||||
const width = image.width;
|
||||
const height = image.height;
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error('Width and height required for raw pixel data');
|
||||
}
|
||||
|
||||
const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
throw new Error('Insufficient pixel data');
|
||||
}
|
||||
|
||||
const pixelCount = width * height;
|
||||
|
||||
if (image.format === KittyFormat.RGBA) {
|
||||
// RGBA: use bytes directly — no copy needed
|
||||
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));
|
||||
}
|
||||
|
||||
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
|
||||
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
|
||||
// Assumes little-endian (all modern browsers/Node.js).
|
||||
const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
|
||||
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
||||
const dst32 = new Uint32Array(data.buffer);
|
||||
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
|
||||
|
||||
let srcOffset = 0;
|
||||
let dstOffset = 0;
|
||||
for (let i = 0; i < alignedPixels; i += 4) {
|
||||
const b0 = src32[srcOffset++];
|
||||
const b1 = src32[srcOffset++];
|
||||
const b2 = src32[srcOffset++];
|
||||
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
|
||||
dst32[dstOffset++] = 0xFF000000 | b0;
|
||||
dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
|
||||
dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
|
||||
dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
|
||||
}
|
||||
|
||||
// Handle remaining 1–3 pixels
|
||||
let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
|
||||
for (let i = alignedPixels; i < pixelCount; i++) {
|
||||
data[dstByte] = bytes[srcByte];
|
||||
data[dstByte + 1] = bytes[srcByte + 1];
|
||||
data[dstByte + 2] = bytes[srcByte + 2];
|
||||
data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
|
||||
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
|
||||
}
|
||||
|
||||
return createImageBitmap(new ImageData(data, width, height));
|
||||
}
|
||||
|
||||
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
|
||||
try {
|
||||
return await this._decompress(compressed, 'deflate');
|
||||
} catch {
|
||||
return await this._decompress(compressed, 'deflate-raw');
|
||||
}
|
||||
}
|
||||
|
||||
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
|
||||
const ds = new DecompressionStream(format);
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(compressed as BufferSource);
|
||||
writer.close();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = ds.readable.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public get images(): ReadonlyMap<number, IKittyImageData> {
|
||||
return this._kittyStorage.images;
|
||||
}
|
||||
|
||||
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
|
||||
return this._kittyStorage.kittyIdToStorageId;
|
||||
}
|
||||
|
||||
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
|
||||
return this._pendingTransmissions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { parseKittyCommand, KittyAction, KittyFormat } from './KittyGraphicsTypes';
|
||||
|
||||
describe('KittyGraphicsTypes', () => {
|
||||
describe('parseKittyCommand', () => {
|
||||
it('should parse control data with action and format', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100');
|
||||
assert.strictEqual(cmd.action, 'T');
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should parse control data with all options', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.strictEqual(cmd.format, 32);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
assert.strictEqual(cmd.width, 10);
|
||||
assert.strictEqual(cmd.height, 20);
|
||||
assert.strictEqual(cmd.columns, 3);
|
||||
assert.strictEqual(cmd.rows, 2);
|
||||
assert.strictEqual(cmd.more, 1);
|
||||
assert.strictEqual(cmd.quiet, 2);
|
||||
});
|
||||
|
||||
it('should handle empty control data', () => {
|
||||
const cmd = parseKittyCommand('');
|
||||
assert.strictEqual(cmd.action, undefined);
|
||||
assert.strictEqual(cmd.format, undefined);
|
||||
});
|
||||
|
||||
it('should parse transmit action', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=100');
|
||||
assert.strictEqual(cmd.action, KittyAction.TRANSMIT);
|
||||
assert.strictEqual(cmd.format, KittyFormat.PNG);
|
||||
});
|
||||
|
||||
it('should parse delete action', () => {
|
||||
const cmd = parseKittyCommand('a=d,i=5');
|
||||
assert.strictEqual(cmd.action, KittyAction.DELETE);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse empty action as empty string', () => {
|
||||
const cmd = parseKittyCommand('a=,f=100');
|
||||
assert.strictEqual(cmd.action, '');
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should leave action undefined when key is not present', () => {
|
||||
const cmd = parseKittyCommand('f=100,i=5');
|
||||
assert.strictEqual(cmd.action, undefined);
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse compression key', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=32,o=z');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.strictEqual(cmd.format, 32);
|
||||
assert.strictEqual(cmd.compression, 'z');
|
||||
});
|
||||
|
||||
it('should parse cursor movement key', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,C=1');
|
||||
assert.strictEqual(cmd.cursorMovement, 1);
|
||||
});
|
||||
|
||||
it('should parse cursor movement key C=0', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,C=0');
|
||||
assert.strictEqual(cmd.cursorMovement, 0);
|
||||
});
|
||||
|
||||
it('should parse x and y offset', () => {
|
||||
const cmd = parseKittyCommand('a=T,x=10,y=20');
|
||||
assert.strictEqual(cmd.x, 10);
|
||||
assert.strictEqual(cmd.y, 20);
|
||||
});
|
||||
|
||||
it('should handle keys without values', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=,i=5');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.ok(isNaN(cmd.format!));
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse z-index key with positive value', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,z=10');
|
||||
assert.strictEqual(cmd.zIndex, 10);
|
||||
});
|
||||
|
||||
it('should parse z-index key with zero', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,z=0');
|
||||
assert.strictEqual(cmd.zIndex, 0);
|
||||
});
|
||||
|
||||
it('should parse z-index key with negative value', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,z=-1');
|
||||
assert.strictEqual(cmd.zIndex, -1);
|
||||
});
|
||||
|
||||
it('should leave zIndex undefined when not specified', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100');
|
||||
assert.strictEqual(cmd.zIndex, undefined);
|
||||
});
|
||||
|
||||
it('should parse delete selector key', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=i,i=5');
|
||||
assert.strictEqual(cmd.action, 'd');
|
||||
assert.strictEqual(cmd.deleteSelector, 'i');
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse uppercase delete selector', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=A');
|
||||
assert.strictEqual(cmd.deleteSelector, 'A');
|
||||
});
|
||||
|
||||
it('should parse delete selector d=a (all)', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=a');
|
||||
assert.strictEqual(cmd.deleteSelector, 'a');
|
||||
});
|
||||
|
||||
it('should leave deleteSelector undefined when not specified', () => {
|
||||
const cmd = parseKittyCommand('a=d,i=5');
|
||||
assert.strictEqual(cmd.deleteSelector, undefined);
|
||||
});
|
||||
|
||||
it('should parse placement id key', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=i,i=5,p=3');
|
||||
assert.strictEqual(cmd.placementId, 3);
|
||||
assert.strictEqual(cmd.deleteSelector, 'i');
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should leave placementId undefined when not specified', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=i,i=5');
|
||||
assert.strictEqual(cmd.placementId, undefined);
|
||||
});
|
||||
|
||||
it('should parse image number key', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=100,I=42');
|
||||
assert.strictEqual(cmd.imageNumber, 42);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Kitty graphics protocol types, constants, and parsing utilities.
|
||||
*/
|
||||
|
||||
import type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
|
||||
// Kitty graphics protocol action types.
|
||||
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
|
||||
export const enum KittyAction {
|
||||
TRANSMIT = 't',
|
||||
TRANSMIT_DISPLAY = 'T',
|
||||
QUERY = 'q',
|
||||
PLACEMENT = 'p',
|
||||
DELETE = 'd'
|
||||
}
|
||||
|
||||
// Kitty graphics protocol format types.
|
||||
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||
export const enum KittyFormat {
|
||||
RGB = 24,
|
||||
RGBA = 32,
|
||||
PNG = 100
|
||||
}
|
||||
|
||||
// Kitty graphics protocol compression types.
|
||||
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
|
||||
export const enum KittyCompression {
|
||||
NONE = '',
|
||||
ZLIB = 'z'
|
||||
}
|
||||
|
||||
// Kitty graphics protocol control data keys.
|
||||
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||
export const enum KittyKey {
|
||||
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
|
||||
ACTION = 'a',
|
||||
// Image format (24=RGB, 32=RGBA, 100=PNG)
|
||||
FORMAT = 'f',
|
||||
// Image ID for referencing stored images
|
||||
ID = 'i',
|
||||
// Image number (alternative to ID, terminal assigns ID)
|
||||
IMAGE_NUMBER = 'I',
|
||||
// Source image width in pixels
|
||||
WIDTH = 's',
|
||||
// Source image height in pixels
|
||||
HEIGHT = 'v',
|
||||
// The left edge (in pixels) of the image area to display
|
||||
X_OFFSET = 'x',
|
||||
// The top edge (in pixels) of the image area to display
|
||||
Y_OFFSET = 'y',
|
||||
// Width (in pixels) of the source rectangle to display
|
||||
SOURCE_WIDTH = 'w',
|
||||
// Height (in pixels) of the source rectangle to display
|
||||
SOURCE_HEIGHT = 'h',
|
||||
// Horizontal offset (in pixels) within the first cell
|
||||
X_PLACEMENT_OFFSET = 'X',
|
||||
// Vertical offset (in pixels) within the first cell
|
||||
Y_PLACEMENT_OFFSET = 'Y',
|
||||
// Number of terminal columns to display the image over
|
||||
COLUMNS = 'c',
|
||||
// Number of terminal rows to display the image over
|
||||
ROWS = 'r',
|
||||
// More data flag (1=more chunks coming, 0=final chunk)
|
||||
MORE = 'm',
|
||||
// Compression type (z=zlib). This is essential for chunking larger images.
|
||||
COMPRESSION = 'o',
|
||||
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
|
||||
QUIET = 'q',
|
||||
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
|
||||
CURSOR_MOVEMENT = 'C',
|
||||
// Z-index for image layering (negative = behind text, 0+ = on top)
|
||||
Z_INDEX = 'z',
|
||||
// Transmission medium (d=direct, f=file, t=temp file, s=shared memory)
|
||||
TRANSMISSION = 't',
|
||||
// Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d
|
||||
DELETE_SELECTOR = 'd',
|
||||
// Placement ID for targeting specific placements
|
||||
PLACEMENT_ID = 'p'
|
||||
}
|
||||
|
||||
// Pixel format constants
|
||||
export const enum KittyPixelConstants {
|
||||
BYTES_PER_PIXEL_RGB = 3,
|
||||
BYTES_PER_PIXEL_RGBA = 4,
|
||||
ALPHA_OPAQUE = 255
|
||||
}
|
||||
|
||||
// Parsed Kitty graphics command.
|
||||
export interface IKittyCommand {
|
||||
action?: string;
|
||||
format?: number;
|
||||
id?: number;
|
||||
imageNumber?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
xOffset?: number;
|
||||
yOffset?: number;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
more?: number;
|
||||
quiet?: number;
|
||||
cursorMovement?: number;
|
||||
zIndex?: number;
|
||||
transmission?: string;
|
||||
deleteSelector?: string;
|
||||
placementId?: number;
|
||||
compression?: string;
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
// Pending chunked transmission state.
|
||||
// Stores metadata from the first chunk while accumulating decoded payload data.
|
||||
export interface IPendingTransmission {
|
||||
// The parsed command from the first chunk (contains action, format, dimensions, etc.)
|
||||
cmd: IKittyCommand;
|
||||
// Decoder used across chunked payloads
|
||||
decoder: Base64Decoder;
|
||||
// Total encoded (base64) bytes received across all chunks - for size limit enforcement
|
||||
totalEncodedSize: number;
|
||||
// Whether any chunk has failed to decode
|
||||
decodeError: boolean;
|
||||
}
|
||||
|
||||
// Stored Kitty image data.
|
||||
export interface IKittyImageData {
|
||||
id: number;
|
||||
// Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit
|
||||
data: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
format: 24 | 32 | 100;
|
||||
compression?: string;
|
||||
}
|
||||
|
||||
// Parses Kitty graphics control data into a command object.
|
||||
export function parseKittyCommand(data: string): IKittyCommand {
|
||||
const cmd: IKittyCommand = {};
|
||||
const parts = data.split(',');
|
||||
|
||||
for (const part of parts) {
|
||||
const eqIdx = part.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
|
||||
const key = part.substring(0, eqIdx);
|
||||
const value = part.substring(eqIdx + 1);
|
||||
|
||||
// Handle string keys first
|
||||
if (key === KittyKey.ACTION) {
|
||||
cmd.action = value;
|
||||
continue;
|
||||
}
|
||||
if (key === KittyKey.COMPRESSION) {
|
||||
cmd.compression = value;
|
||||
continue;
|
||||
}
|
||||
if (key === KittyKey.TRANSMISSION) {
|
||||
cmd.transmission = value;
|
||||
continue;
|
||||
}
|
||||
if (key === KittyKey.DELETE_SELECTOR) {
|
||||
cmd.deleteSelector = value;
|
||||
continue;
|
||||
}
|
||||
const numValue = parseInt(value);
|
||||
switch (key) {
|
||||
case KittyKey.FORMAT: cmd.format = numValue; break;
|
||||
case KittyKey.ID: cmd.id = numValue; break;
|
||||
case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;
|
||||
case KittyKey.WIDTH: cmd.width = numValue; break;
|
||||
case KittyKey.HEIGHT: cmd.height = numValue; break;
|
||||
case KittyKey.X_OFFSET: cmd.x = numValue; break;
|
||||
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
|
||||
case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;
|
||||
case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;
|
||||
case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;
|
||||
case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;
|
||||
case KittyKey.COLUMNS: cmd.columns = numValue; break;
|
||||
case KittyKey.ROWS: cmd.rows = numValue; break;
|
||||
case KittyKey.MORE: cmd.more = numValue; break;
|
||||
case KittyKey.QUIET: cmd.quiet = numValue; break;
|
||||
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
|
||||
case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;
|
||||
case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,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,11 +11,14 @@
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"browser/*": [ "../../../src/browser/*" ],
|
||||
"common/*": [ "../../../src/common/*" ],
|
||||
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
|
||||
"target-browser/*": [ "../../../src/target-browser/*" ],
|
||||
"common/*": [
|
||||
"../../../src/primitives/*",
|
||||
"../../../src/runtime/*",
|
||||
],
|
||||
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ],
|
||||
"*": [ "./*" ]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -23,7 +26,7 @@
|
||||
"../../../typings/xterm.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../src/browser" },
|
||||
{ "path": "../../../src/common" }
|
||||
{ "path": "../../../src/target-browser" },
|
||||
{ "path": "../../../src" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { openTerminal, launchBrowser, pollFor } from '../../../out-test/api/TestUtils';
|
||||
import { Browser, Page } from '@playwright/test';
|
||||
import { IImageAddonOptions } from '../src/Types';
|
||||
import { FINALIZER, introducer, sixelEncode } from 'sixel';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
const APP = 'http://127.0.0.1:3001/test';
|
||||
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
const width = 800;
|
||||
const height = 600;
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const ImageAddon: {
|
||||
new(options?: Partial<IImageAddonOptions>): any;
|
||||
};
|
||||
|
||||
interface ITestData {
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: Uint8Array;
|
||||
palette: number[];
|
||||
sixel: string;
|
||||
}
|
||||
|
||||
interface IDimensions {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// image: 640 x 80, 512 color
|
||||
const TESTDATA: ITestData = (() => {
|
||||
const data8 = readFileSync('./addons/addon-image/fixture/palette.blob');
|
||||
const data32 = new Uint32Array(data8.buffer);
|
||||
const palette = new Set<number>();
|
||||
for (let i = 0; i < data32.length; ++i) palette.add(data32[i]);
|
||||
const sixel = sixelEncode(data8, 640, 80, [...palette]);
|
||||
return {
|
||||
width: 640,
|
||||
height: 80,
|
||||
bytes: data8,
|
||||
palette: [...palette],
|
||||
sixel
|
||||
};
|
||||
})();
|
||||
const SIXEL_SEQ_0 = introducer(0) + TESTDATA.sixel + FINALIZER;
|
||||
// const SIXEL_SEQ_1 = introducer(1) + TESTDATA.sixel + FINALIZER;
|
||||
// const SIXEL_SEQ_2 = introducer(2) + TESTDATA.sixel + FINALIZER;
|
||||
|
||||
// NOTE: the data is loaded as string for easier transport through playwright
|
||||
const TESTDATA_IIP: [string, [number, number]][] = [
|
||||
[readFileSync('./addons/addon-image/fixture/iip/palette.iip', { encoding: 'utf-8' }), [640, 80]],
|
||||
[readFileSync('./addons/addon-image/fixture/iip/spinfox.iip', { encoding: 'utf-8' }), [148, 148]],
|
||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_gif.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]]
|
||||
];
|
||||
|
||||
describe('ImageAddon', () => {
|
||||
before(async () => {
|
||||
browser = await launchBrowser();
|
||||
page = await (await browser.newContext()).newPage();
|
||||
await page.setViewportSize({ width, height });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await page.goto(APP);
|
||||
await openTerminal(page);
|
||||
await page.evaluate(opts => {
|
||||
(window as any).imageAddon = new ImageAddon(opts.opts);
|
||||
(window as any).term.loadAddon((window as any).imageAddon);
|
||||
}, { opts: { sixelPaletteLimit: 512 } });
|
||||
});
|
||||
|
||||
it('test for private accessors', async () => {
|
||||
// terminal privates
|
||||
const accessors = [
|
||||
'_core',
|
||||
'_core._renderService',
|
||||
'_core._inputHandler',
|
||||
'_core._inputHandler._parser',
|
||||
'_core._inputHandler._curAttrData',
|
||||
'_core._inputHandler._dirtyRowTracker',
|
||||
'_core._themeService.colors',
|
||||
'_core._coreBrowserService'
|
||||
];
|
||||
for (const prop of accessors) {
|
||||
assert.equal(
|
||||
await page.evaluate('(() => { const v = window.term.' + prop + '; return v !== undefined && v !== null; })()'),
|
||||
true, `problem at ${prop}`
|
||||
);
|
||||
}
|
||||
// bufferline privates
|
||||
assert.equal(await page.evaluate('window.term._core.buffer.lines.get(0)._data instanceof Uint32Array'), true);
|
||||
assert.equal(await page.evaluate('window.term._core.buffer.lines.get(0)._extendedAttrs instanceof Object'), true);
|
||||
// inputhandler privates
|
||||
assert.equal(await page.evaluate('window.term._core._inputHandler._curAttrData.constructor.name'), 'AttributeData');
|
||||
assert.equal(await page.evaluate('window.term._core._inputHandler._parser.constructor.name'), 'EscapeSequenceParser');
|
||||
});
|
||||
|
||||
describe('ctor options', () => {
|
||||
it('empty settings should load defaults', async () => {
|
||||
const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
enableSizeReports: true,
|
||||
pixelLimit: 16777216,
|
||||
sixelSupport: true,
|
||||
sixelScrolling: true,
|
||||
sixelPaletteLimit: 512, // set to 512 to get example image working
|
||||
sixelSizeLimit: 25000000,
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000
|
||||
};
|
||||
assert.deepEqual(await page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||
});
|
||||
it('custom settings should overload defaults', async () => {
|
||||
const customSettings: IImageAddonOptions = {
|
||||
enableSizeReports: false,
|
||||
pixelLimit: 5,
|
||||
sixelSupport: false,
|
||||
sixelScrolling: false,
|
||||
sixelPaletteLimit: 1024,
|
||||
sixelSizeLimit: 1000,
|
||||
storageLimit: 10,
|
||||
showPlaceholder: false,
|
||||
iipSupport: false,
|
||||
iipSizeLimit: 1000
|
||||
};
|
||||
await page.evaluate(opts => {
|
||||
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
||||
(window as any).term.loadAddon((window as any).imageAddonCustom);
|
||||
}, { opts: customSettings });
|
||||
assert.deepEqual(await page.evaluate(`window.imageAddonCustom._opts`), customSettings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling & cursor modes', () => {
|
||||
it('testdata default (scrolling with VT240 cursor pos)', async () => {
|
||||
const dim = await getDimensions();
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
assert.deepEqual(await getCursor(), [0, Math.floor(TESTDATA.height/dim.cellHeight)]);
|
||||
// moved to right by 10 cells
|
||||
await writeToTerminal('#'.repeat(10) + SIXEL_SEQ_0);
|
||||
assert.deepEqual(await getCursor(), [10, Math.floor(TESTDATA.height/dim.cellHeight) * 2]);
|
||||
});
|
||||
it('write testdata noScrolling', async () => {
|
||||
await writeToTerminal('\x1b[?80h' + SIXEL_SEQ_0);
|
||||
assert.deepEqual(await getCursor(), [0, 0]);
|
||||
// second draw does not change anything
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
assert.deepEqual(await getCursor(), [0, 0]);
|
||||
});
|
||||
it('testdata cursor always at VT240 pos', async () => {
|
||||
const dim = await getDimensions();
|
||||
// offset 0
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
assert.deepEqual(await getCursor(), [0, Math.floor(TESTDATA.height/dim.cellHeight)]);
|
||||
// moved to right by 10 cells
|
||||
await writeToTerminal('#'.repeat(10) + SIXEL_SEQ_0);
|
||||
assert.deepEqual(await getCursor(), [10, Math.floor(TESTDATA.height/dim.cellHeight) * 2]);
|
||||
// moved by 30 cells (+10 prev)
|
||||
await writeToTerminal('#'.repeat(30) + SIXEL_SEQ_0);
|
||||
assert.deepEqual(await getCursor(), [10 + 30, Math.floor(TESTDATA.height/dim.cellHeight) * 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image lifecycle & eviction', () => {
|
||||
it('delete image once scrolled off', async () => {
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
pollFor(page, 'window.imageAddon._storage._images.size', 1);
|
||||
// scroll to scrollback + rows - 1
|
||||
await page.evaluate(
|
||||
scrollback => new Promise(res => (window as any).term.write('\n'.repeat(scrollback), res)),
|
||||
(await getScrollbackPlusRows() - 1)
|
||||
);
|
||||
// wait here, as we have to make sure, that eviction did not yet occur
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
pollFor(page, 'window.imageAddon._storage._images.size', 1);
|
||||
// scroll one further should delete the image
|
||||
await page.evaluate(() => new Promise(res => (window as any).term.write('\n', res)));
|
||||
pollFor(page, 'window.imageAddon._storage._images.size', 0);
|
||||
});
|
||||
it('get storageUsage', async () => {
|
||||
assert.equal(await page.evaluate('imageAddon.storageUsage'), 0);
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
assert.closeTo(await page.evaluate('imageAddon.storageUsage'), 640 * 80 * 4 / 1000000, 0.05);
|
||||
});
|
||||
it('get/set storageLimit', async () => {
|
||||
assert.equal(await page.evaluate('imageAddon.storageLimit'), 128);
|
||||
assert.equal(await page.evaluate('imageAddon.storageLimit = 1'), 1);
|
||||
assert.equal(await page.evaluate('imageAddon.storageLimit'), 1);
|
||||
});
|
||||
it('remove images by storage limit pressure', async () => {
|
||||
assert.equal(await page.evaluate('imageAddon.storageLimit = 1'), 1);
|
||||
// never go beyond storage limit
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const usage = await page.evaluate('imageAddon.storageUsage');
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
await writeToTerminal(SIXEL_SEQ_0);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
assert.equal(await page.evaluate('imageAddon.storageUsage'), usage);
|
||||
assert.equal(usage as number < 1, true);
|
||||
});
|
||||
it('set storageLimit removes images synchronously', async () => {
|
||||
await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0);
|
||||
const usage: number = await page.evaluate('imageAddon.storageUsage');
|
||||
const newUsage: number = await page.evaluate('imageAddon.storageLimit = 0.5; imageAddon.storageUsage');
|
||||
assert.equal(newUsage < usage, true);
|
||||
assert.equal(newUsage < 0.5, true);
|
||||
});
|
||||
it('clear alternate images on buffer change', async () => {
|
||||
assert.equal(await page.evaluate('imageAddon.storageUsage'), 0);
|
||||
await writeToTerminal('\x1b[?1049h' + SIXEL_SEQ_0);
|
||||
assert.closeTo(await page.evaluate('imageAddon.storageUsage'), 640 * 80 * 4 / 1000000, 0.05);
|
||||
await writeToTerminal('\x1b[?1049l');
|
||||
assert.equal(await page.evaluate('imageAddon.storageUsage'), 0);
|
||||
});
|
||||
it('evict tiles by in-place overwrites (only full overwrite tested)', async () => {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H');
|
||||
let usage = await page.evaluate('imageAddon.storageUsage');
|
||||
while (usage === 0) {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
usage = await page.evaluate('imageAddon.storageUsage');
|
||||
}
|
||||
await writeToTerminal('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H');
|
||||
await new Promise(r => setTimeout(r, 200)); // wait some time and re-check
|
||||
assert.equal(await page.evaluate('imageAddon.storageUsage'), usage);
|
||||
});
|
||||
it('manual eviction on alternate buffer must not miss images', async () => {
|
||||
await writeToTerminal('\x1b[?1049h');
|
||||
await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const usage: number = await page.evaluate('imageAddon.storageUsage');
|
||||
await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0);
|
||||
await writeToTerminal(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0);
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const newUsage: number = await page.evaluate('imageAddon.storageUsage');
|
||||
assert.equal(newUsage, usage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IIP support - testimages', () => {
|
||||
it('palette.png', async () => {
|
||||
await writeToTerminal(TESTDATA_IIP[0][0]);
|
||||
assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[0][1]);
|
||||
});
|
||||
it('spinfox.png', async () => {
|
||||
await writeToTerminal(TESTDATA_IIP[1][0]);
|
||||
assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
|
||||
});
|
||||
it('w3c gif', async () => {
|
||||
await writeToTerminal(TESTDATA_IIP[2][0]);
|
||||
assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[2][1]);
|
||||
});
|
||||
it('w3c jpeg', async () => {
|
||||
await writeToTerminal(TESTDATA_IIP[3][0]);
|
||||
assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[3][1]);
|
||||
});
|
||||
it('w3c png', async () => {
|
||||
await writeToTerminal(TESTDATA_IIP[4][0]);
|
||||
assert.deepEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* terminal access helpers.
|
||||
*/
|
||||
async function getDimensions(): Promise<IDimensions> {
|
||||
const dimensions: any = await page.evaluate(`term._core._renderService.dimensions`);
|
||||
return {
|
||||
cellWidth: Math.round(dimensions.css.cell.width),
|
||||
cellHeight: Math.round(dimensions.css.cell.height),
|
||||
width: Math.round(dimensions.css.canvas.width),
|
||||
height: Math.round(dimensions.css.canvas.height)
|
||||
};
|
||||
}
|
||||
|
||||
async function getCursor(): Promise<[number, number]> {
|
||||
return page.evaluate('[window.term.buffer.active.cursorX, window.term.buffer.active.cursorY]');
|
||||
}
|
||||
|
||||
async function getImageStorageLength(): Promise<number> {
|
||||
return page.evaluate('window.imageAddon._storage._images.size');
|
||||
}
|
||||
|
||||
async function getScrollbackPlusRows(): Promise<number> {
|
||||
return page.evaluate('window.term.options.scrollback + window.term.rows');
|
||||
}
|
||||
|
||||
async function writeToTerminal(d: string): Promise<any> {
|
||||
return page.evaluate(data => new Promise(res => (window as any).term.write(data, res)), d);
|
||||
}
|
||||
|
||||
async function getOrigSize(id: number): Promise<[number, number]> {
|
||||
return page.evaluate<any>(`[
|
||||
window.imageAddon._storage._images.get(${id}).orig.width,
|
||||
window.imageAddon._storage._images.get(${id}).orig.height
|
||||
]`);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import test from '@playwright/test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { FINALIZER, introducer, sixelEncode } from 'sixel';
|
||||
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
|
||||
import { deepStrictEqual, ok, strictEqual } from 'assert';
|
||||
import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm';
|
||||
|
||||
/**
|
||||
* Plugin ctor options.
|
||||
*/
|
||||
export interface IImageAddonOptions {
|
||||
enableSizeReports: boolean;
|
||||
pixelLimit: number;
|
||||
storageLimit: number;
|
||||
showPlaceholder: boolean;
|
||||
sixelSupport: boolean;
|
||||
sixelScrolling: boolean;
|
||||
sixelPaletteLimit: number;
|
||||
sixelSizeLimit: number;
|
||||
iipSupport: boolean;
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const ImageAddon: {
|
||||
new(options?: Partial<IImageAddonOptions>): any;
|
||||
};
|
||||
|
||||
interface ITestData {
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: Uint8Array;
|
||||
palette: number[];
|
||||
sixel: string;
|
||||
}
|
||||
|
||||
interface IDimensions {
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// image: 640 x 80, 512 color
|
||||
const TESTDATA: ITestData = (() => {
|
||||
const data8 = readFileSync('./addons/addon-image/fixture/palette.blob');
|
||||
const data32 = new Uint32Array(data8.buffer);
|
||||
const palette = new Set<number>();
|
||||
for (let i = 0; i < data32.length; ++i) palette.add(data32[i]);
|
||||
const sixel = sixelEncode(data8, 640, 80, [...palette]);
|
||||
return {
|
||||
width: 640,
|
||||
height: 80,
|
||||
bytes: data8,
|
||||
palette: [...palette],
|
||||
sixel
|
||||
};
|
||||
})();
|
||||
const SIXEL_SEQ_0 = introducer(0) + TESTDATA.sixel + FINALIZER;
|
||||
// const SIXEL_SEQ_1 = introducer(1) + TESTDATA.sixel + FINALIZER;
|
||||
// const SIXEL_SEQ_2 = introducer(2) + TESTDATA.sixel + FINALIZER;
|
||||
|
||||
// NOTE: the data is loaded as string for easier transport through playwright
|
||||
const TESTDATA_IIP: [string, [number, number]][] = [
|
||||
[readFileSync('./addons/addon-image/fixture/iip/palette.iip', { encoding: 'utf-8' }), [640, 80]],
|
||||
[readFileSync('./addons/addon-image/fixture/iip/spinfox.iip', { encoding: 'utf-8' }), [148, 148]],
|
||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_gif.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]]
|
||||
];
|
||||
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;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await createTestContext(browser);
|
||||
await openTerminal(ctx, { cols: 80, rows: 24 });
|
||||
});
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
|
||||
test.describe('ImageAddon', () => {
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
await ctx.page.evaluate(`
|
||||
window.term.reset()
|
||||
window.imageAddon?.dispose();
|
||||
window.imageAddon = new ImageAddon({ sixelPaletteLimit: 512 });
|
||||
window.term.loadAddon(window.imageAddon);
|
||||
`);
|
||||
});
|
||||
|
||||
test('test for private accessors', async () => {
|
||||
// terminal privates
|
||||
const accessors = [
|
||||
'_core',
|
||||
'_core._renderService',
|
||||
'_core._inputHandler',
|
||||
'_core._inputHandler._parser',
|
||||
'_core._inputHandler._curAttrData',
|
||||
'_core._inputHandler._dirtyRowTracker',
|
||||
'_core._themeService.colors',
|
||||
'_core._coreBrowserService'
|
||||
];
|
||||
for (const prop of accessors) {
|
||||
strictEqual(
|
||||
await ctx.page.evaluate('(() => { const v = window.term.' + prop + '; return v !== undefined && v !== null; })()'),
|
||||
true, `problem at ${prop}`
|
||||
);
|
||||
}
|
||||
// bufferline privates
|
||||
strictEqual(await ctx.page.evaluate('window.term._core.buffer.lines.get(0)._data instanceof Uint32Array'), true);
|
||||
strictEqual(await ctx.page.evaluate('window.term._core.buffer.lines.get(0)._extendedAttrs instanceof Object'), true);
|
||||
// inputhandler privates
|
||||
strictEqual(await ctx.page.evaluate('window.term._core._inputHandler._curAttrData.constructor.name'), '_AttributeData');
|
||||
strictEqual(await ctx.page.evaluate('window.term._core._inputHandler._parser.constructor.name'), 'EscapeSequenceParser');
|
||||
});
|
||||
|
||||
test.describe('ctor options', () => {
|
||||
test('empty settings should load defaults', async () => {
|
||||
const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
enableSizeReports: true,
|
||||
pixelLimit: 16777216,
|
||||
sixelSupport: true,
|
||||
sixelScrolling: true,
|
||||
sixelPaletteLimit: 512, // set to 512 to get example image working
|
||||
sixelSizeLimit: 33554432,
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 33554432,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 33554432
|
||||
};
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||
});
|
||||
test('custom settings should overload defaults', async () => {
|
||||
const customSettings: IImageAddonOptions = {
|
||||
enableSizeReports: false,
|
||||
pixelLimit: 5,
|
||||
sixelSupport: false,
|
||||
sixelScrolling: false,
|
||||
sixelPaletteLimit: 1024,
|
||||
sixelSizeLimit: 1000,
|
||||
storageLimit: 10,
|
||||
showPlaceholder: false,
|
||||
iipSupport: false,
|
||||
iipSizeLimit: 1000,
|
||||
kittySupport: false,
|
||||
kittySizeLimit: 1000
|
||||
};
|
||||
await ctx.page.evaluate(opts => {
|
||||
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
||||
(window as any).term.loadAddon((window as any).imageAddonCustom);
|
||||
}, { opts: customSettings });
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddonCustom._opts`), customSettings);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('scrolling & cursor modes', () => {
|
||||
test('testdata default (scrolling with VT240 cursor pos)', async () => {
|
||||
const dim = await getDimensions();
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
deepStrictEqual(await getCursor(), [0, Math.floor(TESTDATA.height/dim.cellHeight)]);
|
||||
// moved to right by 10 cells
|
||||
await ctx.proxy.write('#'.repeat(10) + SIXEL_SEQ_0);
|
||||
deepStrictEqual(await getCursor(), [10, Math.floor(TESTDATA.height/dim.cellHeight) * 2]);
|
||||
});
|
||||
test('write testdata noScrolling', async () => {
|
||||
await ctx.proxy.write('\x1b[?80h' + SIXEL_SEQ_0);
|
||||
deepStrictEqual(await getCursor(), [0, 0]);
|
||||
// second draw does not change anything
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
deepStrictEqual(await getCursor(), [0, 0]);
|
||||
});
|
||||
test('testdata cursor always at VT240 pos', async () => {
|
||||
const dim = await getDimensions();
|
||||
// offset 0
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
deepStrictEqual(await getCursor(), [0, Math.floor(TESTDATA.height/dim.cellHeight)]);
|
||||
// moved to right by 10 cells
|
||||
await ctx.proxy.write('#'.repeat(10) + SIXEL_SEQ_0);
|
||||
deepStrictEqual(await getCursor(), [10, Math.floor(TESTDATA.height/dim.cellHeight) * 2]);
|
||||
// moved by 30 cells (+10 prev)
|
||||
await ctx.proxy.write('#'.repeat(30) + SIXEL_SEQ_0);
|
||||
deepStrictEqual(await getCursor(), [10 + 30, Math.floor(TESTDATA.height/dim.cellHeight) * 3]);
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
|
||||
// scroll to scrollback + rows - 1
|
||||
await ctx.page.evaluate(
|
||||
scrollback => new Promise(res => (window as any).term.write('\n'.repeat(scrollback), res)),
|
||||
(await getScrollbackPlusRows() - 1)
|
||||
);
|
||||
// wait here, as we have to make sure, that eviction did not yet occur
|
||||
await timeout(100);
|
||||
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
|
||||
// scroll one further should delete the image
|
||||
await ctx.page.evaluate(() => new Promise(res => (window as any).term.write('\n', res)));
|
||||
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 0);
|
||||
});
|
||||
test('get storageUsage', async () => {
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageUsage'), 0);
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
ok(Math.abs((await ctx.page.evaluate<number>('window.imageAddon.storageUsage')) - 640 * 80 * 4 / 1000000) < 0.05);
|
||||
});
|
||||
test('get/set storageLimit', async () => {
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageLimit'), 128);
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageLimit = 1'), 1);
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageLimit'), 1);
|
||||
});
|
||||
test('remove images by storage limit pressure', async () => {
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageLimit = 1'), 1);
|
||||
// never go beyond storage limit
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
await timeout(100);
|
||||
const usage = await ctx.page.evaluate('window.imageAddon.storageUsage');
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
await ctx.proxy.write(SIXEL_SEQ_0);
|
||||
await timeout(100);
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageUsage'), usage);
|
||||
strictEqual(usage as number < 1, true);
|
||||
});
|
||||
test('set storageLimit removes images synchronously', async () => {
|
||||
await ctx.proxy.write(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0);
|
||||
const usage: number = await ctx.page.evaluate('window.imageAddon.storageUsage');
|
||||
const newUsage: number = await ctx.page.evaluate('window.imageAddon.storageLimit = 0.5; window.imageAddon.storageUsage');
|
||||
strictEqual(newUsage < usage, true);
|
||||
strictEqual(newUsage < 0.5, true);
|
||||
});
|
||||
test('clear alternate images on buffer change', async () => {
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageUsage'), 0);
|
||||
await ctx.proxy.write('\x1b[?1049h' + SIXEL_SEQ_0);
|
||||
ok(Math.abs((await ctx.page.evaluate<number>('window.imageAddon.storageUsage')) - 640 * 80 * 4 / 1000000) < 0.05);
|
||||
await ctx.proxy.write('\x1b[?1049l');
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageUsage'), 0);
|
||||
});
|
||||
test('evict tiles by in-place overwrites (only full overwrite tested)', async () => {
|
||||
await timeout(50);
|
||||
await ctx.proxy.write('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H');
|
||||
await timeout(50);
|
||||
let usage = await ctx.page.evaluate('window.imageAddon.storageUsage');
|
||||
while (usage === 0) {
|
||||
await timeout(50);
|
||||
usage = await ctx.page.evaluate('window.imageAddon.storageUsage');
|
||||
}
|
||||
await ctx.proxy.write('\x1b[H' + SIXEL_SEQ_0 + '\x1b[100;100H');
|
||||
await timeout(200); // wait some time and re-check
|
||||
strictEqual(await ctx.page.evaluate('window.imageAddon.storageUsage'), usage);
|
||||
});
|
||||
test('manual eviction on alternate buffer must not miss images', async () => {
|
||||
await ctx.proxy.write('\x1b[?1049h');
|
||||
await ctx.proxy.write(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0);
|
||||
await timeout(100);
|
||||
const usage: number = await ctx.page.evaluate('window.imageAddon.storageUsage');
|
||||
await ctx.proxy.write(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0);
|
||||
await ctx.proxy.write(SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0 + SIXEL_SEQ_0);
|
||||
await timeout(100);
|
||||
const newUsage: number = await ctx.page.evaluate('window.imageAddon.storageUsage');
|
||||
strictEqual(newUsage, usage);
|
||||
});
|
||||
});
|
||||
|
||||
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('palette.png', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[0][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[0][1]);
|
||||
});
|
||||
test('spinfox.png', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[1][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
|
||||
});
|
||||
test('w3c gif', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[2][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[2][1]);
|
||||
});
|
||||
test('w3c jpeg', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[3][0]);
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[3][1]);
|
||||
});
|
||||
test('w3c png', async () => {
|
||||
await ctx.proxy.write(TESTDATA_IIP[4][0]);
|
||||
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]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* terminal access helpers.
|
||||
*/
|
||||
async function getDimensions(): Promise<IDimensions> {
|
||||
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
|
||||
return {
|
||||
cellWidth: Math.round(dimensions.css.cell.width),
|
||||
cellHeight: Math.round(dimensions.css.cell.height),
|
||||
width: Math.round(dimensions.css.canvas.width),
|
||||
height: Math.round(dimensions.css.canvas.height)
|
||||
};
|
||||
}
|
||||
|
||||
async function getCursor(): Promise<[number, number]> {
|
||||
return ctx.page.evaluate('[window.term.buffer.active.cursorX, window.term.buffer.active.cursorY]');
|
||||
}
|
||||
|
||||
async function getImageStorageLength(): Promise<number> {
|
||||
return ctx.page.evaluate('window.imageAddon._storage._images.size');
|
||||
}
|
||||
|
||||
async function getScrollbackPlusRows(): Promise<number> {
|
||||
return ctx.page.evaluate('window.term.options.scrollback + window.term.rows');
|
||||
}
|
||||
|
||||
async function getOrigSize(id: number): Promise<[number, number]> {
|
||||
return ctx.page.evaluate<any>(`[
|
||||
window.imageAddon._storage._images.get(${id}).orig.width,
|
||||
window.imageAddon._storage._images.get(${id}).orig.height
|
||||
]`);
|
||||
}
|
||||
|
||||
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
|
||||
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import { PlaywrightTestConfig } from '@playwright/test';
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: '.',
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'FirefoxStable',
|
||||
use: {
|
||||
browserName: 'firefox'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'WebKit',
|
||||
use: {
|
||||
browserName: 'webkit'
|
||||
}
|
||||
}
|
||||
],
|
||||
reporter: 'list',
|
||||
webServer: {
|
||||
command: 'npm run start',
|
||||
port: 3000,
|
||||
timeout: 120000,
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user