Compare commits
696
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4997f529 | ||
|
|
16ea2ec0ef | ||
|
|
eb0f5383c3 | ||
|
|
a4502a6d00 | ||
|
|
c11a8f43f4 | ||
|
|
d7ba56cd28 | ||
|
|
be44833f12 | ||
|
|
fcb3cc1a21 | ||
|
|
376268157c | ||
|
|
5ceb02b9c6 | ||
|
|
d320898231 | ||
|
|
cf3939af4a | ||
|
|
596ab845d0 | ||
|
|
a59d527d1a | ||
|
|
08f85cf826 | ||
|
|
e95ec8d4bc | ||
|
|
d15b426bd4 | ||
|
|
c907edd75f | ||
|
|
1029e19b57 | ||
|
|
d44900faa2 | ||
|
|
5f724c5924 | ||
|
|
ae58176bcb | ||
|
|
429b2882f1 | ||
|
|
ffac8c03ab | ||
|
|
a7c4a160a8 | ||
|
|
c9dc994090 | ||
|
|
e4710d3286 | ||
|
|
8f9045ed88 | ||
|
|
b8e5ec7304 | ||
|
|
6a3d2480e0 | ||
|
|
735afde77f | ||
|
|
53efc95170 | ||
|
|
3512f140de | ||
|
|
dbe35428cb | ||
|
|
297ac838a3 | ||
|
|
5316cb0812 | ||
|
|
c124550971 | ||
|
|
6525a7e68e | ||
|
|
b2133d93f0 | ||
|
|
a5e4f90139 | ||
|
|
a27b8feb8f | ||
|
|
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 | ||
|
|
085a275038 | ||
|
|
104c63d7e0 | ||
|
|
70c9861c39 | ||
|
|
c93623cb3b | ||
|
|
b0389a2ed1 | ||
|
|
7907f13774 | ||
|
|
956c7cbcba | ||
|
|
23bef99132 | ||
|
|
b104bcd144 | ||
|
|
809b9aa1ad | ||
|
|
9a536f758d | ||
|
|
15a63bae22 | ||
|
|
41640d9402 | ||
|
|
15664de472 |
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "xterm.js",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:18-bookworm",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": 18
|
||||
"version": 22
|
||||
} // yarn
|
||||
},
|
||||
"forwardPorts": [
|
||||
3000
|
||||
],
|
||||
"postCreateCommand": "yarn install && yarn setup",
|
||||
"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
|
||||
|
||||
-273
@@ -1,273 +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",
|
||||
"src/vs/tsconfig.json",
|
||||
"test/benchmark/tsconfig.json",
|
||||
"test/playwright/tsconfig.json",
|
||||
"addons/addon-attach/src/tsconfig.json",
|
||||
"addons/addon-attach/test/tsconfig.json",
|
||||
"addons/addon-clipboard/src/tsconfig.json",
|
||||
"addons/addon-clipboard/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-progress/src/tsconfig.json",
|
||||
"addons/addon-progress/test/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",
|
||||
"src/vs/*",
|
||||
"out/*",
|
||||
"out-test/*",
|
||||
"out-esbuild/*",
|
||||
"out-esbuild-test/*",
|
||||
"**/inwasm-sdks/*",
|
||||
"**/typings/*.d.ts",
|
||||
"**/node_modules",
|
||||
"**/*.js",
|
||||
"**/*.mjs"
|
||||
],
|
||||
"plugins": [
|
||||
"@stylistic/ts",
|
||||
"@typescript-eslint",
|
||||
"jsdoc"
|
||||
],
|
||||
"rules": {
|
||||
"@stylistic/ts/indent": [
|
||||
"warn",
|
||||
2
|
||||
],
|
||||
"@stylistic/ts/semi": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"@stylistic/ts/quotes": [
|
||||
"warn",
|
||||
"single",
|
||||
{ "allowTemplateLiterals": true }
|
||||
],
|
||||
|
||||
"@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/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",
|
||||
|
||||
"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-extra-semi": "error",
|
||||
"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,107 +0,0 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@stylistic/ts",
|
||||
"@typescript-eslint",
|
||||
"jsdoc"
|
||||
],
|
||||
"rules": {
|
||||
"@stylistic/ts/indent": [
|
||||
"warn",
|
||||
2
|
||||
],
|
||||
"@stylistic/ts/semi": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"@stylistic/ts/quotes": [
|
||||
"warn",
|
||||
"single",
|
||||
{ "allowTemplateLiterals": true }
|
||||
],
|
||||
|
||||
"@typescript-eslint/array-type": [
|
||||
"warn",
|
||||
{
|
||||
"default": "array",
|
||||
"readonly": "generic"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"warn",
|
||||
{
|
||||
"allowExpressions": true
|
||||
}
|
||||
],
|
||||
"@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",
|
||||
|
||||
"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-extra-semi": "error",
|
||||
"no-irregular-whitespace": "warn",
|
||||
"no-trailing-spaces": "warn",
|
||||
"object-curly-spacing": [
|
||||
"warn",
|
||||
"always"
|
||||
],
|
||||
"spaced-comment": [
|
||||
"warn",
|
||||
"always",
|
||||
{
|
||||
"markers": ["/"],
|
||||
"exceptions": ["-"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# xterm.js Copilot Instructions
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
**Core Structure**: xterm.js is a multi-target terminal emulator with three main distributions:
|
||||
- `src/browser/`: Full-featured browser terminal with DOM rendering
|
||||
- `src/headless/`: Server-side terminal for Node.js (no DOM)
|
||||
- `src/common/`: Shared core logic (parsing, buffer management, terminal state)
|
||||
|
||||
**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
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
applyTo: '**/*.benchmark.ts'
|
||||
---
|
||||
# Benchmark run instructions
|
||||
|
||||
- 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`
|
||||
|
||||
When writing 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,8 @@
|
||||
---
|
||||
applyTo: '**/*.test.ts'
|
||||
---
|
||||
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.
|
||||
+49
-47
@@ -12,17 +12,17 @@ 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
|
||||
run: npm ci
|
||||
- name: Setup and run tsc
|
||||
run: yarn setup
|
||||
run: npm run setup
|
||||
- name: Esbuild
|
||||
run: yarn esbuild
|
||||
run: npm run esbuild
|
||||
- name: Zip artifacts
|
||||
run: |
|
||||
zip -r compressed-build \
|
||||
@@ -62,6 +62,9 @@ jobs:
|
||||
./addons/addon-web-links/lib/* \
|
||||
./addons/addon-web-links/out/* \
|
||||
./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-*st/*
|
||||
@@ -77,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
|
||||
@@ -99,15 +101,14 @@ 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
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
@@ -122,7 +123,7 @@ 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
|
||||
@@ -131,7 +132,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18]
|
||||
node-version: [22]
|
||||
runs-on: [ubuntu, macos, windows]
|
||||
runs-on: ${{ matrix.runs-on }}-latest
|
||||
steps:
|
||||
@@ -140,11 +141,10 @@ 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:
|
||||
@@ -163,13 +163,13 @@ jobs:
|
||||
fi
|
||||
ls -R
|
||||
- name: Unit tests
|
||||
run: yarn test-unit --forbid-only
|
||||
run: npm run test-unit --forbid-only
|
||||
|
||||
test-integration:
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18] # just one as integration tests are about testing in browser
|
||||
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 }}
|
||||
@@ -179,11 +179,10 @@ 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: Install playwright
|
||||
run: npx playwright install --with-deps ${{ matrix.browser }}
|
||||
- name: Wait for build job
|
||||
@@ -203,50 +202,53 @@ jobs:
|
||||
unzip -o compressed-build.zip
|
||||
fi
|
||||
ls -R
|
||||
- name: Build demo
|
||||
run: yarn esbuild-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-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
|
||||
- name: Integration tests (addon-attach)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
|
||||
- name: Integration tests (addon-clipboard)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
|
||||
- name: Integration tests (addon-fit)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
|
||||
- name: Integration tests (addon-image)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
|
||||
- name: Integration tests (addon-progress)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
|
||||
- name: Integration tests (addon-search)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
|
||||
- name: Integration tests (addon-serialize)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
|
||||
- name: Integration tests (addon-unicode-graphemes)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
|
||||
- name: Integration tests (addon-unicode11)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
|
||||
- name: Integration tests (addon-web-fonts)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
|
||||
- name: Integration tests (addon-web-links)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
|
||||
- name: Integration tests (addon-webgl)
|
||||
run: yarn test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
|
||||
|
||||
release-dry-run:
|
||||
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@v4
|
||||
@@ -263,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:
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ node_modules/
|
||||
.lock-wscript
|
||||
lib/
|
||||
out/
|
||||
out-demo/
|
||||
out-test/
|
||||
out-esbuild/
|
||||
out-esbuild-test/
|
||||
@@ -18,7 +19,8 @@ npm-debug.log
|
||||
.env
|
||||
build/
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
test-results/
|
||||
|
||||
# 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
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-1
@@ -61,7 +61,7 @@
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["start"],
|
||||
"stopOnEntry": true,
|
||||
"runtimeVersion": "18",
|
||||
"runtimeVersion": "22",
|
||||
"serverReadyAction": {
|
||||
"action": "openExternally",
|
||||
"pattern": "App listening to (http://.*?:[0-9]+)"
|
||||
|
||||
Vendored
+1
-20
@@ -1,23 +1,4 @@
|
||||
{
|
||||
"files.associations": {
|
||||
".eslintrc.json.typings": "jsonc"
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/.nyc_output": true
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.preferences.quoteStyle": "single",
|
||||
"mochaExplorer.envPath": ".mocha.env",
|
||||
"mochaExplorer.files": [
|
||||
"out/**/*.test.js",
|
||||
"addons/**/out/*.test.js",
|
||||
"out-*/**/*.test.js",
|
||||
"addons/**/out-*/*.test.js"
|
||||
],
|
||||
"mochaExplorer.watch": [
|
||||
"out/**/*.js",
|
||||
"addons/**/out/*.js",
|
||||
"out-*/**/*.js",
|
||||
"addons/**/out-*/*.js"
|
||||
]
|
||||
"typescript.preferences.quoteStyle": "single"
|
||||
}
|
||||
|
||||
Vendored
+25
-4
@@ -10,13 +10,22 @@
|
||||
"tasks": [
|
||||
// Compound tasks
|
||||
{
|
||||
"label": "Development",
|
||||
"dependsOn": ["demo-server", "tsc", "esbuild", "esbuild-demo"],
|
||||
"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
|
||||
{
|
||||
@@ -55,9 +64,21 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "esbuild-demo",
|
||||
"label": "esbuild-demo-client",
|
||||
"type": "npm",
|
||||
"script": "esbuild-demo-watch",
|
||||
"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,
|
||||
|
||||
+35
-89
@@ -1,104 +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.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests
|
||||
|
||||
Unit tests are run with `yarn test-unit`:
|
||||
|
||||
```sh
|
||||
# All unit tests
|
||||
yarn test-unit
|
||||
|
||||
# Absolute file path
|
||||
yarn test-unit out-esbuild/browser/Terminal.test.js
|
||||
|
||||
# Filter by wildcard
|
||||
yarn test-unit out-esbuild/**/Terminal.test.js
|
||||
|
||||
# Specific addon unit tests tests
|
||||
yarn test-unit addons/addon-image/out-esbuild/*.test.js
|
||||
|
||||
# Multiple files
|
||||
yarn test-unit out-esbuild/**/Terminal.test.js out-esbuild/**/InputHandler.test.js
|
||||
```
|
||||
|
||||
These use mocha to run all `.test.js` files within the esbuild output (`out-esbuild/`).
|
||||
|
||||
### Integration tests
|
||||
|
||||
Integration tests are run with `yarn test-integration`:
|
||||
|
||||
```sh
|
||||
# All integration tests
|
||||
yarn test-integration
|
||||
|
||||
# Core integration tests
|
||||
yarn test-integration --suite=core
|
||||
|
||||
# Specific addon integration tests
|
||||
yarn test-integration --suite=addon-search
|
||||
```
|
||||
|
||||
These use `@playwright/test` to run all tests within the esbuild test output (`out-esbuild-test/`).
|
||||
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,27 +1,27 @@
|
||||
# [](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/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`.
|
||||
@@ -54,12 +54,10 @@ 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`:
|
||||
@@ -80,50 +78,51 @@ The xterm.js team maintains the following addons, but anyone can build them:
|
||||
- [`@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/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.
|
||||
@@ -229,14 +228,15 @@ Xterm.js is used in several world-class applications to provide great terminal e
|
||||
- [**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.
|
||||
- [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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xterm/addon-attach",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
@@ -21,8 +21,5 @@
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'ChromeStable',
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
channel: 'chrome'
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xterm/addon-clipboard",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
@@ -22,9 +22,6 @@
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'ChromeStable',
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
channel: 'chrome'
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
js-base64@^3.7.5:
|
||||
version "3.7.7"
|
||||
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.7.tgz#e51b84bf78fbf5702b9541e2cb7bfcb893b43e79"
|
||||
integrity sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xterm/addon-fit",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
@@ -21,8 +21,5 @@
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* @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 'browser/shared/Constants';
|
||||
|
||||
interface ITerminalDimensions {
|
||||
@@ -23,7 +22,18 @@ interface ITerminalDimensions {
|
||||
const MINIMUM_COLS = 2;
|
||||
const 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 {
|
||||
@@ -38,12 +48,8 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -57,11 +63,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -69,10 +73,10 @@ export class FitAddon implements ITerminalAddon , IFitApi {
|
||||
? 0
|
||||
: (this._terminal.options.overviewRuler?.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')),
|
||||
|
||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'ChromeStable',
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
channel: 'chrome'
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,7 +17,6 @@ npm-debug.log
|
||||
.env
|
||||
build/
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Keep bundled code out of Git
|
||||
|
||||
@@ -30,7 +30,9 @@ const customSettings: IImageAddonOptions = {
|
||||
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: 20000000, // size limit of a single IIP sequence
|
||||
kittySupport: true, // enable Kitty graphics support
|
||||
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
|
||||
}
|
||||
|
||||
// initialization
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
Binary file not shown.
|
After Width: | Height: | Size: 449 B |
Binary file not shown.
|
After Width: | Height: | Size: 131 B |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xterm/addon-image",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
@@ -23,11 +23,8 @@
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sixel": "^0.16.0",
|
||||
"xterm-wasm-parts": "^0.1.0"
|
||||
"xterm-wasm-parts": "^0.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||
|
||||
|
||||
// eslint-disable-next-line
|
||||
declare const Buffer: any;
|
||||
|
||||
// limit hold memory in base64 decoder
|
||||
// limit hold memory in base64 decoder (encoded bytes)
|
||||
const KEEP_DATA = 4194304;
|
||||
const INITIAL_DATA = 1048576;
|
||||
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
const DECODER_OK: DecodeStatus.OK = 0;
|
||||
|
||||
// default IIP header values
|
||||
const DEFAULT_HEADER: IHeaderFields = {
|
||||
@@ -31,7 +31,7 @@ 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 _metrics = UNSUPPORTED_TYPE;
|
||||
|
||||
constructor(
|
||||
@@ -39,7 +39,11 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _storage: ImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {}
|
||||
) {
|
||||
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
||||
const initialBytes = Math.min(INITIAL_DATA, maxEncodedBytes);
|
||||
this._dec = new Base64Decoder(KEEP_DATA, maxEncodedBytes, initialBytes);
|
||||
}
|
||||
|
||||
public reset(): void {}
|
||||
|
||||
@@ -54,7 +58,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)) !== DECODER_OK) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
}
|
||||
@@ -70,8 +74,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
this._dec.init(this._header.size);
|
||||
if (this._dec.put(data, dataPos, end)) {
|
||||
this._dec.init();
|
||||
if (this._dec.put(data.subarray(dataPos, end)) !== DECODER_OK) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
}
|
||||
@@ -89,13 +93,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
let cond: number | boolean = true;
|
||||
if (cond = success) {
|
||||
if (cond = !this._dec.end()) {
|
||||
this._metrics = imageType(this._dec.data8);
|
||||
if (cond = this._metrics.mime !== 'unsupported') {
|
||||
w = this._metrics.width;
|
||||
h = this._metrics.height;
|
||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||
[w, h] = this._resize(w, h).map(Math.floor);
|
||||
cond = w && h && w * h < this._opts.pixelLimit;
|
||||
if (cond = this._dec.data8.length === this._header.size) {
|
||||
this._metrics = imageType(this._dec.data8);
|
||||
if (cond = this._metrics.mime !== 'unsupported') {
|
||||
w = this._metrics.width;
|
||||
h = this._metrics.height;
|
||||
if (cond = w && h && w * h < this._opts.pixelLimit) {
|
||||
[w, h] = this._resize(w, h).map(Math.floor);
|
||||
cond = w && h && w * h < this._opts.pixelLimit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +111,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
const blob = new Blob([this._dec.data8], { type: this._metrics.mime });
|
||||
// HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it
|
||||
const blob = new Blob([this._dec.data8 as Uint8Array<ArrayBuffer>], { type: this._metrics.mime });
|
||||
this._dec.release();
|
||||
|
||||
if (!window.createImageBitmap) {
|
||||
|
||||
@@ -8,6 +8,7 @@ declare const Buffer: any;
|
||||
|
||||
|
||||
export interface IHeaderFields {
|
||||
[key: string]: number | string | Uint32Array | null | undefined;
|
||||
// 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.
|
||||
@@ -81,7 +82,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,
|
||||
@@ -100,7 +101,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);
|
||||
@@ -176,7 +177,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;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ImageAddon as IImageApi } from '@xterm/addon-image';
|
||||
import { IIPHandler } from './IIPHandler';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
|
||||
import { SixelHandler } from './SixelHandler';
|
||||
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
||||
|
||||
@@ -22,7 +23,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000
|
||||
iipSizeLimit: 20000000,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
};
|
||||
|
||||
// max palette size supported by the sixel lib (compile time setting)
|
||||
@@ -48,7 +51,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;
|
||||
@@ -90,7 +93,7 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
// 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;
|
||||
@@ -144,6 +147,15 @@ export class ImageAddon implements ITerminalAddon , IImageApi {
|
||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||
);
|
||||
}
|
||||
|
||||
// Kitty graphics handler
|
||||
if (this._opts.kittySupport) {
|
||||
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, this._storage!, terminal);
|
||||
this._handlers.set('kitty', kittyHandler);
|
||||
this._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
|
||||
|
||||
@@ -38,7 +38,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;
|
||||
@@ -124,7 +124,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +242,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
}
|
||||
if (!this._placeholder) return;
|
||||
this._ctx.drawImage(
|
||||
this._placeholderBitmap || this._placeholder!,
|
||||
this._placeholderBitmap ?? this._placeholder!,
|
||||
col * width,
|
||||
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
|
||||
width * count,
|
||||
|
||||
@@ -132,8 +132,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 = {
|
||||
@@ -233,9 +235,22 @@ export class ImageStorage implements IDisposable {
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to add an image to the storage.
|
||||
* Delete an image by its internal storage ID.
|
||||
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||
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.
|
||||
* Returns the internal image ID assigned to the stored image.
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
|
||||
// never allow storage to exceed memory limit
|
||||
this._evictOldest(img.width * img.height);
|
||||
|
||||
@@ -329,6 +344,7 @@ export class ImageStorage implements IDisposable {
|
||||
|
||||
// finally add the image
|
||||
this._images.set(imageId, imgSpec);
|
||||
return imageId;
|
||||
}
|
||||
|
||||
|
||||
@@ -381,7 +397,7 @@ export class ImageStorage implements IDisposable {
|
||||
if (!line) return;
|
||||
for (let col = 0; col < cols; ++col) {
|
||||
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
|
||||
let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS;
|
||||
let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;
|
||||
const imageId = e.imageId;
|
||||
if (imageId === undefined || imageId === -1) {
|
||||
continue;
|
||||
@@ -400,7 +416,7 @@ export class ImageStorage implements IDisposable {
|
||||
while (
|
||||
++col < cols
|
||||
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
|
||||
&& (e = line._extendedAttrs[col] || EMPTY_ATTRS)
|
||||
&& (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)
|
||||
&& (e.imageId === imageId)
|
||||
&& (e.tileId === startTile + count)
|
||||
) {
|
||||
@@ -442,7 +458,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 +503,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 +523,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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
|
||||
import { ImageRenderer } from '../ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
|
||||
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import {
|
||||
KittyAction,
|
||||
KittyFormat,
|
||||
KittyCompression,
|
||||
IKittyCommand,
|
||||
IPendingTransmission,
|
||||
IKittyImageData,
|
||||
BYTES_PER_PIXEL_RGB,
|
||||
BYTES_PER_PIXEL_RGBA,
|
||||
ALPHA_OPAQUE,
|
||||
parseKittyCommand
|
||||
} from './KittyGraphicsTypes';
|
||||
|
||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||
const DECODER_KEEP_DATA = 4194304;
|
||||
const DECODER_INITIAL_DATA = 4194304; // 4MB
|
||||
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
const DECODER_OK: DecodeStatus.OK = 0;
|
||||
|
||||
// Maximum control data size
|
||||
const MAX_CONTROL_DATA_SIZE = 512;
|
||||
|
||||
// Semicolon codepoint
|
||||
const SEMICOLON = 0x3B;
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol handler with streaming base64 decoding.
|
||||
*/
|
||||
export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
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(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();
|
||||
private _nextImageId = 1;
|
||||
/** Maps Kitty protocol image ID → ImageStorage internal ID for deletion/lookup. */
|
||||
private _kittyIdToStorageId: Map<number, number> = new Map();
|
||||
// TODO: Eliminate double storage — raw image data lives here (as Blob) AND rendered
|
||||
// ImageBitmaps live in ImageStorage. Currently we only use ImageStorage.addImage(bitmap)
|
||||
// for tiling + cursor movement + marker-based eviction.
|
||||
//
|
||||
|
||||
// See: https://github.com/xtermjs/xterm.js/pull/5619#issuecomment-3853678815
|
||||
private _images: Map<number, IKittyImageData> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _storage: ImageStorage,
|
||||
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(DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
for (const pending of this._pendingTransmissions.values()) {
|
||||
pending.decoder.release();
|
||||
}
|
||||
this._pendingTransmissions.clear();
|
||||
if (this._activeDecoder) {
|
||||
this._activeDecoder.release();
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
this._images.clear();
|
||||
this._kittyIdToStorageId.clear();
|
||||
}
|
||||
|
||||
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] === SEMICOLON) {
|
||||
this._inControlData = false;
|
||||
controlEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy control data
|
||||
const copyLength = controlEnd - start;
|
||||
if (this._controlLength + copyLength > 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
|
||||
const pendingKey = this._parsedCommand?.id ?? 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._pendingTransmissions.delete(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(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._pendingTransmissions.delete(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);
|
||||
}
|
||||
|
||||
const pendingKey = cmd.id ?? 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._activeDecoder = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
default:
|
||||
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);
|
||||
if (cmd.id !== undefined && !decodeError && 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);
|
||||
default:
|
||||
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)
|
||||
|
||||
if (decodeError || bytes.length === 0) return true;
|
||||
|
||||
const id = cmd.id ?? this._nextImageId++;
|
||||
const image: IKittyImageData = {
|
||||
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 ?? ''
|
||||
};
|
||||
this._images.set(id, image);
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
||||
if (decodeError) return true;
|
||||
|
||||
const pendingKey = cmd.id ?? 0;
|
||||
|
||||
this._handleTransmit(cmd, bytes, decodeError);
|
||||
|
||||
// If still accumulating chunks, don't display yet
|
||||
if (this._pendingTransmissions.has(pendingKey)) return true;
|
||||
|
||||
// Display the completed image
|
||||
const id = cmd.id ?? this._nextImageId - 1;
|
||||
const image = this._images.get(id);
|
||||
if (image) {
|
||||
const result = this._displayImage(image, cmd);
|
||||
if (cmd.id !== undefined) {
|
||||
return (result as Promise<boolean>).then(r => {
|
||||
this._sendResponse(id, 'OK', cmd.quiet ?? 0);
|
||||
return r;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
||||
const id = cmd.id ?? 0;
|
||||
const quiet = cmd.quiet ?? 0;
|
||||
|
||||
// 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 ? BYTES_PER_PIXEL_RGBA : 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 {
|
||||
const id = cmd.id;
|
||||
|
||||
if (id !== undefined) {
|
||||
// Abort in-flight chunked upload for this specific image
|
||||
const pending = this._pendingTransmissions.get(id);
|
||||
if (pending) {
|
||||
pending.decoder.release();
|
||||
this._pendingTransmissions.delete(id);
|
||||
}
|
||||
|
||||
this._images.delete(id);
|
||||
const storageId = this._kittyIdToStorageId.get(id);
|
||||
if (storageId !== undefined) {
|
||||
this._storage.deleteImage(storageId);
|
||||
this._kittyIdToStorageId.delete(id);
|
||||
}
|
||||
} else {
|
||||
// Abort all in-flight chunked uploads
|
||||
for (const pending of this._pendingTransmissions.values()) {
|
||||
pending.decoder.release();
|
||||
}
|
||||
this._pendingTransmissions.clear();
|
||||
|
||||
this._images.clear();
|
||||
for (const storageId of this._kittyIdToStorageId.values()) {
|
||||
this._storage.deleteImage(storageId);
|
||||
}
|
||||
this._kittyIdToStorageId.clear();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _sendResponse(id: number, message: string, quiet: number): void {
|
||||
const isOk = message === 'OK';
|
||||
if (isOk && quiet === 1) return;
|
||||
if (!isOk && quiet === 2) return;
|
||||
|
||||
const response = `\x1b_Gi=${id};${message}\x1b\\`;
|
||||
this._coreTerminal._core.coreService.triggerDataEvent(response);
|
||||
}
|
||||
|
||||
// Image display
|
||||
|
||||
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): boolean | Promise<boolean> {
|
||||
return this._decodeAndDisplay(image, cmd)
|
||||
.then(() => true)
|
||||
.catch(() => true);
|
||||
}
|
||||
|
||||
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||
const bitmap = await this._createBitmap(image);
|
||||
|
||||
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
|
||||
const imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw);
|
||||
const imgRows = cmd.rows ?? 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) return;
|
||||
|
||||
// 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;
|
||||
|
||||
let storageId: number;
|
||||
if (w !== bitmap.width || h !== bitmap.height) {
|
||||
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||
storageId = this._storage.addImage(resized);
|
||||
} else {
|
||||
storageId = this._storage.addImage(bitmap);
|
||||
}
|
||||
this._kittyIdToStorageId.set(image.id, storageId);
|
||||
|
||||
// Kitty cursor movement
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', reject);
|
||||
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 ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
throw new Error('Insufficient pixel data');
|
||||
}
|
||||
|
||||
const pixelCount = width * height;
|
||||
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
|
||||
const isRgba = image.format === KittyFormat.RGBA;
|
||||
|
||||
let srcOffset = 0;
|
||||
let dstOffset = 0;
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
data[dstOffset] = bytes[srcOffset];
|
||||
data[dstOffset + 1] = bytes[srcOffset + 1];
|
||||
data[dstOffset + 2] = bytes[srcOffset + 2];
|
||||
data[dstOffset + 3] = isRgba ? bytes[srcOffset + 3] : ALPHA_OPAQUE;
|
||||
srcOffset += bytesPerPixel;
|
||||
dstOffset += 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._images;
|
||||
}
|
||||
|
||||
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
|
||||
return this._pendingTransmissions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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',
|
||||
// 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'
|
||||
}
|
||||
|
||||
// Pixel format constants
|
||||
export const BYTES_PER_PIXEL_RGB = 3;
|
||||
export const BYTES_PER_PIXEL_RGBA = 4;
|
||||
export const 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;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
more?: number;
|
||||
quiet?: number;
|
||||
cursorMovement?: 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;
|
||||
}
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export interface IImageAddonOptions {
|
||||
sixelSizeLimit: number;
|
||||
iipSupport: boolean;
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
@@ -134,7 +136,9 @@ test.describe('ImageAddon', () => {
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000
|
||||
iipSizeLimit: 20000000,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
};
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||
});
|
||||
@@ -149,7 +153,9 @@ test.describe('ImageAddon', () => {
|
||||
storageLimit: 10,
|
||||
showPlaceholder: false,
|
||||
iipSupport: false,
|
||||
iipSizeLimit: 1000
|
||||
iipSizeLimit: 1000,
|
||||
kittySupport: false,
|
||||
kittySizeLimit: 1000
|
||||
};
|
||||
await ctx.page.evaluate(opts => {
|
||||
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
||||
@@ -300,7 +306,7 @@ test.describe('ImageAddon', () => {
|
||||
* terminal access helpers.
|
||||
*/
|
||||
async function getDimensions(): Promise<IDimensions> {
|
||||
const dimensions: any = await ctx.page.evaluate(`term._core._renderService.dimensions`);
|
||||
const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
|
||||
return {
|
||||
cellWidth: Math.round(dimensions.css.cell.width),
|
||||
cellHeight: Math.round(dimensions.css.cell.height),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'ChromeStable',
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
channel: 'chrome'
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+9
@@ -76,6 +76,15 @@ declare module '@xterm/addon-image' {
|
||||
iipSupport?: boolean;
|
||||
/** IIP sequence size limit (default 20000000 bytes). */
|
||||
iipSizeLimit?: number;
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol settings
|
||||
*/
|
||||
|
||||
/** Whether Kitty graphics protocol is enabled (default is true). */
|
||||
kittySupport?: boolean;
|
||||
/** Kitty image size limit in bytes (default 20000000 bytes). */
|
||||
kittySizeLimit?: number;
|
||||
}
|
||||
|
||||
export class ImageAddon implements ITerminalAddon {
|
||||
|
||||
@@ -3,11 +3,9 @@ node_modules/
|
||||
coverage/
|
||||
|
||||
lib/
|
||||
fonts/
|
||||
|
||||
.env
|
||||
.vscode/
|
||||
*.swp
|
||||
*.tgz
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018
|
||||
Copyright (c) 2019, 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
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The code that analyzes font ligatures is forked from https://github.com/princjef/font-ligatures with this license:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Jeffrey Principe
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
|
||||
const axios = require('axios').default;
|
||||
const mkdirp = require('mkdirp');
|
||||
const yauzl = require('yauzl');
|
||||
|
||||
const urls = {
|
||||
fira: 'https://github.com/tonsky/FiraCode/raw/d42e7276fa925e5f82748f3ec9ea429736611b48/distr/otf/FiraCode-Regular.otf',
|
||||
iosevka: 'https://github.com/be5invis/Iosevka/releases/download/v1.14.3/01-iosevka-1.14.3.zip'
|
||||
};
|
||||
|
||||
const writeFile = util.promisify(fs.writeFile);
|
||||
const fontsFolder = path.join(__dirname, '../fonts');
|
||||
|
||||
async function download() {
|
||||
await mkdirp(fontsFolder);
|
||||
|
||||
try {
|
||||
await downloadFiraCode();
|
||||
await downloadIosevka();
|
||||
console.log('Loaded all fonts for testing')
|
||||
} catch (e) {
|
||||
console.warn('Fonts failed to download, ligature tests will not work', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFiraCode() {
|
||||
const file = path.join(fontsFolder, 'firaCode.otf');
|
||||
if (await util.promisify(fs.exists)(file)) {
|
||||
console.log('Fira Code already loaded');
|
||||
} else {
|
||||
console.log('Downloading Fira Code...');
|
||||
await writeFile(
|
||||
file,
|
||||
(await axios.get(urls.fira, { responseType: 'arraybuffer' })).data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadIosevka() {
|
||||
const file = path.join(fontsFolder, 'iosevka.ttf');
|
||||
if (await util.promisify(fs.exists)(file)) {
|
||||
console.log('Iosevka already loaded');
|
||||
} else {
|
||||
console.log('Downloading Iosevka...');
|
||||
const iosevkaContents = (await axios.get(urls.iosevka, { responseType: 'arraybuffer' })).data;
|
||||
const iosevkaZipfile = await util.promisify(yauzl.fromBuffer)(iosevkaContents);
|
||||
await new Promise((resolve, reject) => {
|
||||
iosevkaZipfile.on('entry', entry => {
|
||||
if (entry.fileName === 'ttf/iosevka-regular.ttf') {
|
||||
iosevkaZipfile.openReadStream(entry, (err, stream) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const writeStream = fs.createWriteStream(file);
|
||||
stream.pipe(writeStream);
|
||||
writeStream.on('close', () => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
download();
|
||||
|
||||
process.on('unhandledRejection', e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xterm/addon-ligatures",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"description": "Add support for programming ligatures to xterm.js",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
@@ -14,7 +14,6 @@
|
||||
"node": ">8.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"prepare": "node bin/download-fonts.js",
|
||||
"build": "tsc -p src",
|
||||
"watch": "tsc -w -p src",
|
||||
"prepackage": "npm run build",
|
||||
@@ -32,17 +31,15 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"font-finder": "^1.1.0",
|
||||
"font-ligatures": "^1.4.1"
|
||||
"lru-cache": "^6.0.0",
|
||||
"opentype.js": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sinon": "^5.0.1",
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/opentype.js": "^0.7.0",
|
||||
"axios": "^1.6.0",
|
||||
"font-finder": "^1.1.0",
|
||||
"mkdirp": "0.5.5",
|
||||
"sinon": "6.3.5",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface ITerminalAddon {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class LigaturesAddon implements ITerminalAddon , ILigaturesApi {
|
||||
export class LigaturesAddon implements ITerminalAddon, ILigaturesApi {
|
||||
private readonly _fallbackLigatures: string[];
|
||||
private readonly _fontFeatureSettings?: string;
|
||||
|
||||
@@ -22,7 +22,7 @@ export class LigaturesAddon implements ITerminalAddon , ILigaturesApi {
|
||||
|
||||
constructor(options?: Partial<ILigatureOptions>) {
|
||||
// Source: calt set from https://github.com/be5invis/Iosevka?tab=readme-ov-file#ligations
|
||||
this._fallbackLigatures = (options?.fallbackLigatures || [
|
||||
this._fallbackLigatures = (options?.fallbackLigatures ?? [
|
||||
'<--', '<---', '<<-', '<-', '->', '->>', '-->', '--->',
|
||||
'<==', '<===', '<<=', '<=', '=>', '=>>', '==>', '===>', '>=', '>>=',
|
||||
'<->', '<-->', '<--->', '<---->', '<=>', '<==>', '<===>', '<====>', '::', ':::',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Font, loadBuffer } from 'font-ligatures';
|
||||
import { Font, loadBuffer } from './fontLigatures/index';
|
||||
|
||||
import parse from './parse';
|
||||
|
||||
@@ -42,11 +42,11 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
|
||||
if (status && status.state !== 'granted') {
|
||||
throw new Error('Permission to access local fonts not granted.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// A `TypeError` indicates the 'local-fonts'
|
||||
// permission is not yet implemented, so
|
||||
// only `throw` if this is _not_ the problem.
|
||||
if (err.name !== 'TypeError') {
|
||||
if (err instanceof Error && err.name !== 'TypeError') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,10 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
|
||||
fonts[metadata.family].push(metadata);
|
||||
}
|
||||
fontsPromise = Promise.resolve(fonts);
|
||||
} catch (err: any) {
|
||||
console.error(err.name, err.message);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
console.error(err.name, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Latest proposal https://bugs.chromium.org/p/chromium/issues/detail?id=1312603
|
||||
@@ -76,13 +78,13 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
|
||||
fonts[metadata.family].push(metadata);
|
||||
}
|
||||
fontsPromise = Promise.resolve(fonts);
|
||||
} catch (err: any) {
|
||||
console.error(err.name, err.message);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
console.error(err.name, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fontsPromise) {
|
||||
fontsPromise = Promise.resolve({});
|
||||
}
|
||||
fontsPromise ??= Promise.resolve({});
|
||||
}
|
||||
|
||||
const fonts = await fontsPromise;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ILookupTree, IFlattenedLookupTree, ILookupTreeEntry, IFlattenedLookupTreeEntry } from './types';
|
||||
|
||||
export default function flatten(tree: ILookupTree, visited: Map<ILookupTreeEntry, IFlattenedLookupTreeEntry> = new Map()): IFlattenedLookupTree {
|
||||
const result: IFlattenedLookupTree = {};
|
||||
for (const [glyphId, entry] of Object.entries(tree.individual)) {
|
||||
result[glyphId] = flattenEntry(entry, visited);
|
||||
}
|
||||
|
||||
for (const { range, entry } of tree.range) {
|
||||
const flattened = flattenEntry(entry, visited);
|
||||
for (let glyphId = range[0]; glyphId < range[1]; glyphId++) {
|
||||
result[glyphId] = flattened;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function flattenEntry(entry: ILookupTreeEntry, visited: Map<ILookupTreeEntry, IFlattenedLookupTreeEntry>): IFlattenedLookupTreeEntry {
|
||||
if (visited.has(entry)) {
|
||||
return visited.get(entry)!;
|
||||
}
|
||||
|
||||
const result: IFlattenedLookupTreeEntry = {};
|
||||
visited.set(entry, result);
|
||||
|
||||
if (entry.forward) {
|
||||
result.forward = flatten(entry.forward, visited);
|
||||
}
|
||||
|
||||
if (entry.reverse) {
|
||||
result.reverse = flatten(entry.reverse, visited);
|
||||
}
|
||||
|
||||
if (entry.lookup) {
|
||||
result.lookup = entry.lookup;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { assert } from 'chai';
|
||||
import { loadBuffer } from './index';
|
||||
|
||||
interface IFont {
|
||||
findLigatures(text: string): { outputGlyphs: number[], contextRanges: [number, number][] };
|
||||
findLigatureRanges(text: string): [number, number][];
|
||||
}
|
||||
|
||||
interface ITestCase {
|
||||
font: string;
|
||||
input: string;
|
||||
glyphs: number[];
|
||||
ranges: [number, number][];
|
||||
}
|
||||
|
||||
const fira = (input: string, glyphs: number[], ranges: [number, number][]): ITestCase =>
|
||||
({ font: 'Fira Code', input, glyphs, ranges });
|
||||
|
||||
const iosevka = (input: string, glyphs: number[], ranges: [number, number][]): ITestCase =>
|
||||
({ font: 'Iosevka', input, glyphs, ranges });
|
||||
|
||||
const monoid = (input: string, glyphs: number[], ranges: [number, number][]): ITestCase =>
|
||||
({ font: 'Monoid', input, glyphs, ranges });
|
||||
|
||||
const ubuntu = (input: string, glyphs: number[], ranges: [number, number][]): ITestCase =>
|
||||
({ font: 'Ubuntu Mono', input, glyphs, ranges });
|
||||
|
||||
const firaCases: ITestCase[] = [
|
||||
fira('abc', [133, 145, 146], []),
|
||||
fira('.=', [1614, 1081], [[0, 2]]),
|
||||
fira('..=', [1614, 1614, 1083], [[0, 3]]),
|
||||
fira('.-', [1614, 1080], [[0, 2]]),
|
||||
fira(':=', [1614, 1055], [[0, 2]]),
|
||||
fira('=:=', [1614, 1614, 1483], [[0, 3]]),
|
||||
fira('=!=', [1614, 1614, 1484], [[0, 3]]),
|
||||
fira('__', [1614, 1099], [[0, 2]]),
|
||||
fira('==', [1614, 1485], [[0, 2]]),
|
||||
fira('!=', [1614, 1058], [[0, 2]]),
|
||||
fira('===', [1614, 1614, 1486], [[0, 3]]),
|
||||
fira('!==', [1614, 1614, 1059], [[0, 3]]),
|
||||
fira('=/=', [1614, 1614, 1491], [[0, 3]]),
|
||||
fira('<-<', [1614, 1614, 1513], [[0, 3]]),
|
||||
fira('<<-', [1614, 1614, 1522], [[0, 3]]),
|
||||
fira('<--', [1614, 1614, 1511], [[0, 3]]),
|
||||
fira('<-', [1614, 1510], [[0, 2]]),
|
||||
fira('<->', [1614, 1614, 1512], [[0, 3]]),
|
||||
fira('->', [1614, 1064], [[0, 2]]),
|
||||
fira('-->', [1614, 1614, 1063], [[0, 3]]),
|
||||
fira('->>', [1614, 1614, 1065], [[0, 3]]),
|
||||
fira('>->', [1614, 1614, 1493], [[0, 3]]),
|
||||
fira('<=<', [1614, 1614, 1519], [[0, 3]]),
|
||||
fira('<<=', [1614, 1614, 1523], [[0, 3]]),
|
||||
fira('<==', [1614, 1614, 1517], [[0, 3]]),
|
||||
fira('<=>', [1614, 1614, 1518], [[0, 3]]),
|
||||
fira('=>', [1614, 1488], [[0, 2]]),
|
||||
fira('==>', [1614, 1614, 1487], [[0, 3]]),
|
||||
fira('=>>', [1614, 1614, 1489], [[0, 3]]),
|
||||
fira('>=>', [1614, 1614, 1495], [[0, 3]]),
|
||||
fira('>>=', [1614, 1614, 1498], [[0, 3]]),
|
||||
fira('>>-', [1614, 1614, 1497], [[0, 3]]),
|
||||
fira('>-', [1614, 1492], [[0, 2]]),
|
||||
fira('<~>', [1614, 1614, 1526], [[0, 3]]),
|
||||
fira('-<', [1614, 1066], [[0, 2]]),
|
||||
fira('-<<', [1614, 1614, 1067], [[0, 3]]),
|
||||
fira('=<<', [1614, 1614, 1490], [[0, 3]]),
|
||||
fira('<~~', [1614, 1614, 1527], [[0, 3]]),
|
||||
fira('<~', [1614, 1525], [[0, 2]]),
|
||||
fira('~~', [1614, 1534], [[0, 2]]),
|
||||
fira('~>', [1614, 1533], [[0, 2]]),
|
||||
fira('~~>', [1614, 1614, 1535], [[0, 3]]),
|
||||
fira('<<<', [1614, 1614, 1524], [[0, 3]]),
|
||||
fira('<<', [1614, 1521], [[0, 2]]),
|
||||
fira('<=', [1614, 1516], [[0, 2]]),
|
||||
fira('<>', [1614, 1520], [[0, 2]]),
|
||||
fira('>=', [1614, 1494], [[0, 2]]),
|
||||
fira('>>', [1614, 1496], [[0, 2]]),
|
||||
fira('>>>', [1614, 1614, 1499], [[0, 3]]),
|
||||
fira('{.', [1001, 977], [[0, 2]]),
|
||||
fira('{|', [1614, 1049], [[0, 2]]),
|
||||
fira('[|', [1614, 1050], [[0, 2]]),
|
||||
fira('<:', [1614, 1506], [[0, 2]]),
|
||||
fira(':>', [1614, 1056], [[0, 2]]),
|
||||
fira('|]', [1614, 1474], [[0, 2]]),
|
||||
fira('|}', [1614, 1473], [[0, 2]]),
|
||||
fira('.}', [977, 1002], [[0, 2]]),
|
||||
fira('<|||', [1614, 1614, 1614, 1504], [[0, 4]]),
|
||||
fira('<||', [1614, 1614, 1503], [[0, 3]]),
|
||||
fira('<|', [1614, 1502], [[0, 2]]),
|
||||
fira('<|>', [1614, 1614, 1505], [[0, 3]]),
|
||||
fira('|>', [1614, 1477], [[0, 2]]),
|
||||
fira('||>', [1614, 1614, 1472], [[0, 3]]),
|
||||
fira('|||>', [1614, 1614, 1614, 1470], [[0, 4]]),
|
||||
fira('<$', [1614, 1507], [[0, 2]]),
|
||||
fira('<$>', [1614, 1614, 1508], [[0, 3]]),
|
||||
fira('$>', [1614, 1479], [[0, 2]]),
|
||||
fira('<+', [1614, 1514], [[0, 2]]),
|
||||
fira('<+>', [1614, 1614, 1515], [[0, 3]]),
|
||||
fira('+>', [1614, 1482], [[0, 2]]),
|
||||
fira('<*', [1614, 1500], [[0, 2]]),
|
||||
fira('<*>', [1614, 1614, 1501], [[0, 3]]),
|
||||
fira('*>', [1614, 1047], [[0, 2]]),
|
||||
fira('/*', [1614, 1092], [[0, 2]]),
|
||||
fira('*/', [1614, 1048], [[0, 2]]),
|
||||
fira('///', [1614, 1614, 1097], [[0, 3]]),
|
||||
fira('//', [1614, 1096], [[0, 2]]),
|
||||
fira('</', [1614, 1528], [[0, 2]]),
|
||||
fira('<!--', [1614, 1614, 1614, 1509], [[0, 4]]),
|
||||
fira('</>', [1614, 1614, 1529], [[0, 3]]),
|
||||
fira('/>', [1614, 1095], [[0, 2]]),
|
||||
fira('0xff', [895, 270, 166, 166], [[0, 3]]),
|
||||
fira('10x10', [896, 895, 270, 896, 895], [[1, 4]]),
|
||||
fira('9:45', [904, 998, 899, 900], [[0, 2]]),
|
||||
fira('[:]', [1003, 998, 1004], [[0, 2]]),
|
||||
fira(';;', [1614, 1091], [[0, 2]]),
|
||||
fira('::', [1614, 1052], [[0, 2]]),
|
||||
fira(':::', [1614, 1614, 1053], [[0, 3]]),
|
||||
fira('..', [1614, 1082], [[0, 2]]),
|
||||
fira('...', [1614, 1614, 1085], [[0, 3]]),
|
||||
fira('..<', [1614, 1614, 1084], [[0, 3]]),
|
||||
fira('!!', [1614, 1057], [[0, 2]]),
|
||||
fira('??', [1614, 1090], [[0, 2]]),
|
||||
fira('%%', [1614, 1536], [[0, 2]]),
|
||||
fira('&&', [1614, 1468], [[0, 2]]),
|
||||
fira('||', [1614, 1469], [[0, 2]]),
|
||||
fira('?.', [1614, 1089], [[0, 2]]),
|
||||
fira('?:', [1614, 1087], [[0, 2]]),
|
||||
fira('++', [1614, 1480], [[0, 2]]),
|
||||
fira('+++', [1614, 1614, 1481], [[0, 3]]),
|
||||
fira('--', [1614, 1061], [[0, 2]]),
|
||||
fira('---', [1614, 1614, 1062], [[0, 3]]),
|
||||
fira('**', [1614, 1045], [[0, 2]]),
|
||||
fira('***', [1614, 1614, 1046], [[0, 3]]),
|
||||
fira('~=', [1614, 1532], [[0, 2]]),
|
||||
fira('~-', [1614, 1531], [[0, 2]]),
|
||||
fira('www', [1614, 1614, 271], [[0, 3]]),
|
||||
fira('-~', [1614, 1068], [[0, 2]]),
|
||||
fira('~@', [1614, 1530], [[0, 2]]),
|
||||
fira('^=', [1614, 1478], [[0, 2]]),
|
||||
fira('?=', [1614, 1088], [[0, 2]]),
|
||||
fira('/=', [1614, 1093], [[0, 2]]),
|
||||
fira('/==', [1614, 1614, 1094], [[0, 3]]),
|
||||
fira('-|', [1614, 1060], [[0, 2]]),
|
||||
fira('_|_', [1614, 1614, 1098], [[0, 3]]),
|
||||
fira('|-', [1614, 1475], [[0, 2]]),
|
||||
fira('|=', [1614, 1476], [[0, 2]]),
|
||||
fira('||=', [1614, 1614, 1471], [[0, 3]]),
|
||||
fira('#!', [1614, 1071], [[0, 2]]),
|
||||
fira('#=', [1614, 1075], [[0, 2]]),
|
||||
fira('##', [1614, 1072], [[0, 2]]),
|
||||
fira('###', [1614, 1614, 1073], [[0, 3]]),
|
||||
fira('####', [1614, 1614, 1614, 1074], [[0, 4]]),
|
||||
fira('#{', [1614, 1069], [[0, 2]]),
|
||||
fira('#[', [1614, 1070], [[0, 2]]),
|
||||
fira(']#', [1614, 1051], [[0, 2]]),
|
||||
fira('#(', [1614, 1076], [[0, 2]]),
|
||||
fira('#?', [1614, 1077], [[0, 2]]),
|
||||
fira('#_', [1614, 1078], [[0, 2]]),
|
||||
fira('#_(', [1614, 1614, 1079], [[0, 3]]),
|
||||
fira('::=', [1614, 1614, 1054], [[0, 3]]),
|
||||
fira('.?', [1614, 1086], [[0, 2]]),
|
||||
fira('===>', [1614, 1614, 1486, 1148], [[0, 4]])
|
||||
];
|
||||
|
||||
const iosevkaCases: ITestCase[] = [
|
||||
iosevka('<-', [31, 3127], [[0, 2]]),
|
||||
iosevka('<--', [31, 3129, 3139], [[0, 3]]),
|
||||
iosevka('<---', [31, 3129, 3150, 3139], [[0, 4]]),
|
||||
iosevka('<-----', [31, 3129, 3150, 3139, 3151, 3151], [[0, 6]]),
|
||||
iosevka('->', [3126, 33], [[0, 2]]),
|
||||
iosevka('-->', [3140, 3128, 33], [[0, 3]]),
|
||||
iosevka('--->', [3140, 3150, 3128, 33], [[0, 4]]),
|
||||
iosevka('----->', [3153, 3153, 3140, 3150, 3128, 33], [[0, 6]]),
|
||||
iosevka('<->', [31, 3149, 33], [[0, 3]]),
|
||||
iosevka('<-->', [31, 3129, 3128, 33], [[0, 4]]),
|
||||
iosevka('<--->', [31, 3129, 3150, 3128, 33], [[0, 5]]),
|
||||
iosevka('<----->', [31, 3129, 3150, 3150, 3150, 3128, 33], [[0, 7]]),
|
||||
iosevka('<=', [3094, 3095], [[0, 2]]),
|
||||
iosevka('<==', [31, 3158, 3168], [[0, 3]]),
|
||||
iosevka('<===', [31, 3158, 3179, 3168], [[0, 4]]),
|
||||
iosevka('<=====', [31, 3158, 3179, 3168, 3180, 3180], [[0, 6]]),
|
||||
iosevka('=>', [3155, 33], [[0, 2]]),
|
||||
iosevka('==>', [3169, 3157, 33], [[0, 3]]),
|
||||
iosevka('===>', [3169, 3179, 3157, 33], [[0, 4]]),
|
||||
iosevka('=====>', [3182, 3182, 3169, 3179, 3157, 33], [[0, 6]]),
|
||||
iosevka('<=>', [31, 3178, 33], [[0, 3]]),
|
||||
iosevka('<==>', [31, 3158, 3157, 33], [[0, 4]]),
|
||||
iosevka('<===>', [31, 3158, 3179, 3157, 33], [[0, 5]]),
|
||||
iosevka('<=====>', [31, 3158, 3179, 3179, 3179, 3157, 33], [[0, 7]]),
|
||||
iosevka('<!--', [31, 3184, 3130, 3139], [[0, 4]]),
|
||||
iosevka('<!---', [31, 3184, 3130, 3150, 3139], [[0, 5]]),
|
||||
iosevka('<!-----', [31, 3184, 3130, 3150, 3139, 3151, 3151], [[0, 7]]),
|
||||
iosevka('a:b', [68, 29, 69], []),
|
||||
iosevka('a::b', [68, 3012, 3013, 69], [[1, 3]]),
|
||||
iosevka('a:::b', [68, 3012, 3010, 3013, 69], [[1, 4]]),
|
||||
iosevka(':=', [3011, 32], [[0, 2]]),
|
||||
iosevka(':-', [3011, 16], [[0, 2]]),
|
||||
iosevka(':+', [3011, 14], [[0, 2]]),
|
||||
iosevka('=:', [32, 3011], [[0, 2]]),
|
||||
iosevka('-:', [16, 3011], [[0, 2]]),
|
||||
iosevka('+:', [14, 3011], [[0, 2]]),
|
||||
iosevka('<*', [31, 3023], [[0, 2]]),
|
||||
iosevka('*>', [3023, 33], [[0, 2]]),
|
||||
iosevka('<*>', [31, 3023, 33], [[1, 3]]),
|
||||
iosevka('<**>', [31, 3023, 3023, 33], [[1, 4]]),
|
||||
iosevka('<****>', [31, 3023, 3023, 3023, 3023, 33], [[0, 3], [3, 6]]),
|
||||
iosevka('==', [3083, 3084], [[0, 2]]),
|
||||
iosevka('!=', [3098, 3084], [[0, 2]]),
|
||||
iosevka('===', [3083, 3085, 3084], [[0, 3]]),
|
||||
iosevka('!==', [3099, 3085, 3084], [[0, 3]]),
|
||||
iosevka('====', [3083, 3085, 3085, 3084], [[0, 4]]),
|
||||
iosevka('!===', [3100, 3085, 3085, 3084], [[0, 4]])
|
||||
];
|
||||
|
||||
const monoidCases: ITestCase[] = [
|
||||
monoid('<!--', [775, 775, 775, 643], [[0, 4]]),
|
||||
monoid('-->', [779, 779, 628], [[0, 3]]),
|
||||
monoid('<--', [776, 776, 627], [[0, 3]]),
|
||||
monoid('->>', [780, 780, 626], [[0, 3]]),
|
||||
monoid('<<-', [777, 777, 625], [[0, 3]]),
|
||||
monoid('->', [781, 623], [[0, 2]]),
|
||||
monoid('<-', [778, 624], [[0, 2]]),
|
||||
monoid('=>', [793, 666], [[0, 2]]),
|
||||
monoid('<=>', [785, 785, 760], [[0, 3]]),
|
||||
monoid('<==>', [786, 786, 786, 771], [[0, 4]]),
|
||||
monoid('==>', [787, 787, 672], [[0, 3]]),
|
||||
monoid('<==', [788, 788, 671], [[0, 3]]),
|
||||
monoid('>>=', [791, 791, 758], [[0, 3]]),
|
||||
monoid('=<<', [792, 792, 759], [[0, 3]]),
|
||||
monoid('--', [667, 667], [[0, 2]]),
|
||||
monoid(':=', [29, 761], [[0, 2]]),
|
||||
monoid('=:=', [789, 789, 665], [[0, 3]]),
|
||||
monoid('==', [794, 641], [[0, 2]]),
|
||||
monoid('!==', [782, 782, 646], [[0, 3]]),
|
||||
monoid('!=', [783, 629], [[0, 2]]),
|
||||
monoid('<=', [790, 630], [[0, 2]]),
|
||||
monoid('>=', [792, 631], [[0, 2]]),
|
||||
monoid('//', [621, 664], [[0, 2]]),
|
||||
monoid('/**', [18, 753, 753], [[0, 3]]),
|
||||
monoid('/*', [18, 753], [[0, 2]]),
|
||||
monoid('*/', [754, 18], [[0, 2]]),
|
||||
monoid('&&', [633, 775], [[0, 2]]),
|
||||
monoid('.&', [17, 755], [[0, 2]]),
|
||||
monoid('||', [634, 635], [[0, 2]]),
|
||||
monoid('!!', [769, 770], [[0, 2]]),
|
||||
monoid('::', [772, 773], [[0, 2]]),
|
||||
monoid('>>', [637, 638], [[0, 2]]),
|
||||
monoid('<<', [639, 640], [[0, 2]]),
|
||||
monoid('¯\\_(ツ)_/¯', [113, 765, 66, 767, 613, 768, 66, 766, 113], [[0, 3], [3, 6], [6, 9]]),
|
||||
monoid('__', [763, 764], [[0, 2]])
|
||||
];
|
||||
|
||||
const ubuntuCases: ITestCase[] = [
|
||||
ubuntu('==>', [32, 32, 33], [])
|
||||
];
|
||||
|
||||
const fontPaths: Record<string, string> = {
|
||||
'Fira Code': path.join(__dirname, '../../fonts/FiraCode-Regular.otf'),
|
||||
'Iosevka': path.join(__dirname, '../../fonts/iosevka-regular.ttf'),
|
||||
'Monoid': path.join(__dirname, '../../fonts/Monoid-Regular.ttf'),
|
||||
'Ubuntu Mono': path.join(__dirname, '../../fonts/UbuntuMono-Regular.ttf')
|
||||
};
|
||||
|
||||
const fontCache: Map<string, IFont> = new Map();
|
||||
|
||||
function loadFont(fontName: string): IFont {
|
||||
let font = fontCache.get(fontName);
|
||||
if (!font) {
|
||||
const fontPath = fontPaths[fontName];
|
||||
const buffer = fs.readFileSync(fontPath);
|
||||
font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
||||
fontCache.set(fontName, font);
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
describe('addon-ligatures - index', () => {
|
||||
describe('findLigatures', () => {
|
||||
for (const { font: fontName, input, glyphs, ranges } of [...firaCases, ...iosevkaCases, ...monoidCases, ...ubuntuCases]) {
|
||||
it(`${fontName}: '${input}'`, () => {
|
||||
const font = loadFont(fontName);
|
||||
const result = font.findLigatures(input);
|
||||
assert.deepEqual(result.outputGlyphs, glyphs);
|
||||
assert.deepEqual(result.contextRanges, ranges);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('findLigatureRanges', () => {
|
||||
for (const { font: fontName, input, ranges } of [...firaCases, ...iosevkaCases, ...monoidCases, ...ubuntuCases]) {
|
||||
it(`${fontName}: '${input}'`, () => {
|
||||
const font = loadFont(fontName);
|
||||
const result = font.findLigatureRanges(input);
|
||||
assert.deepEqual(result, ranges);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('caching', () => {
|
||||
it('findLigatures caches successive calls correctly', () => {
|
||||
const fontPath = fontPaths['Fira Code'];
|
||||
const buffer = fs.readFileSync(fontPath);
|
||||
const font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), { cacheSize: 100 });
|
||||
const result1 = font.findLigatures('in --> out');
|
||||
const result2 = font.findLigatures('in --> out');
|
||||
assert.deepEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('findLigatureRanges caches successive calls correctly', () => {
|
||||
const fontPath = fontPaths['Fira Code'];
|
||||
const buffer = fs.readFileSync(fontPath);
|
||||
const font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), { cacheSize: 100 });
|
||||
const result1 = font.findLigatureRanges('in --> out');
|
||||
const result2 = font.findLigatureRanges('in --> out');
|
||||
assert.deepEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('caches calls to findLigatures after findLigatureRanges correctly', () => {
|
||||
const fontPath = fontPaths['Fira Code'];
|
||||
const buffer = fs.readFileSync(fontPath);
|
||||
|
||||
const uncached = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
||||
const uncachedResult1 = uncached.findLigatureRanges('in --> out');
|
||||
const uncachedResult2 = uncached.findLigatures('in --> out');
|
||||
|
||||
const font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), { cacheSize: 100 });
|
||||
const result1 = font.findLigatureRanges('in --> out');
|
||||
const result2 = font.findLigatures('in --> out');
|
||||
|
||||
assert.deepEqual(result1, uncachedResult1);
|
||||
assert.deepEqual(result2, uncachedResult2);
|
||||
assert.deepEqual(result1, result2.contextRanges);
|
||||
});
|
||||
|
||||
it('caches calls to findLigatureRanges after findLigatures correctly', () => {
|
||||
const fontPath = fontPaths['Fira Code'];
|
||||
const buffer = fs.readFileSync(fontPath);
|
||||
|
||||
const uncached = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
|
||||
const uncachedResult1 = uncached.findLigatures('in --> out');
|
||||
const uncachedResult2 = uncached.findLigatureRanges('in --> out');
|
||||
|
||||
const font = loadBuffer(buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), { cacheSize: 100 });
|
||||
const result1 = font.findLigatures('in --> out');
|
||||
const result2 = font.findLigatureRanges('in --> out');
|
||||
|
||||
assert.deepEqual(result1, uncachedResult1);
|
||||
assert.deepEqual(result2, uncachedResult2);
|
||||
assert.deepEqual(result1.contextRanges, result2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import * as opentype from 'opentype.js';
|
||||
import LRUCache = require('lru-cache');
|
||||
|
||||
import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types';
|
||||
import mergeTrees from './merge';
|
||||
import walkTree from './walk';
|
||||
import mergeRange from './mergeRange';
|
||||
|
||||
import buildTreeGsubType6Format1 from './processors/6-1';
|
||||
import buildTreeGsubType6Format2 from './processors/6-2';
|
||||
import buildTreeGsubType6Format3 from './processors/6-3';
|
||||
import buildTreeGsubType8Format1 from './processors/8-1';
|
||||
import flatten from './flatten';
|
||||
|
||||
class FontImpl implements IFont {
|
||||
private _font: opentype.Font;
|
||||
private _lookupTrees: { tree: IFlattenedLookupTree, processForward: boolean }[] = [];
|
||||
private _glyphLookups: { [glyphId: string]: number[] } = {};
|
||||
private _cache?: LRUCache<string, ILigatureData | [number, number][]>;
|
||||
|
||||
constructor(font: opentype.Font, options: Required<IOptions>) {
|
||||
this._font = font;
|
||||
|
||||
if (options.cacheSize > 0) {
|
||||
this._cache = new LRUCache({
|
||||
max: options.cacheSize,
|
||||
length: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
|
||||
});
|
||||
}
|
||||
|
||||
const caltFeatures = this._font.tables.gsub && this._font.tables.gsub.features.filter((f: { tag: string }) => f.tag === 'calt') || [];
|
||||
const lookupIndices: number[] = caltFeatures
|
||||
.reduce((acc: number[], val: { feature: { lookupListIndexes: number[] } }) => [...acc, ...val.feature.lookupListIndexes], []);
|
||||
|
||||
const allLookups = this._font.tables.gsub && this._font.tables.gsub.lookups || [];
|
||||
const lookupGroups = allLookups.filter((l: unknown, i: number) => lookupIndices.some(idx => idx === i));
|
||||
|
||||
for (const [index, lookup] of lookupGroups.entries()) {
|
||||
const trees: ILookupTree[] = [];
|
||||
switch (lookup.lookupType) {
|
||||
case 6:
|
||||
for (const [index, table] of lookup.subtables.entries()) {
|
||||
switch (table.substFormat) {
|
||||
case 1:
|
||||
trees.push(buildTreeGsubType6Format1(table, allLookups, index));
|
||||
break;
|
||||
case 2:
|
||||
trees.push(buildTreeGsubType6Format2(table, allLookups, index));
|
||||
break;
|
||||
case 3:
|
||||
trees.push(buildTreeGsubType6Format3(table, allLookups, index));
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 8:
|
||||
for (const [index, table] of lookup.subtables.entries()) {
|
||||
trees.push(buildTreeGsubType8Format1(table, index));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const tree = flatten(mergeTrees(trees));
|
||||
|
||||
this._lookupTrees.push({
|
||||
tree,
|
||||
processForward: lookup.lookupType !== 8
|
||||
});
|
||||
|
||||
for (const glyphId of Object.keys(tree)) {
|
||||
if (!this._glyphLookups[glyphId]) {
|
||||
this._glyphLookups[glyphId] = [];
|
||||
}
|
||||
|
||||
this._glyphLookups[glyphId].push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public findLigatures(text: string): ILigatureData {
|
||||
const cached = this._cache && this._cache.get(text);
|
||||
if (cached && !Array.isArray(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const glyphIds: number[] = [];
|
||||
for (const char of text) {
|
||||
glyphIds.push(this._font.charToGlyphIndex(char));
|
||||
}
|
||||
|
||||
// If there are no lookup groups, there's no point looking for
|
||||
// replacements. This gives us a minor performance boost for fonts with
|
||||
// no ligatures
|
||||
if (this._lookupTrees.length === 0) {
|
||||
return {
|
||||
inputGlyphs: glyphIds,
|
||||
outputGlyphs: glyphIds,
|
||||
contextRanges: []
|
||||
};
|
||||
}
|
||||
|
||||
const result = this._findInternal(glyphIds.slice());
|
||||
const finalResult: ILigatureData = {
|
||||
inputGlyphs: glyphIds,
|
||||
outputGlyphs: result.sequence,
|
||||
contextRanges: result.ranges
|
||||
};
|
||||
if (this._cache) {
|
||||
this._cache.set(text, finalResult);
|
||||
}
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
public findLigatureRanges(text: string): [number, number][] {
|
||||
// Short circuit the process if there are no possible ligatures in the
|
||||
// font
|
||||
if (this._lookupTrees.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cached = this._cache && this._cache.get(text);
|
||||
if (cached) {
|
||||
return Array.isArray(cached) ? cached : cached.contextRanges;
|
||||
}
|
||||
|
||||
const glyphIds: number[] = [];
|
||||
for (const char of text) {
|
||||
glyphIds.push(this._font.charToGlyphIndex(char));
|
||||
}
|
||||
|
||||
const result = this._findInternal(glyphIds);
|
||||
if (this._cache) {
|
||||
this._cache.set(text, result.ranges);
|
||||
}
|
||||
|
||||
return result.ranges;
|
||||
}
|
||||
|
||||
private _findInternal(sequence: number[]): { sequence: number[], ranges: [number, number][] } {
|
||||
const ranges: [number, number][] = [];
|
||||
|
||||
let nextLookup = this._getNextLookup(sequence, 0);
|
||||
while (nextLookup.index !== null) {
|
||||
const lookup = this._lookupTrees[nextLookup.index];
|
||||
if (lookup.processForward) {
|
||||
let lastGlyphIndex = nextLookup.last;
|
||||
for (let i = nextLookup.first; i < lastGlyphIndex; i++) {
|
||||
const result = walkTree(lookup.tree, sequence, i, i);
|
||||
if (result) {
|
||||
for (let j = 0; j < result.substitutions.length; j++) {
|
||||
const sub = result.substitutions[j];
|
||||
if (sub !== null) {
|
||||
sequence[i + j] = sub;
|
||||
}
|
||||
}
|
||||
|
||||
mergeRange(
|
||||
ranges,
|
||||
result.contextRange[0] + i,
|
||||
result.contextRange[1] + i
|
||||
);
|
||||
|
||||
// Substitutions can end up extending the search range
|
||||
if (i + result.length >= lastGlyphIndex) {
|
||||
lastGlyphIndex = i + result.length + 1;
|
||||
}
|
||||
|
||||
i += result.length - 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// We don't need to do the lastGlyphIndex tracking here because
|
||||
// reverse processing isn't allowed to replace more than one
|
||||
// character at a time.
|
||||
for (let i = nextLookup.last - 1; i >= nextLookup.first; i--) {
|
||||
const result = walkTree(lookup.tree, sequence, i, i);
|
||||
if (result) {
|
||||
for (let j = 0; j < result.substitutions.length; j++) {
|
||||
const sub = result.substitutions[j];
|
||||
if (sub !== null) {
|
||||
sequence[i + j] = sub;
|
||||
}
|
||||
}
|
||||
|
||||
mergeRange(
|
||||
ranges,
|
||||
result.contextRange[0] + i,
|
||||
result.contextRange[1] + i
|
||||
);
|
||||
|
||||
i -= result.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextLookup = this._getNextLookup(sequence, nextLookup.index + 1);
|
||||
}
|
||||
|
||||
return { sequence, ranges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the lookup and glyph range for the first lookup that might
|
||||
* contain a match.
|
||||
*
|
||||
* @param sequence Input glyph sequence
|
||||
* @param start The first input to try
|
||||
*/
|
||||
private _getNextLookup(sequence: number[], start: number): { index: number | null, first: number, last: number } {
|
||||
const result: { index: number | null, first: number, last: number } = {
|
||||
index: null,
|
||||
first: Infinity,
|
||||
last: -1
|
||||
};
|
||||
|
||||
// Loop through each glyph and find the first valid lookup for it
|
||||
for (let i = 0; i < sequence.length; i++) {
|
||||
const lookups = this._glyphLookups[sequence[i]];
|
||||
if (!lookups) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let j = 0; j < lookups.length; j++) {
|
||||
const lookupIndex = lookups[j];
|
||||
if (lookupIndex >= start) {
|
||||
// Update the lookup information if it's the one we're
|
||||
// storing or earlier than it.
|
||||
if (result.index === null || lookupIndex <= result.index) {
|
||||
result.index = lookupIndex;
|
||||
|
||||
if (result.first > i) {
|
||||
result.first = i;
|
||||
}
|
||||
|
||||
result.last = i + 1;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the font from it's binary data. The returned value can be used to find
|
||||
* ligatures for the font.
|
||||
*
|
||||
* @param buffer ArrayBuffer of the font to load
|
||||
*/
|
||||
export function loadBuffer(buffer: ArrayBuffer, options?: IOptions): IFont {
|
||||
const font = opentype.parse(buffer);
|
||||
return new FontImpl(font, {
|
||||
cacheSize: 0,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
export { IFont as Font, ILigatureData as LigatureData, IOptions as Options };
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import mergeTrees from './merge';
|
||||
|
||||
interface ILookupResult {
|
||||
contextRange: [number, number];
|
||||
index: number;
|
||||
subIndex: number;
|
||||
length: number;
|
||||
substitutions: number[];
|
||||
}
|
||||
|
||||
function lookup(substitutionGlyph: number, index?: number, subIndex?: number): ILookupResult {
|
||||
return {
|
||||
contextRange: [0, 1],
|
||||
index: index || 0,
|
||||
subIndex: subIndex || 0,
|
||||
length: 1,
|
||||
substitutions: [substitutionGlyph]
|
||||
};
|
||||
}
|
||||
|
||||
describe('addon-ligatures - merge', () => {
|
||||
describe('mergeTrees', () => {
|
||||
it('combines disjoint trees', () => {
|
||||
const result = mergeTrees([
|
||||
{
|
||||
individual: {
|
||||
'1': { lookup: lookup(1) }
|
||||
},
|
||||
range: []
|
||||
},
|
||||
{
|
||||
individual: {},
|
||||
range: [{
|
||||
entry: { lookup: lookup(2) },
|
||||
range: [2, 4]
|
||||
}]
|
||||
},
|
||||
{
|
||||
individual: {
|
||||
'5': { lookup: lookup(3) }
|
||||
},
|
||||
range: []
|
||||
},
|
||||
{
|
||||
individual: {},
|
||||
range: [{
|
||||
entry: { lookup: lookup(4) },
|
||||
range: [8, 10]
|
||||
}]
|
||||
}
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
individual: {
|
||||
'1': { lookup: lookup(1) },
|
||||
'5': { lookup: lookup(3) }
|
||||
},
|
||||
range: [{
|
||||
entry: { lookup: lookup(2) },
|
||||
range: [2, 4]
|
||||
}, {
|
||||
entry: { lookup: lookup(4) },
|
||||
range: [8, 10]
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it('merges matching individual glyphs', () => {
|
||||
const result = mergeTrees([
|
||||
{
|
||||
individual: {
|
||||
'1': { lookup: lookup(1, 1) }
|
||||
},
|
||||
range: []
|
||||
},
|
||||
{
|
||||
individual: {
|
||||
'1': { lookup: lookup(2, 0) }
|
||||
},
|
||||
range: []
|
||||
},
|
||||
{
|
||||
individual: {
|
||||
'1': { lookup: lookup(3, 2) }
|
||||
},
|
||||
range: []
|
||||
}
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
individual: {
|
||||
'1': { lookup: lookup(2, 0) }
|
||||
},
|
||||
range: []
|
||||
});
|
||||
});
|
||||
|
||||
it('merges range glyphs overlapping individual glyphs', () => {
|
||||
const result = mergeTrees([
|
||||
{
|
||||
individual: {
|
||||
'1': { lookup: lookup(1, 0) }
|
||||
},
|
||||
range: []
|
||||
},
|
||||
{
|
||||
individual: {},
|
||||
range: [{
|
||||
entry: { lookup: lookup(2, 1) },
|
||||
range: [0, 4]
|
||||
}]
|
||||
}
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
individual: {
|
||||
'0': { lookup: lookup(2, 1) },
|
||||
'1': { lookup: lookup(1, 0) }
|
||||
},
|
||||
range: [{
|
||||
entry: { lookup: lookup(2, 1) },
|
||||
range: [2, 4]
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it('merges individual glyphs overlapping range glyphs', () => {
|
||||
const result = mergeTrees([
|
||||
{
|
||||
individual: {},
|
||||
range: [{
|
||||
entry: { lookup: lookup(2, 1) },
|
||||
range: [0, 4]
|
||||
}]
|
||||
},
|
||||
{
|
||||
individual: {
|
||||
'1': { lookup: lookup(1, 0) }
|
||||
},
|
||||
range: []
|
||||
}
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
individual: {
|
||||
'0': { lookup: lookup(2, 1) },
|
||||
'1': { lookup: lookup(1, 0) }
|
||||
},
|
||||
range: [{
|
||||
entry: { lookup: lookup(2, 1) },
|
||||
range: [2, 4]
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it('merges multiple overlapping ranges', () => {
|
||||
const result = mergeTrees([
|
||||
{
|
||||
individual: {},
|
||||
range: [{
|
||||
entry: { lookup: lookup(1, 2) },
|
||||
range: [0, 3]
|
||||
}, {
|
||||
entry: { lookup: lookup(2, 1) },
|
||||
range: [6, 12]
|
||||
}, {
|
||||
entry: { lookup: lookup(5, 3) },
|
||||
range: [15, 20]
|
||||
}, {
|
||||
entry: { lookup: lookup(7, 4) },
|
||||
range: [20, 22]
|
||||
}]
|
||||
},
|
||||
{
|
||||
individual: {},
|
||||
range: [{
|
||||
entry: { lookup: lookup(3, 0) },
|
||||
range: [2, 8]
|
||||
}, {
|
||||
entry: { lookup: lookup(4, 0) },
|
||||
range: [10, 13]
|
||||
}, {
|
||||
entry: { lookup: lookup(6, 0) },
|
||||
range: [16, 21]
|
||||
}]
|
||||
}
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
individual: {
|
||||
'2': { lookup: lookup(3, 0) },
|
||||
'12': { lookup: lookup(4, 0) },
|
||||
'15': { lookup: lookup(5, 3) },
|
||||
'20': { lookup: lookup(6, 0) },
|
||||
'21': { lookup: lookup(7, 4) }
|
||||
},
|
||||
range: [{
|
||||
entry: { lookup: lookup(1, 2) },
|
||||
range: [0, 2]
|
||||
}, {
|
||||
entry: { lookup: lookup(3, 0) },
|
||||
range: [6, 8]
|
||||
}, {
|
||||
entry: { lookup: lookup(3, 0) },
|
||||
range: [3, 6]
|
||||
}, {
|
||||
entry: { lookup: lookup(2, 1) },
|
||||
range: [8, 10]
|
||||
}, {
|
||||
entry: { lookup: lookup(4, 0) },
|
||||
range: [10, 12]
|
||||
}, {
|
||||
entry: { lookup: lookup(6, 0) },
|
||||
range: [16, 20]
|
||||
}]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { ILookupTree, ILookupTreeEntry } from './types';
|
||||
|
||||
/**
|
||||
* Merges the provided trees into a single lookup tree. When conflicting lookups
|
||||
* are encountered between two trees, the one with the lower index, then the
|
||||
* lower subindex is chosen.
|
||||
*
|
||||
* @param trees Array of trees to merge. Entries in earlier trees are favored
|
||||
* over those in later trees when there is a choice.
|
||||
*/
|
||||
export default function mergeTrees(trees: ILookupTree[]): ILookupTree {
|
||||
const result: ILookupTree = {
|
||||
individual: {},
|
||||
range: []
|
||||
};
|
||||
|
||||
const mergedEntries = new WeakMap<ILookupTreeEntry, Set<ILookupTreeEntry>>();
|
||||
for (const tree of trees) {
|
||||
mergeSubtree(result, tree, mergedEntries);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively merges the data for the mergeTree into the mainTree.
|
||||
*
|
||||
* @param mainTree The tree where the values should be merged
|
||||
* @param mergeTree The tree to be merged into the mainTree
|
||||
* @param mergedEntries WeakMap to track already merged entry pairs
|
||||
*/
|
||||
function mergeSubtree(mainTree: ILookupTree, mergeTree: ILookupTree, mergedEntries: WeakMap<ILookupTreeEntry, Set<ILookupTreeEntry>>): void {
|
||||
// Need to fix this recursively (and handle lookups)
|
||||
for (const [glyphId, value] of Object.entries(mergeTree.individual)) {
|
||||
// The main tree is guaranteed to have no overlaps between the
|
||||
// individual and range values, so if we match an invididual, there
|
||||
// must not be a range
|
||||
if (mainTree.individual[glyphId]) {
|
||||
mergeTreeEntry(mainTree.individual[glyphId], value, mergedEntries);
|
||||
} else {
|
||||
let matched = false;
|
||||
for (const [index, { range, entry }] of mainTree.range.entries()) {
|
||||
const overlap = getIndividualOverlap(Number(glyphId), range);
|
||||
|
||||
// Don't overlap
|
||||
if (overlap.both === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matched = true;
|
||||
|
||||
// If they overlap, we have to split the range and then
|
||||
// merge the overlap
|
||||
mainTree.individual[glyphId] = value;
|
||||
mergeTreeEntry(mainTree.individual[glyphId], cloneEntry(entry), mergedEntries);
|
||||
|
||||
// When there's an overlap, we also have to fix up the range
|
||||
// that we had already processed
|
||||
mainTree.range.splice(index, 1);
|
||||
for (const glyph of overlap.second) {
|
||||
if (Array.isArray(glyph)) {
|
||||
mainTree.range.push({
|
||||
range: glyph,
|
||||
entry: cloneEntry(entry)
|
||||
});
|
||||
} else {
|
||||
mainTree.individual[glyph] = cloneEntry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
mainTree.individual[glyphId] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { range, entry } of mergeTree.range) {
|
||||
// Ranges are more complicated, because they can overlap with
|
||||
// multiple things, individual and range alike. We start by
|
||||
// eliminating ranges that are already present in another range
|
||||
let remainingRanges: (number | [number, number])[] = [range];
|
||||
|
||||
for (let index = 0; index < mainTree.range.length; index++) {
|
||||
const { range, entry: resultEntry } = mainTree.range[index];
|
||||
for (const [remainingIndex, remainingRange] of remainingRanges.entries()) {
|
||||
if (Array.isArray(remainingRange)) {
|
||||
const overlap = getRangeOverlap(remainingRange, range);
|
||||
if (overlap.both === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mainTree.range.splice(index, 1);
|
||||
index--;
|
||||
|
||||
const entryToMerge: ILookupTreeEntry = cloneEntry(resultEntry);
|
||||
if (Array.isArray(overlap.both)) {
|
||||
mainTree.range.push({
|
||||
range: overlap.both,
|
||||
entry: entryToMerge
|
||||
});
|
||||
} else {
|
||||
mainTree.individual[overlap.both] = entryToMerge;
|
||||
}
|
||||
|
||||
mergeTreeEntry(entryToMerge, cloneEntry(entry), mergedEntries);
|
||||
|
||||
for (const second of overlap.second) {
|
||||
if (Array.isArray(second)) {
|
||||
mainTree.range.push({
|
||||
range: second,
|
||||
entry: cloneEntry(resultEntry)
|
||||
});
|
||||
} else {
|
||||
mainTree.individual[second] = cloneEntry(resultEntry);
|
||||
}
|
||||
}
|
||||
|
||||
remainingRanges = overlap.first;
|
||||
} else {
|
||||
const overlap = getIndividualOverlap(remainingRange, range);
|
||||
if (overlap.both === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If they overlap, we have to split the range and then
|
||||
// merge the overlap
|
||||
mainTree.individual[remainingRange] = cloneEntry(entry);
|
||||
mergeTreeEntry(mainTree.individual[remainingRange], cloneEntry(resultEntry), mergedEntries);
|
||||
|
||||
// When there's an overlap, we also have to fix up the range
|
||||
// that we had already processed
|
||||
mainTree.range.splice(index, 1);
|
||||
index--;
|
||||
|
||||
for (const glyph of overlap.second) {
|
||||
if (Array.isArray(glyph)) {
|
||||
mainTree.range.push({
|
||||
range: glyph,
|
||||
entry: cloneEntry(resultEntry)
|
||||
});
|
||||
} else {
|
||||
mainTree.individual[glyph] = cloneEntry(resultEntry);
|
||||
}
|
||||
}
|
||||
|
||||
remainingRanges.splice(remainingIndex, 1, ...overlap.first);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Next, we run the same against any individual glyphs
|
||||
for (const glyphId of Object.keys(mainTree.individual)) {
|
||||
for (const [remainingIndex, remainingRange] of remainingRanges.entries()) {
|
||||
if (Array.isArray(remainingRange)) {
|
||||
const overlap = getIndividualOverlap(Number(glyphId), remainingRange);
|
||||
if (overlap.both === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If they overlap, we have to merge the overlap
|
||||
mergeTreeEntry(mainTree.individual[glyphId], cloneEntry(entry), mergedEntries);
|
||||
|
||||
// Update the remaining ranges
|
||||
remainingRanges.splice(remainingIndex, 1, ...overlap.second);
|
||||
break;
|
||||
} else {
|
||||
if (Number(glyphId) === remainingRange) {
|
||||
mergeTreeEntry(mainTree.individual[glyphId], cloneEntry(entry), mergedEntries);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any remaining ranges should just be added directly
|
||||
for (const remainingRange of remainingRanges) {
|
||||
if (Array.isArray(remainingRange)) {
|
||||
mainTree.range.push({
|
||||
range: remainingRange,
|
||||
entry: cloneEntry(entry)
|
||||
});
|
||||
} else {
|
||||
mainTree.individual[remainingRange] = cloneEntry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively merges the entry forr the mergeTree into the mainTree
|
||||
*
|
||||
* @param mainTree The entry where the values should be merged
|
||||
* @param mergeTree The entry to merge into the mainTree
|
||||
* @param mergedEntries WeakMap to track already merged entry pairs
|
||||
*/
|
||||
function mergeTreeEntry(mainTree: ILookupTreeEntry, mergeTree: ILookupTreeEntry, mergedEntries: WeakMap<ILookupTreeEntry, Set<ILookupTreeEntry>>): void {
|
||||
// Check if we've already merged this pair
|
||||
let mergedSet = mergedEntries.get(mainTree);
|
||||
if (mergedSet?.has(mergeTree)) {
|
||||
return;
|
||||
}
|
||||
if (!mergedSet) {
|
||||
mergedSet = new Set();
|
||||
mergedEntries.set(mainTree, mergedSet);
|
||||
}
|
||||
mergedSet.add(mergeTree);
|
||||
|
||||
if (
|
||||
mergeTree.lookup && (
|
||||
!mainTree.lookup ||
|
||||
mainTree.lookup.index > mergeTree.lookup.index ||
|
||||
(mainTree.lookup.index === mergeTree.lookup.index && mainTree.lookup.subIndex > mergeTree.lookup.subIndex)
|
||||
)
|
||||
) {
|
||||
mainTree.lookup = mergeTree.lookup;
|
||||
}
|
||||
|
||||
if (mergeTree.forward) {
|
||||
if (!mainTree.forward) {
|
||||
mainTree.forward = mergeTree.forward;
|
||||
} else {
|
||||
mergeSubtree(mainTree.forward, mergeTree.forward, mergedEntries);
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeTree.reverse) {
|
||||
if (!mainTree.reverse) {
|
||||
mainTree.reverse = mergeTree.reverse;
|
||||
} else {
|
||||
mergeSubtree(mainTree.reverse, mergeTree.reverse, mergedEntries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IOverlap {
|
||||
first: (number | [number, number])[];
|
||||
second: (number | [number, number])[];
|
||||
both: number | [number, number] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the overlap (if any) between two ranges. Returns the distinct
|
||||
* ranges for each range and the overlap (if any).
|
||||
*
|
||||
* @param first First range
|
||||
* @param second Second range
|
||||
*/
|
||||
function getRangeOverlap(first: [number, number], second: [number, number]): IOverlap {
|
||||
const result: IOverlap = {
|
||||
first: [],
|
||||
second: [],
|
||||
both: null
|
||||
};
|
||||
|
||||
// Both
|
||||
if (first[0] < second[1] && second[0] < first[1]) {
|
||||
const start = Math.max(first[0], second[0]);
|
||||
const end = Math.min(first[1], second[1]);
|
||||
result.both = rangeOrIndividual(start, end);
|
||||
}
|
||||
|
||||
// Before
|
||||
if (first[0] < second[0]) {
|
||||
const start = first[0];
|
||||
const end = Math.min(second[0], first[1]);
|
||||
result.first.push(rangeOrIndividual(start, end));
|
||||
} else if (second[0] < first[0]) {
|
||||
const start = second[0];
|
||||
const end = Math.min(second[1], first[0]);
|
||||
result.second.push(rangeOrIndividual(start, end));
|
||||
}
|
||||
|
||||
// After
|
||||
if (first[1] > second[1]) {
|
||||
const start = Math.max(first[0], second[1]);
|
||||
const end = first[1];
|
||||
result.first.push(rangeOrIndividual(start, end));
|
||||
} else if (second[1] > first[1]) {
|
||||
const start = Math.max(first[1], second[0]);
|
||||
const end = second[1];
|
||||
result.second.push(rangeOrIndividual(start, end));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the overlap (if any) between the individual glyph and the range
|
||||
* provided. Returns the glyphs and/or ranges that are unique to each provided
|
||||
* and the overlap (if any).
|
||||
*
|
||||
* @param first Individual glyph
|
||||
* @param second Range
|
||||
*/
|
||||
function getIndividualOverlap(first: number, second: [number, number]): IOverlap {
|
||||
// Disjoint
|
||||
if (first < second[0] || first > second[1]) {
|
||||
return {
|
||||
first: [first],
|
||||
second: [second],
|
||||
both: null
|
||||
};
|
||||
}
|
||||
|
||||
const result: IOverlap = {
|
||||
first: [],
|
||||
second: [],
|
||||
both: first
|
||||
};
|
||||
|
||||
if (second[0] < first) {
|
||||
result.second.push(rangeOrIndividual(second[0], first));
|
||||
}
|
||||
|
||||
if (second[1] > first) {
|
||||
result.second.push(rangeOrIndividual(first + 1, second[1]));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an individual glyph if the range is of size one or a range if it is
|
||||
* larger.
|
||||
*
|
||||
* @param start Beginning of the range (inclusive)
|
||||
* @param end End of the range (exclusive)
|
||||
*/
|
||||
function rangeOrIndividual(start: number, end: number): number | [number, number] {
|
||||
if (end - start === 1) {
|
||||
return start;
|
||||
}
|
||||
return [start, end];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones an individual lookup tree entry.
|
||||
*
|
||||
* @param entry Lookup tree entry to clone
|
||||
* @param visited Map to track already cloned entries (prevents infinite loops)
|
||||
*/
|
||||
function cloneEntry(entry: ILookupTreeEntry, visited: Map<ILookupTreeEntry, ILookupTreeEntry> = new Map()): ILookupTreeEntry {
|
||||
if (visited.has(entry)) {
|
||||
return visited.get(entry)!;
|
||||
}
|
||||
|
||||
const result: ILookupTreeEntry = {};
|
||||
visited.set(entry, result);
|
||||
|
||||
if (entry.forward) {
|
||||
result.forward = cloneTree(entry.forward, visited);
|
||||
}
|
||||
|
||||
if (entry.reverse) {
|
||||
result.reverse = cloneTree(entry.reverse, visited);
|
||||
}
|
||||
|
||||
if (entry.lookup) {
|
||||
result.lookup = {
|
||||
contextRange: entry.lookup.contextRange.slice() as [number, number],
|
||||
index: entry.lookup.index,
|
||||
length: entry.lookup.length,
|
||||
subIndex: entry.lookup.subIndex,
|
||||
substitutions: entry.lookup.substitutions.slice()
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a lookup tree.
|
||||
*
|
||||
* @param tree Lookup tree to clone
|
||||
* @param visited Map to track already cloned entries (prevents infinite loops)
|
||||
*/
|
||||
function cloneTree(tree: ILookupTree, visited: Map<ILookupTreeEntry, ILookupTreeEntry> = new Map()): ILookupTree {
|
||||
const individual: { [glyphId: string]: ILookupTreeEntry } = {};
|
||||
for (const [glyphId, entry] of Object.entries(tree.individual)) {
|
||||
individual[glyphId] = cloneEntry(entry, visited);
|
||||
}
|
||||
|
||||
return {
|
||||
individual,
|
||||
range: tree.range.map(({ range, entry }) => ({
|
||||
range: range.slice() as [number, number],
|
||||
entry: cloneEntry(entry, visited)
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import mergeRange from './mergeRange';
|
||||
|
||||
describe('addon-ligatures - mergeRange', () => {
|
||||
it('inserts a new range before the existing ones', () => {
|
||||
const result = mergeRange([[1, 2], [2, 3]], 0, 1);
|
||||
assert.deepEqual(result, [[0, 1], [1, 2], [2, 3]]);
|
||||
});
|
||||
|
||||
it('inserts in between two ranges', () => {
|
||||
const result = mergeRange([[0, 2], [4, 6]], 2, 4);
|
||||
assert.deepEqual(result, [[0, 2], [2, 4], [4, 6]]);
|
||||
});
|
||||
|
||||
it('inserts after the last range', () => {
|
||||
const result = mergeRange([[0, 2], [4, 6]], 6, 8);
|
||||
assert.deepEqual(result, [[0, 2], [4, 6], [6, 8]]);
|
||||
});
|
||||
|
||||
it('extends the beginning of a range', () => {
|
||||
const result = mergeRange([[0, 2], [4, 6]], 3, 5);
|
||||
assert.deepEqual(result, [[0, 2], [3, 6]]);
|
||||
});
|
||||
|
||||
it('extends the end of a range', () => {
|
||||
const result = mergeRange([[0, 2], [4, 6]], 1, 4);
|
||||
assert.deepEqual(result, [[0, 4], [4, 6]]);
|
||||
});
|
||||
|
||||
it('extends the last range', () => {
|
||||
const result = mergeRange([[0, 2], [4, 6]], 5, 7);
|
||||
assert.deepEqual(result, [[0, 2], [4, 7]]);
|
||||
});
|
||||
|
||||
it('connects two ranges', () => {
|
||||
const result = mergeRange([[0, 2], [4, 6]], 1, 5);
|
||||
assert.deepEqual(result, [[0, 6]]);
|
||||
});
|
||||
|
||||
it('connects more than two ranges', () => {
|
||||
const result = mergeRange([[0, 2], [4, 6], [8, 10], [12, 14]], 1, 10);
|
||||
assert.deepEqual(result, [[0, 10], [12, 14]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Merges the range defined by the provided start and end into the list of
|
||||
* existing ranges. The merge is done in place on the existing range for
|
||||
* performance and is also returned.
|
||||
*
|
||||
* @param ranges Existing range list
|
||||
* @param newRangeStart Start position of the range to merge, inclusive
|
||||
* @param newRangeEnd End position of range to merge, exclusive
|
||||
*/
|
||||
export default function mergeRange(ranges: [number, number][], newRangeStart: number, newRangeEnd: number): [number, number][] {
|
||||
let inRange = false;
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const range = ranges[i];
|
||||
if (!inRange) {
|
||||
if (newRangeEnd <= range[0]) {
|
||||
// Case 1: New range is before the search range
|
||||
ranges.splice(i, 0, [newRangeStart, newRangeEnd]);
|
||||
return ranges;
|
||||
}
|
||||
if (newRangeEnd <= range[1]) {
|
||||
// Case 2: New range is either wholly contained within the
|
||||
// search range or overlaps with the front of it
|
||||
range[0] = Math.min(newRangeStart, range[0]);
|
||||
return ranges;
|
||||
}
|
||||
if (newRangeStart < range[1]) {
|
||||
// Case 3: New range either wholly contains the search range
|
||||
// or overlaps with the end of it
|
||||
range[0] = Math.min(newRangeStart, range[0]);
|
||||
inRange = true;
|
||||
} else {
|
||||
// Case 4: New range starts after the search range
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (newRangeEnd <= range[0]) {
|
||||
// Case 5: New range extends from previous range but doesn't
|
||||
// reach the current one
|
||||
ranges[i - 1][1] = newRangeEnd;
|
||||
return ranges;
|
||||
}
|
||||
if (newRangeEnd <= range[1]) {
|
||||
// Case 6: New range extends from prvious range into the
|
||||
// current range
|
||||
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
|
||||
ranges.splice(i, 1);
|
||||
inRange = false;
|
||||
return ranges;
|
||||
}
|
||||
// Case 7: New range extends from previous range past the
|
||||
// end of the current range
|
||||
ranges.splice(i, 1);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
if (inRange) {
|
||||
// Case 8: New range extends past the last existing range
|
||||
ranges[ranges.length - 1][1] = newRangeEnd;
|
||||
} else {
|
||||
// Case 9: New range starts after the last existing range
|
||||
ranges.push([newRangeStart, newRangeEnd]);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ChainingContextualSubstitutionTable, Lookup } from '../tables';
|
||||
import { ILookupTree } from '../types';
|
||||
|
||||
import { listGlyphsByIndex } from './coverage';
|
||||
import { processInputPosition, processLookaheadPosition, processBacktrackPosition, getInputTree, IEntryMeta } from './helper';
|
||||
|
||||
/**
|
||||
* Build lookup tree for GSUB lookup table 6, format 1.
|
||||
* https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#61-chaining-context-substitution-format-1-simple-glyph-contexts
|
||||
*
|
||||
* @param table JSON representation of the table
|
||||
* @param lookups List of lookup tables
|
||||
* @param tableIndex Index of this table in the overall lookup
|
||||
*/
|
||||
export default function buildTree(table: ChainingContextualSubstitutionTable.IFormat1, lookups: Lookup[], tableIndex: number): ILookupTree {
|
||||
const result: ILookupTree = {
|
||||
individual: {},
|
||||
range: []
|
||||
};
|
||||
|
||||
const firstGlyphs = listGlyphsByIndex(table.coverage);
|
||||
|
||||
for (const { glyphId, index } of firstGlyphs) {
|
||||
const chainRuleSet = table.chainRuleSets[index];
|
||||
|
||||
// If the chain rule set is null there's nothing to do with this table.
|
||||
if (!chainRuleSet) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [subIndex, subTable] of chainRuleSet.entries()) {
|
||||
let currentEntries: IEntryMeta[] = getInputTree(
|
||||
result,
|
||||
subTable.lookupRecords,
|
||||
lookups,
|
||||
0,
|
||||
glyphId
|
||||
).map(({ entry, substitution }) => ({ entry, substitutions: [substitution] }));
|
||||
|
||||
// We walk forward, then backward
|
||||
for (const [index, glyph] of subTable.input.entries()) {
|
||||
currentEntries = processInputPosition(
|
||||
[glyph],
|
||||
index + 1,
|
||||
currentEntries,
|
||||
subTable.lookupRecords,
|
||||
lookups
|
||||
);
|
||||
}
|
||||
|
||||
for (const glyph of subTable.lookahead) {
|
||||
currentEntries = processLookaheadPosition(
|
||||
[glyph],
|
||||
currentEntries
|
||||
);
|
||||
}
|
||||
|
||||
for (const glyph of subTable.backtrack) {
|
||||
currentEntries = processBacktrackPosition(
|
||||
[glyph],
|
||||
currentEntries
|
||||
);
|
||||
}
|
||||
|
||||
// When we get to the end, insert the lookup information
|
||||
for (const { entry, substitutions } of currentEntries) {
|
||||
entry.lookup = {
|
||||
substitutions,
|
||||
length: subTable.input.length + 1,
|
||||
index: tableIndex,
|
||||
subIndex,
|
||||
contextRange: [
|
||||
-1 * subTable.backtrack.length,
|
||||
1 + subTable.input.length + subTable.lookahead.length
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ChainingContextualSubstitutionTable, Lookup } from '../tables';
|
||||
import { ILookupTree } from '../types';
|
||||
import mergeTrees from '../merge';
|
||||
|
||||
import { listGlyphsByIndex } from './coverage';
|
||||
import getGlyphClass, { listClassGlyphs } from './classDef';
|
||||
import { processInputPosition, processLookaheadPosition, processBacktrackPosition, getInputTree, IEntryMeta } from './helper';
|
||||
|
||||
/**
|
||||
* Build lookup tree for GSUB lookup table 6, format 2.
|
||||
* https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#62-chaining-context-substitution-format-2-class-based-glyph-contexts
|
||||
*
|
||||
* @param table JSON representation of the table
|
||||
* @param lookups List of lookup tables
|
||||
* @param tableIndex Index of this table in the overall lookup
|
||||
*/
|
||||
export default function buildTree(table: ChainingContextualSubstitutionTable.IFormat2, lookups: Lookup[], tableIndex: number): ILookupTree {
|
||||
const results: ILookupTree[] = [];
|
||||
|
||||
const firstGlyphs = listGlyphsByIndex(table.coverage);
|
||||
|
||||
for (const { glyphId } of firstGlyphs) {
|
||||
const firstInputClass = getGlyphClass(table.inputClassDef, glyphId);
|
||||
for (const [glyphId, inputClass] of firstInputClass.entries()) {
|
||||
// istanbul ignore next - invalid font
|
||||
if (inputClass === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const classSet = table.chainClassSet[inputClass];
|
||||
|
||||
// If the class set is null there's nothing to do with this table.
|
||||
if (!classSet) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [subIndex, subTable] of classSet.entries()) {
|
||||
const result: ILookupTree = {
|
||||
individual: {},
|
||||
range: []
|
||||
};
|
||||
|
||||
let currentEntries: IEntryMeta[] = getInputTree(
|
||||
result,
|
||||
subTable.lookupRecords,
|
||||
lookups,
|
||||
0,
|
||||
glyphId
|
||||
).map(({ entry, substitution }) => ({ entry, substitutions: [substitution] }));
|
||||
|
||||
for (const [index, classNum] of subTable.input.entries()) {
|
||||
currentEntries = processInputPosition(
|
||||
listClassGlyphs(table.inputClassDef, classNum),
|
||||
index + 1,
|
||||
currentEntries,
|
||||
subTable.lookupRecords,
|
||||
lookups
|
||||
);
|
||||
}
|
||||
|
||||
for (const classNum of subTable.lookahead) {
|
||||
currentEntries = processLookaheadPosition(
|
||||
listClassGlyphs(table.lookaheadClassDef, classNum),
|
||||
currentEntries
|
||||
);
|
||||
}
|
||||
|
||||
for (const classNum of subTable.backtrack) {
|
||||
currentEntries = processBacktrackPosition(
|
||||
listClassGlyphs(table.backtrackClassDef, classNum),
|
||||
currentEntries
|
||||
);
|
||||
}
|
||||
|
||||
// When we get to the end, all of the entries we've accumulated
|
||||
// should have a lookup defined
|
||||
for (const { entry, substitutions } of currentEntries) {
|
||||
entry.lookup = {
|
||||
substitutions,
|
||||
index: tableIndex,
|
||||
subIndex,
|
||||
length: subTable.input.length + 1,
|
||||
contextRange: [
|
||||
-1 * subTable.backtrack.length,
|
||||
1 + subTable.input.length + subTable.lookahead.length
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeTrees(results);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ChainingContextualSubstitutionTable, Lookup } from '../tables';
|
||||
import { ILookupTree } from '../types';
|
||||
|
||||
import { listGlyphsByIndex } from './coverage';
|
||||
import { processInputPosition, processLookaheadPosition, processBacktrackPosition, getInputTree, IEntryMeta } from './helper';
|
||||
|
||||
/**
|
||||
* Build lookup tree for GSUB lookup table 6, format 3.
|
||||
* https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#63-chaining-context-substitution-format-3-coverage-based-glyph-contexts
|
||||
*
|
||||
* @param table JSON representation of the table
|
||||
* @param lookups List of lookup tables
|
||||
* @param tableIndex Index of this table in the overall lookup
|
||||
*/
|
||||
export default function buildTree(table: ChainingContextualSubstitutionTable.IFormat3, lookups: Lookup[], tableIndex: number): ILookupTree {
|
||||
const result: ILookupTree = {
|
||||
individual: {},
|
||||
range: []
|
||||
};
|
||||
|
||||
const firstGlyphs = listGlyphsByIndex(table.inputCoverage[0]);
|
||||
|
||||
for (const { glyphId } of firstGlyphs) {
|
||||
let currentEntries: IEntryMeta[] = getInputTree(
|
||||
result,
|
||||
table.lookupRecords,
|
||||
lookups,
|
||||
0,
|
||||
glyphId
|
||||
).map(({ entry, substitution }) => ({ entry, substitutions: [substitution] }));
|
||||
|
||||
for (const [index, coverage] of table.inputCoverage.slice(1).entries()) {
|
||||
currentEntries = processInputPosition(
|
||||
listGlyphsByIndex(coverage).map(glyph => glyph.glyphId),
|
||||
index + 1,
|
||||
currentEntries,
|
||||
table.lookupRecords,
|
||||
lookups
|
||||
);
|
||||
}
|
||||
|
||||
for (const coverage of table.lookaheadCoverage) {
|
||||
currentEntries = processLookaheadPosition(
|
||||
listGlyphsByIndex(coverage).map(glyph => glyph.glyphId),
|
||||
currentEntries
|
||||
);
|
||||
}
|
||||
|
||||
for (const coverage of table.backtrackCoverage) {
|
||||
currentEntries = processBacktrackPosition(
|
||||
listGlyphsByIndex(coverage).map(glyph => glyph.glyphId),
|
||||
currentEntries
|
||||
);
|
||||
}
|
||||
|
||||
// When we get to the end, all of the entries we've accumulated
|
||||
// should have a lookup defined
|
||||
for (const { entry, substitutions } of currentEntries) {
|
||||
entry.lookup = {
|
||||
substitutions,
|
||||
index: tableIndex,
|
||||
subIndex: 0,
|
||||
length: table.inputCoverage.length,
|
||||
contextRange: [
|
||||
-1 * table.backtrackCoverage.length,
|
||||
table.inputCoverage.length + table.lookaheadCoverage.length
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { IReverseChainingContextualSingleSubstitutionTable } from '../tables';
|
||||
import { ILookupTree, ILookupTreeEntry } from '../types';
|
||||
|
||||
import { listGlyphsByIndex } from './coverage';
|
||||
import { processLookaheadPosition, processBacktrackPosition, IEntryMeta } from './helper';
|
||||
|
||||
/**
|
||||
* Build lookup tree for GSUB lookup table 8, format 1.
|
||||
* https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#81-reverse-chaining-contextual-single-substitution-format-1-coverage-based-glyph-contexts
|
||||
*
|
||||
* @param table JSON representation of the table
|
||||
* @param tableIndex Index of this table in the overall lookup
|
||||
*/
|
||||
export default function buildTree(table: IReverseChainingContextualSingleSubstitutionTable, tableIndex: number): ILookupTree {
|
||||
const result: ILookupTree = {
|
||||
individual: {},
|
||||
range: []
|
||||
};
|
||||
|
||||
const glyphs = listGlyphsByIndex(table.coverage);
|
||||
|
||||
for (const { glyphId, index } of glyphs) {
|
||||
const initialEntry: ILookupTreeEntry = {};
|
||||
if (Array.isArray(glyphId)) {
|
||||
result.range.push({
|
||||
entry: initialEntry,
|
||||
range: glyphId
|
||||
});
|
||||
} else {
|
||||
result.individual[glyphId] = initialEntry;
|
||||
}
|
||||
|
||||
let currentEntries: IEntryMeta[] = [{
|
||||
entry: initialEntry,
|
||||
substitutions: [table.substitutes[index]]
|
||||
}];
|
||||
|
||||
// We walk forward, then backward
|
||||
for (const coverage of table.lookaheadCoverage) {
|
||||
currentEntries = processLookaheadPosition(
|
||||
listGlyphsByIndex(coverage).map(glyph => glyph.glyphId),
|
||||
currentEntries
|
||||
);
|
||||
}
|
||||
|
||||
for (const coverage of table.backtrackCoverage) {
|
||||
currentEntries = processBacktrackPosition(
|
||||
listGlyphsByIndex(coverage).map(glyph => glyph.glyphId),
|
||||
currentEntries
|
||||
);
|
||||
}
|
||||
|
||||
// When we get to the end, insert the lookup information
|
||||
for (const { entry, substitutions } of currentEntries) {
|
||||
entry.lookup = {
|
||||
substitutions,
|
||||
index: tableIndex,
|
||||
subIndex: 0,
|
||||
length: 1,
|
||||
contextRange: [
|
||||
-1 * table.backtrackCoverage.length,
|
||||
1 + table.lookaheadCoverage.length
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ClassDefTable } from '../tables';
|
||||
|
||||
/**
|
||||
* Get the number of the class to which the glyph belongs, or null if it doesn't
|
||||
* belong to any of them.
|
||||
*
|
||||
* @param table JSON representation of the class def table
|
||||
* @param glyphId Index of the glyph to look for
|
||||
*/
|
||||
export default function getGlyphClass(table: ClassDefTable, glyphId: number | [number, number]): Map<number | [number, number], number | null> {
|
||||
switch (table.format) {
|
||||
// https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#class-definition-table-format-2
|
||||
case 2:
|
||||
if (Array.isArray(glyphId)) {
|
||||
return getRangeGlyphClass(table, glyphId);
|
||||
}
|
||||
return new Map([[
|
||||
glyphId,
|
||||
getIndividualGlyphClass(table, glyphId)
|
||||
]]);
|
||||
// https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#class-definition-table-format-1
|
||||
default:
|
||||
return new Map([[glyphId, null]]);
|
||||
}
|
||||
}
|
||||
|
||||
function getRangeGlyphClass(table: ClassDefTable.IFormat2, glyphId: [number, number]): Map<number | [number, number], number | null> {
|
||||
const classStart: number = glyphId[0];
|
||||
const currentClass: number | null = getIndividualGlyphClass(table, classStart);
|
||||
let search: number = glyphId[0] + 1;
|
||||
|
||||
const result = new Map<[number, number] | number, number | null>();
|
||||
|
||||
while (search < glyphId[1]) {
|
||||
const clazz = getIndividualGlyphClass(table, search);
|
||||
if (clazz !== currentClass) {
|
||||
if (search - classStart <= 1) {
|
||||
result.set(classStart, currentClass);
|
||||
} else {
|
||||
result.set([classStart, search], currentClass);
|
||||
}
|
||||
}
|
||||
search++;
|
||||
}
|
||||
|
||||
if (search - classStart <= 1) {
|
||||
result.set(classStart, currentClass);
|
||||
} else {
|
||||
result.set([classStart, search], currentClass);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getIndividualGlyphClass(table: ClassDefTable.IFormat2, glyphId: number): number | null {
|
||||
for (const range of table.ranges) {
|
||||
if (range.start <= glyphId && range.end >= glyphId) {
|
||||
return range.classId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listClassGlyphs(table: ClassDefTable, index: number): (number | [number, number])[] {
|
||||
switch (table.format) {
|
||||
case 2:
|
||||
const results: (number | [number, number])[] = [];
|
||||
for (const range of table.ranges) {
|
||||
if (range.classId !== index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (range.end === range.start) {
|
||||
results.push(range.start);
|
||||
} else {
|
||||
results.push([range.start, range.end + 1]);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CoverageTable } from '../tables';
|
||||
|
||||
/**
|
||||
* Get the index of the given glyph in the coverage table, or null if it is not
|
||||
* present in the table.
|
||||
*
|
||||
* @param table JSON representation of the coverage table
|
||||
* @param glyphId Index of the glyph to look for
|
||||
*/
|
||||
export default function getCoverageGlyphIndex(table: CoverageTable, glyphId: number): number | null {
|
||||
switch (table.format) {
|
||||
// https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#coverage-format-1
|
||||
case 1:
|
||||
const index = table.glyphs.indexOf(glyphId);
|
||||
return index !== -1
|
||||
? index
|
||||
: null;
|
||||
// https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#coverage-format-2
|
||||
case 2:
|
||||
const range = table.ranges
|
||||
.find(range => range.start <= glyphId && range.end >= glyphId);
|
||||
return range
|
||||
? range.index
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export function listGlyphsByIndex(table: CoverageTable): { glyphId: number | [number, number], index: number }[] {
|
||||
switch (table.format) {
|
||||
case 1:
|
||||
return table.glyphs.map((glyphId, index) => ({ glyphId, index }));
|
||||
case 2:
|
||||
const results: { glyphId: number | [number, number], index: number }[] = [];
|
||||
for (const [index, range] of table.ranges.entries()) {
|
||||
if (range.end === range.start) {
|
||||
results.push({ glyphId: range.start, index });
|
||||
} else {
|
||||
results.push({ glyphId: [range.start, range.end + 1], index });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ILookupTreeEntry, ILookupTree } from '../types';
|
||||
import { ISubstitutionLookupRecord, Lookup } from '../tables';
|
||||
|
||||
import { getIndividualSubstitutionGlyph, getRangeSubstitutionGlyphs } from './substitution';
|
||||
|
||||
export interface IEntryMeta {
|
||||
entry: ILookupTreeEntry;
|
||||
substitutions: (number | null)[];
|
||||
}
|
||||
|
||||
export function processInputPosition(
|
||||
glyphs: (number | [number, number])[],
|
||||
position: number,
|
||||
currentEntries: IEntryMeta[],
|
||||
lookupRecords: ISubstitutionLookupRecord[],
|
||||
lookups: Lookup[]
|
||||
): IEntryMeta[] {
|
||||
const nextEntries: IEntryMeta[] = [];
|
||||
for (const currentEntry of currentEntries) {
|
||||
currentEntry.entry.forward = {
|
||||
individual: {},
|
||||
range: []
|
||||
};
|
||||
for (const glyph of glyphs) {
|
||||
nextEntries.push(...getInputTree(
|
||||
currentEntry.entry.forward,
|
||||
lookupRecords,
|
||||
lookups,
|
||||
position,
|
||||
glyph
|
||||
).map(({ entry, substitution }) => ({
|
||||
entry,
|
||||
substitutions: [...currentEntry.substitutions, substitution]
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function processLookaheadPosition(
|
||||
glyphs: (number | [number, number])[],
|
||||
currentEntries: IEntryMeta[]
|
||||
): IEntryMeta[] {
|
||||
const nextEntries: IEntryMeta[] = [];
|
||||
const processedEntries = new Set<ILookupTreeEntry>();
|
||||
|
||||
for (const currentEntry of currentEntries) {
|
||||
// Skip if we've already processed this entry object
|
||||
if (processedEntries.has(currentEntry.entry)) {
|
||||
continue;
|
||||
}
|
||||
processedEntries.add(currentEntry.entry);
|
||||
|
||||
currentEntry.entry.forward ??= {
|
||||
individual: {},
|
||||
range: []
|
||||
};
|
||||
|
||||
// All glyphs at this position share ONE entry - lookahead just needs to match,
|
||||
// all paths lead to the same result
|
||||
const sharedEntry: ILookupTreeEntry = {};
|
||||
|
||||
for (const glyph of glyphs) {
|
||||
if (Array.isArray(glyph)) {
|
||||
currentEntry.entry.forward.range.push({
|
||||
entry: sharedEntry,
|
||||
range: glyph
|
||||
});
|
||||
} else {
|
||||
currentEntry.entry.forward.individual[glyph] = sharedEntry;
|
||||
}
|
||||
}
|
||||
|
||||
nextEntries.push({
|
||||
entry: sharedEntry,
|
||||
substitutions: currentEntry.substitutions
|
||||
});
|
||||
}
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function processBacktrackPosition(
|
||||
glyphs: (number | [number, number])[],
|
||||
currentEntries: IEntryMeta[]
|
||||
): IEntryMeta[] {
|
||||
const nextEntries: IEntryMeta[] = [];
|
||||
const processedEntries = new Set<ILookupTreeEntry>();
|
||||
|
||||
for (const currentEntry of currentEntries) {
|
||||
// Skip if we've already processed this entry object
|
||||
if (processedEntries.has(currentEntry.entry)) {
|
||||
continue;
|
||||
}
|
||||
processedEntries.add(currentEntry.entry);
|
||||
|
||||
currentEntry.entry.reverse ??= {
|
||||
individual: {},
|
||||
range: []
|
||||
};
|
||||
|
||||
// All glyphs at this position share ONE entry - backtrack just needs to match,
|
||||
// all paths lead to the same result
|
||||
const sharedEntry: ILookupTreeEntry = {};
|
||||
|
||||
for (const glyph of glyphs) {
|
||||
if (Array.isArray(glyph)) {
|
||||
currentEntry.entry.reverse.range.push({
|
||||
entry: sharedEntry,
|
||||
range: glyph
|
||||
});
|
||||
} else {
|
||||
currentEntry.entry.reverse.individual[glyph] = sharedEntry;
|
||||
}
|
||||
}
|
||||
|
||||
nextEntries.push({
|
||||
entry: sharedEntry,
|
||||
substitutions: currentEntry.substitutions
|
||||
});
|
||||
}
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function getInputTree(tree: ILookupTree, substitutions: ISubstitutionLookupRecord[], lookups: Lookup[], inputIndex: number, glyphId: number | [number, number]): { entry: ILookupTreeEntry, substitution: number | null }[] {
|
||||
const result: { entry: ILookupTreeEntry, substitution: number | null }[] = [];
|
||||
if (!Array.isArray(glyphId)) {
|
||||
tree.individual[glyphId] = {};
|
||||
result.push({
|
||||
entry: tree.individual[glyphId],
|
||||
substitution: getSubstitutionAtPosition(substitutions, lookups, inputIndex, glyphId)
|
||||
});
|
||||
} else {
|
||||
const subs = getSubstitutionAtPositionRange(substitutions, lookups, inputIndex, glyphId);
|
||||
for (const [range, substitution] of subs) {
|
||||
const entry: ILookupTreeEntry = {};
|
||||
if (Array.isArray(range)) {
|
||||
tree.range.push({ range, entry });
|
||||
} else {
|
||||
tree.individual[range] = {};
|
||||
}
|
||||
result.push({ entry, substitution });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSubstitutionAtPositionRange(substitutions: ISubstitutionLookupRecord[], lookups: Lookup[], index: number, range: [number, number]): Map<number | [number, number], number | null> {
|
||||
for (const substitution of substitutions.filter(s => s.sequenceIndex === index)) {
|
||||
for (const substitutionTable of (lookups[substitution.lookupListIndex] as Lookup.IType1).subtables) {
|
||||
const sub = getRangeSubstitutionGlyphs(
|
||||
substitutionTable,
|
||||
range
|
||||
);
|
||||
|
||||
if (!Array.from(sub.values()).every(val => val !== null)) {
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Map([[range, null]]);
|
||||
}
|
||||
|
||||
function getSubstitutionAtPosition(substitutions: ISubstitutionLookupRecord[], lookups: Lookup[], index: number, glyphId: number): number | null {
|
||||
for (const substitution of substitutions.filter(s => s.sequenceIndex === index)) {
|
||||
for (const substitutionTable of (lookups[substitution.lookupListIndex] as Lookup.IType1).subtables) {
|
||||
const sub = getIndividualSubstitutionGlyph(
|
||||
substitutionTable,
|
||||
glyphId
|
||||
);
|
||||
|
||||
if (sub !== null) {
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { SubstitutionTable } from '../tables';
|
||||
|
||||
import getCoverageGlyphIndex from './coverage';
|
||||
|
||||
/**
|
||||
* Get the substitution glyph for the givne glyph, or null if the glyph was not
|
||||
* found in the table.
|
||||
*
|
||||
* @param table JSON representation of the substitution table
|
||||
* @param glyphId The index of the glpyh to find substitutions for
|
||||
*/
|
||||
export function getRangeSubstitutionGlyphs(table: SubstitutionTable, glyphId: [number, number]): Map<[number, number] | number, number | null> {
|
||||
const replacementStart: number = glyphId[0];
|
||||
const currentReplacement: number | null = getIndividualSubstitutionGlyph(table, replacementStart);
|
||||
let search: number = glyphId[0] + 1;
|
||||
|
||||
const result = new Map<[number, number] | number, number | null>();
|
||||
|
||||
while (search < glyphId[1]) {
|
||||
const sub = getIndividualSubstitutionGlyph(table, search);
|
||||
if (sub !== currentReplacement) {
|
||||
if (search - replacementStart <= 1) {
|
||||
result.set(replacementStart, currentReplacement);
|
||||
} else {
|
||||
result.set([replacementStart, search], currentReplacement);
|
||||
}
|
||||
}
|
||||
|
||||
search++;
|
||||
}
|
||||
|
||||
if (search - replacementStart <= 1) {
|
||||
result.set(replacementStart, currentReplacement);
|
||||
} else {
|
||||
result.set([replacementStart, search], currentReplacement);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getIndividualSubstitutionGlyph(table: SubstitutionTable, glyphId: number): number | null {
|
||||
const coverageIndex = getCoverageGlyphIndex(table.coverage, glyphId);
|
||||
|
||||
// istanbul ignore next - invalid font
|
||||
if (coverageIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (table.substFormat) {
|
||||
// https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#11-single-substitution-format-1
|
||||
case 1:
|
||||
// TODO: determine if there's a rhyme or reason to the 16-bit
|
||||
// wraparound and if it can ever be a different number
|
||||
return (glyphId + table.deltaGlyphId) % (2 ** 16);
|
||||
// https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#12-single-substitution-format-2
|
||||
case 2:
|
||||
return table.substitute[coverageIndex] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export type SubstitutionTable = SubstitutionTable.IFormat1 | SubstitutionTable.IFormat2;
|
||||
export namespace SubstitutionTable {
|
||||
export interface IFormat1 {
|
||||
substFormat: 1;
|
||||
coverage: CoverageTable;
|
||||
deltaGlyphId: number;
|
||||
}
|
||||
|
||||
export interface IFormat2 {
|
||||
substFormat: 2;
|
||||
coverage: CoverageTable;
|
||||
substitute: number[];
|
||||
}
|
||||
}
|
||||
|
||||
export type CoverageTable = CoverageTable.IFormat1 | CoverageTable.IFormat2;
|
||||
export namespace CoverageTable {
|
||||
export interface IFormat1 {
|
||||
format: 1;
|
||||
glyphs: number[];
|
||||
}
|
||||
|
||||
export interface IFormat2 {
|
||||
format: 2;
|
||||
ranges: {
|
||||
start: number;
|
||||
end: number;
|
||||
index: number;
|
||||
}[];
|
||||
}
|
||||
}
|
||||
|
||||
export type ChainingContextualSubstitutionTable = ChainingContextualSubstitutionTable.IFormat1 |
|
||||
ChainingContextualSubstitutionTable.IFormat2 | ChainingContextualSubstitutionTable.IFormat3;
|
||||
export namespace ChainingContextualSubstitutionTable {
|
||||
export interface IFormat1 {
|
||||
substFormat: 1;
|
||||
coverage: CoverageTable;
|
||||
chainRuleSets: ChainSubRuleTable[][];
|
||||
}
|
||||
|
||||
export interface IFormat2 {
|
||||
substFormat: 2;
|
||||
coverage: CoverageTable;
|
||||
backtrackClassDef: ClassDefTable;
|
||||
inputClassDef: ClassDefTable;
|
||||
lookaheadClassDef: ClassDefTable;
|
||||
chainClassSet: (null | IChainSubClassRuleTable[])[];
|
||||
}
|
||||
|
||||
export interface IFormat3 {
|
||||
substFormat: 3;
|
||||
backtrackCoverage: CoverageTable[];
|
||||
inputCoverage: CoverageTable[];
|
||||
lookaheadCoverage: CoverageTable[];
|
||||
lookupRecords: ISubstitutionLookupRecord[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface IReverseChainingContextualSingleSubstitutionTable {
|
||||
substFormat: 1;
|
||||
coverage: CoverageTable;
|
||||
backtrackCoverage: CoverageTable[];
|
||||
lookaheadCoverage: CoverageTable[];
|
||||
substitutes: number[];
|
||||
}
|
||||
|
||||
export type ClassDefTable = ClassDefTable.IFormat2;
|
||||
export namespace ClassDefTable {
|
||||
export interface IFormat2 {
|
||||
format: 2;
|
||||
ranges: {
|
||||
start: number;
|
||||
end: number;
|
||||
classId: number;
|
||||
}[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface ISubstitutionLookupRecord {
|
||||
sequenceIndex: number;
|
||||
lookupListIndex: number;
|
||||
}
|
||||
|
||||
export type ChainSubRuleTable = IChainSubClassRuleTable;
|
||||
export interface IChainSubClassRuleTable {
|
||||
backtrack: number[];
|
||||
input: number[];
|
||||
lookahead: number[];
|
||||
lookupRecords: ISubstitutionLookupRecord[];
|
||||
}
|
||||
|
||||
export type Lookup = Lookup.IType1 | Lookup.IType6 | Lookup.IType8;
|
||||
export namespace Lookup {
|
||||
export interface IType1 {
|
||||
lookupType: 1;
|
||||
lookupFlag: number;
|
||||
subtables: SubstitutionTable[];
|
||||
}
|
||||
|
||||
export interface IType6 {
|
||||
lookupType: 6;
|
||||
lookupFlag: number;
|
||||
subtables: ChainingContextualSubstitutionTable[];
|
||||
}
|
||||
|
||||
export interface IType8 {
|
||||
lookupType: 8;
|
||||
lookupFlag: number;
|
||||
subtables: IReverseChainingContextualSingleSubstitutionTable[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface ISubstitutionResult {
|
||||
index: number;
|
||||
contextRange: [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about ligatures found in a sequence of text
|
||||
*/
|
||||
export interface ILigatureData {
|
||||
/**
|
||||
* The list of font glyphs in the input text.
|
||||
*/
|
||||
inputGlyphs: number[];
|
||||
|
||||
/**
|
||||
* The list of font glyphs after performing replacements for font ligatures.
|
||||
*/
|
||||
outputGlyphs: number[];
|
||||
|
||||
/**
|
||||
* Sorted array of ranges that must be rendered together to produce the
|
||||
* ligatures in the output sequence. The ranges are inclusive on the left and
|
||||
* exclusive on the right.
|
||||
*/
|
||||
contextRanges: [number, number][];
|
||||
}
|
||||
|
||||
export interface IFont {
|
||||
/**
|
||||
* Scans the provided text for font ligatures, returning an object with
|
||||
* metadata about the text and any ligatures found.
|
||||
*
|
||||
* @param text String to search for ligatures
|
||||
*/
|
||||
findLigatures(text: string): ILigatureData;
|
||||
|
||||
/**
|
||||
* Scans the provided text for font ligatures, returning an array of ranges
|
||||
* where ligatures are located.
|
||||
*
|
||||
* @param text String to search for ligatures
|
||||
*/
|
||||
findLigatureRanges(text: string): [number, number][];
|
||||
}
|
||||
|
||||
export interface IOptions {
|
||||
/**
|
||||
* Optional size of previous results to store, measured in total number of
|
||||
* characters from input strings. Defaults to no cache (0)
|
||||
*/
|
||||
cacheSize?: number;
|
||||
}
|
||||
|
||||
export interface ILookupTree {
|
||||
individual: {
|
||||
[glyphId: string]: ILookupTreeEntry;
|
||||
};
|
||||
range: {
|
||||
range: [number, number];
|
||||
entry: ILookupTreeEntry;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ILookupTreeEntry {
|
||||
lookup?: ILookupResult;
|
||||
forward?: ILookupTree;
|
||||
reverse?: ILookupTree;
|
||||
}
|
||||
|
||||
export interface ILookupResult {
|
||||
substitutions: (number | null)[];
|
||||
length: number;
|
||||
index: number;
|
||||
subIndex: number;
|
||||
contextRange: [number, number];
|
||||
}
|
||||
|
||||
export interface IFlattenedLookupTree {
|
||||
[glyphId: string]: IFlattenedLookupTreeEntry;
|
||||
}
|
||||
|
||||
export interface IFlattenedLookupTreeEntry {
|
||||
lookup?: ILookupResult;
|
||||
forward?: IFlattenedLookupTree;
|
||||
reverse?: IFlattenedLookupTree;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { IFlattenedLookupTree, ILookupResult } from './types';
|
||||
|
||||
export default function walkTree(tree: IFlattenedLookupTree, sequence: number[], startIndex: number, index: number): ILookupResult | undefined {
|
||||
const glyphId = sequence[index];
|
||||
const subtree = tree[glyphId];
|
||||
if (!subtree) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let lookup = subtree.lookup;
|
||||
if (subtree.reverse) {
|
||||
const reverseLookup = walkReverse(subtree.reverse, sequence, startIndex);
|
||||
|
||||
if (
|
||||
(!lookup && reverseLookup) ||
|
||||
(
|
||||
reverseLookup && lookup && (
|
||||
lookup.index > reverseLookup.index ||
|
||||
(lookup.index === reverseLookup.index && lookup.subIndex > reverseLookup.subIndex)
|
||||
)
|
||||
)
|
||||
) {
|
||||
lookup = reverseLookup;
|
||||
}
|
||||
}
|
||||
|
||||
if (++index >= sequence.length || !subtree.forward) {
|
||||
return lookup;
|
||||
}
|
||||
|
||||
const forwardLookup = walkTree(subtree.forward, sequence, startIndex, index);
|
||||
|
||||
if (
|
||||
(!lookup && forwardLookup) ||
|
||||
(
|
||||
forwardLookup && lookup && (
|
||||
lookup.index > forwardLookup.index ||
|
||||
(lookup.index === forwardLookup.index && lookup.subIndex > forwardLookup.subIndex)
|
||||
)
|
||||
)
|
||||
) {
|
||||
lookup = forwardLookup;
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function walkReverse(tree: IFlattenedLookupTree, sequence: number[], index: number): ILookupResult | undefined {
|
||||
let subtree = tree[sequence[--index]];
|
||||
let lookup: ILookupResult | undefined = subtree && subtree.lookup;
|
||||
while (subtree) {
|
||||
if (
|
||||
(!lookup && subtree.lookup) ||
|
||||
(subtree.lookup && lookup && lookup.index > subtree.lookup.index)
|
||||
) {
|
||||
lookup = subtree.lookup;
|
||||
}
|
||||
|
||||
if (--index < 0 || !subtree.reverse) {
|
||||
break;
|
||||
}
|
||||
|
||||
subtree = subtree.reverse[sequence[index]];
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { Font } from 'font-ligatures';
|
||||
import { Font } from './fontLigatures/index';
|
||||
|
||||
import load from './font';
|
||||
|
||||
@@ -30,7 +30,7 @@ export function enableLigatures(term: Terminal, fallbackLigatures: string[] = []
|
||||
let currentFontName: string | undefined = undefined;
|
||||
let font: Font | undefined = undefined;
|
||||
let loadingState: LoadingState = LoadingState.UNLOADED;
|
||||
let loadError: any | undefined = undefined;
|
||||
let loadError: unknown = undefined;
|
||||
|
||||
return term.registerCharacterJoiner((text: string): [number, number][] => {
|
||||
// If the font hasn't been loaded yet, load it and return an empty result
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
|
||||
import parse from './parse';
|
||||
|
||||
// TODO: integrate tests from http://test.csswg.org/suites/css-fonts-4_dev/nightly-unstable/
|
||||
describe('parse', () => {
|
||||
describe('addon-ligatures - parse', () => {
|
||||
it('parses individual families', () => {
|
||||
assert.deepEqual(parse('monospace'), ['monospace']);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"noUnusedLocals": true,
|
||||
"preserveWatchOutput": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha"
|
||||
"../../../node_modules/@types/mocha",
|
||||
// HACK: src shouldn't use node types but it's needed for index.test.ts
|
||||
"../../../node_modules/@types/node"
|
||||
],
|
||||
"paths": {
|
||||
"@xterm/addon-ligatures" : [
|
||||
|
||||
+20
-19
@@ -4,61 +4,62 @@
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as sinon from 'sinon';
|
||||
import { assert } from 'chai';
|
||||
import * as fontFinder from 'font-finder';
|
||||
|
||||
import * as ligatureSupport from '.';
|
||||
// Use require to get a mutable module object (ESM imports create read-only bindings)
|
||||
const fontFinder = require('font-finder');
|
||||
const ligatureSupport = require('../out-esbuild/index');
|
||||
|
||||
const originalList = fontFinder.list;
|
||||
|
||||
describe('LigaturesAddon', () => {
|
||||
let onRefresh: sinon.SinonStub;
|
||||
let onRefresh: { called: boolean, callCount: number, (...args: any[]): void };
|
||||
let term: MockTerminal;
|
||||
|
||||
// -> forms a ligature in Fira Code and Iosevka, but www only forms a ligature
|
||||
// in Fira Code
|
||||
const input = 'a -> b www c';
|
||||
|
||||
before(() => {
|
||||
sinon.stub(fontFinder, 'list').returns(Promise.resolve({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
fontFinder.list = () => Promise.resolve({
|
||||
'Fira Code': [{
|
||||
path: path.join(__dirname, '../fonts/firaCode.otf'),
|
||||
style: fontFinder.Style.Regular,
|
||||
type: fontFinder.Type.Monospace,
|
||||
weight: 400
|
||||
}],
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Iosevka': [{
|
||||
path: path.join(__dirname, '../fonts/iosevka.ttf'),
|
||||
style: fontFinder.Style.Regular,
|
||||
type: fontFinder.Type.Monospace,
|
||||
weight: 400
|
||||
}],
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Nonexistant Font': [{
|
||||
path: path.join(__dirname, '../fonts/nonexistant.ttf'),
|
||||
style: fontFinder.Style.Regular,
|
||||
type: fontFinder.Type.Monospace,
|
||||
weight: 400
|
||||
}]
|
||||
} as fontFinder.FontList));
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fontFinder.list = originalList;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
onRefresh = sinon.stub();
|
||||
onRefresh = Object.assign((..._args: any[]) => { onRefresh.called = true; onRefresh.callCount++; }, { called: false, callCount: 0 });
|
||||
term = new MockTerminal(onRefresh);
|
||||
ligatureSupport.enableLigatures(term as any);
|
||||
});
|
||||
|
||||
it('registers itself correctly', () => {
|
||||
const term = new MockTerminal(sinon.spy());
|
||||
const term = new MockTerminal(() => {});
|
||||
assert.isUndefined(term.joiner);
|
||||
ligatureSupport.enableLigatures(term as any);
|
||||
assert.isFunction(term.joiner);
|
||||
});
|
||||
|
||||
it('registers itself correctly when called directly', () => {
|
||||
const term = new MockTerminal(sinon.spy());
|
||||
const term = new MockTerminal(() => {});
|
||||
assert.isUndefined(term.joiner);
|
||||
ligatureSupport.enableLigatures(term as any);
|
||||
assert.isFunction(term.joiner);
|
||||
@@ -72,14 +73,14 @@ describe('LigaturesAddon', () => {
|
||||
term.options.fontFamily = 'Nonexistant Font, monospace';
|
||||
assert.deepEqual(term.joiner!(input), []);
|
||||
await delay(500);
|
||||
assert.isTrue(onRefresh.notCalled);
|
||||
assert.strictEqual(onRefresh.callCount, 0);
|
||||
});
|
||||
|
||||
it('returns nothing if the font is not present on the system', async () => {
|
||||
term.options.fontFamily = 'notinstalled';
|
||||
assert.deepEqual(term.joiner!(input), []);
|
||||
await delay(500);
|
||||
assert.isTrue(onRefresh.notCalled);
|
||||
assert.strictEqual(onRefresh.callCount, 0);
|
||||
assert.deepEqual(term.joiner!(input), []);
|
||||
});
|
||||
|
||||
@@ -87,7 +88,7 @@ describe('LigaturesAddon', () => {
|
||||
term.options.fontFamily = 'monospace';
|
||||
assert.deepEqual(term.joiner!(input), []);
|
||||
await delay(500);
|
||||
assert.isTrue(onRefresh.notCalled);
|
||||
assert.strictEqual(onRefresh.callCount, 0);
|
||||
assert.deepEqual(term.joiner!(input), []);
|
||||
});
|
||||
|
||||
@@ -95,7 +96,7 @@ describe('LigaturesAddon', () => {
|
||||
term.options.fontFamily = '';
|
||||
assert.deepEqual(term.joiner!(input), []);
|
||||
await delay(500);
|
||||
assert.isTrue(onRefresh.notCalled);
|
||||
assert.strictEqual(onRefresh.callCount, 0);
|
||||
assert.deepEqual(term.joiner!(input), []);
|
||||
});
|
||||
|
||||
@@ -103,7 +104,7 @@ describe('LigaturesAddon', () => {
|
||||
term.options.fontFamily = {} as any;
|
||||
assert.deepEqual(term.joiner!(input), []);
|
||||
await delay(500);
|
||||
assert.isTrue(onRefresh.notCalled);
|
||||
assert.strictEqual(onRefresh.callCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"es2021"
|
||||
],
|
||||
"rootDir": ".",
|
||||
"outDir": "../out-esbuild-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"strict": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha",
|
||||
"../../../node_modules/@types/node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../../typings/xterm.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{ "path": "./src" }
|
||||
{ "path": "./src" },
|
||||
{ "path": "./test" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@sinonjs/commons@^1", "@sinonjs/commons@^1.0.2", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.7.0":
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.0.tgz#c8d68821a854c555bba172f3b06959a0039b236d"
|
||||
integrity sha512-wEj54PfsZ5jGSwMX68G8ZXFawcSglQSXqCftWX3ec8MDUzQdHgcKvw97awHbY0efQEL5iKUOAmmVtoYgmrSG4Q==
|
||||
dependencies:
|
||||
type-detect "4.0.8"
|
||||
|
||||
"@sinonjs/formatio@^3.0.0", "@sinonjs/formatio@^3.2.1":
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-3.2.2.tgz#771c60dfa75ea7f2d68e3b94c7e888a78781372c"
|
||||
integrity sha512-B8SEsgd8gArBLMD6zpRw3juQ2FVSsmdd7qlevyDqzS9WTCtvF55/gAL+h6gue8ZvPYcdiPdvueM/qm//9XzyTQ==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1"
|
||||
"@sinonjs/samsam" "^3.1.0"
|
||||
|
||||
"@sinonjs/samsam@^2.1.2":
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-2.1.3.tgz#62cf2a9b624edc795134135fe37fc2ae8ea36be3"
|
||||
integrity sha512-8zNeBkSKhU9a5cRNbpCKau2WWPfan+Q2zDlcXvXyhn9EsMqgYs4qzo0XHNVlXC6ABQL8fT6nV+zzo5RTHJzyXw==
|
||||
|
||||
"@sinonjs/samsam@^3.1.0":
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-3.3.3.tgz#46682efd9967b259b81136b9f120fd54585feb4a"
|
||||
integrity sha512-bKCMKZvWIjYD0BLGnNrxVuw4dkWCYsLqFOUWw8VgKF/+5Y+mE7LfHWPIYoDXowH+3a9LsWDMo0uAP8YDosPvHQ==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1.3.0"
|
||||
array-from "^2.1.1"
|
||||
lodash "^4.17.15"
|
||||
|
||||
"@sinonjs/text-encoding@^0.7.1":
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
|
||||
integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
|
||||
|
||||
"@types/sinon@^5.0.1":
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-5.0.1.tgz#a15b36ec42f1f53166617491feabd1734cb03e21"
|
||||
|
||||
array-from@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/array-from/-/array-from-2.1.1.tgz#cfe9d8c26628b9dc5aecc62a9f5d8f1f352c1195"
|
||||
integrity sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=
|
||||
|
||||
asynckit@^0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||
|
||||
axios@^1.6.0:
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979"
|
||||
integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==
|
||||
dependencies:
|
||||
follow-redirects "^1.15.6"
|
||||
form-data "^4.0.0"
|
||||
proxy-from-env "^1.1.0"
|
||||
|
||||
buffer-crc32@~0.2.3:
|
||||
version "0.2.13"
|
||||
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
|
||||
|
||||
combined-stream@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
|
||||
dependencies:
|
||||
delayed-stream "~1.0.0"
|
||||
|
||||
delayed-stream@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
||||
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
|
||||
|
||||
diff@^3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
|
||||
|
||||
fd-slicer@~1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
|
||||
dependencies:
|
||||
pend "~1.2.0"
|
||||
|
||||
follow-redirects@^1.15.6:
|
||||
version "1.15.9"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
|
||||
integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
|
||||
|
||||
font-finder@^1.0.3:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/font-finder/-/font-finder-1.0.4.tgz#2ca944954dd8d0e1b5bdc4c596cc08607761d89b"
|
||||
dependencies:
|
||||
get-system-fonts "^2.0.0"
|
||||
promise-stream-reader "^1.0.1"
|
||||
|
||||
font-finder@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/font-finder/-/font-finder-1.1.0.tgz#2bff2b2762acba720239c8bec898a96daae90858"
|
||||
integrity sha512-wpCL2uIbi6GurJbU7ZlQ3nGd61Ho+dSU6U83/xJT5UPFfN35EeCW/rOtS+5k+IuEZu2SYmHzDIPL9eA5tSYRAw==
|
||||
dependencies:
|
||||
get-system-fonts "^2.0.0"
|
||||
promise-stream-reader "^1.0.1"
|
||||
|
||||
font-ligatures@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.npmjs.org/font-ligatures/-/font-ligatures-1.4.1.tgz#e8c9df7cf6bee80c0cca4a9a578618c7b1f5d8de"
|
||||
integrity sha512-7W6zlfyhvCqShZ5ReUWqmSd9vBaUudW0Hxis+tqUjtHhsPU+L3Grf8mcZAtCiXHTzorhwdRTId2WeH/88gdFkw==
|
||||
dependencies:
|
||||
font-finder "^1.0.3"
|
||||
lru-cache "^6.0.0"
|
||||
opentype.js "^0.8.0"
|
||||
|
||||
form-data@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
|
||||
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.8"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
get-system-fonts@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/get-system-fonts/-/get-system-fonts-2.0.0.tgz#a43b9a33f05c0715a60176d2aad5ce6e98f0a3c6"
|
||||
|
||||
has-flag@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
|
||||
|
||||
isarray@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
|
||||
|
||||
just-extend@^4.0.2:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.0.tgz#7278a4027d889601640ee0ce0e5a00b992467da4"
|
||||
integrity sha512-ApcjaOdVTJ7y4r08xI5wIqpvwS48Q0PBG4DJROcEkH1f8MdAiNFyFxz3xoL0LWAVwjrwPYZdVHHxhRHcx/uGLA==
|
||||
|
||||
lodash.get@^4.4.2:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
||||
|
||||
lodash@^4.17.15:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
lolex@^2.7.5:
|
||||
version "2.7.5"
|
||||
resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.7.5.tgz#113001d56bfc7e02d56e36291cc5c413d1aa0733"
|
||||
integrity sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==
|
||||
|
||||
lolex@^5.0.1:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/lolex/-/lolex-5.1.2.tgz#953694d098ce7c07bc5ed6d0e42bc6c0c6d5a367"
|
||||
integrity sha512-h4hmjAvHTmd+25JSwrtTIuwbKdwg5NzZVRMLn9saij4SZaepCrTCxPr35H/3bjwfMJtN+t3CX8672UIkglz28A==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1.7.0"
|
||||
|
||||
lru-cache@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
|
||||
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
mime-db@1.52.0:
|
||||
version "1.52.0"
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
|
||||
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
|
||||
|
||||
mime-types@^2.1.12:
|
||||
version "2.1.35"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
|
||||
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
|
||||
dependencies:
|
||||
mime-db "1.52.0"
|
||||
|
||||
minimist@^1.2.5:
|
||||
version "1.2.6"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
|
||||
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
|
||||
|
||||
mkdirp@0.5.5:
|
||||
version "0.5.5"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
|
||||
integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
|
||||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
|
||||
nise@^1.4.5:
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/nise/-/nise-1.5.3.tgz#9d2cfe37d44f57317766c6e9408a359c5d3ac1f7"
|
||||
integrity sha512-Ymbac/94xeIrMf59REBPOv0thr+CJVFMhrlAkW/gjCIE58BGQdCj0x7KRCb3yz+Ga2Rz3E9XXSvUyyxqqhjQAQ==
|
||||
dependencies:
|
||||
"@sinonjs/formatio" "^3.2.1"
|
||||
"@sinonjs/text-encoding" "^0.7.1"
|
||||
just-extend "^4.0.2"
|
||||
lolex "^5.0.1"
|
||||
path-to-regexp "^1.7.0"
|
||||
|
||||
opentype.js@^0.8.0:
|
||||
version "0.8.0"
|
||||
resolved "https://registry.yarnpkg.com/opentype.js/-/opentype.js-0.8.0.tgz#acabcfa1642fbe894a3e4d759e43ba694e02bd35"
|
||||
dependencies:
|
||||
tiny-inflate "^1.0.2"
|
||||
|
||||
path-to-regexp@^1.7.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.9.0.tgz#5dc0753acbf8521ca2e0f137b4578b917b10cf24"
|
||||
integrity sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==
|
||||
dependencies:
|
||||
isarray "0.0.1"
|
||||
|
||||
pend@~1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
|
||||
|
||||
promise-stream-reader@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/promise-stream-reader/-/promise-stream-reader-1.0.1.tgz#4e793a79c9d49a73ccd947c6da9c127f12923649"
|
||||
|
||||
proxy-from-env@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
|
||||
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
|
||||
|
||||
sinon@6.3.5:
|
||||
version "6.3.5"
|
||||
resolved "https://registry.yarnpkg.com/sinon/-/sinon-6.3.5.tgz#0f6d6a5b4ebaad1f6e8e019395542d1d02c144a0"
|
||||
integrity sha512-xgoZ2gKjyVRcF08RrIQc+srnSyY1JDJtxu3Nsz07j1ffjgXoY6uPLf/qja6nDBZgzYYEovVkFryw2+KiZz11xQ==
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^1.0.2"
|
||||
"@sinonjs/formatio" "^3.0.0"
|
||||
"@sinonjs/samsam" "^2.1.2"
|
||||
diff "^3.5.0"
|
||||
lodash.get "^4.4.2"
|
||||
lolex "^2.7.5"
|
||||
nise "^1.4.5"
|
||||
supports-color "^5.5.0"
|
||||
type-detect "^4.0.8"
|
||||
|
||||
supports-color@^5.5.0:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
|
||||
integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
|
||||
dependencies:
|
||||
has-flag "^3.0.0"
|
||||
|
||||
tiny-inflate@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.2.tgz#93d9decffc8805bd57eae4310f0b745e9b6fb3a7"
|
||||
|
||||
type-detect@4.0.8, type-detect@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
|
||||
|
||||
yallist@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
|
||||
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
||||
|
||||
yauzl@^2.10.0:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
|
||||
dependencies:
|
||||
buffer-crc32 "~0.2.3"
|
||||
fd-slicer "~1.1.0"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xterm/addon-progress",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
@@ -21,8 +21,5 @@
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha",
|
||||
"../../../src/vs/typings/thenable"
|
||||
"../../../node_modules/@types/mocha"
|
||||
],
|
||||
"paths": {
|
||||
"browser/*": [
|
||||
|
||||
@@ -5,10 +5,9 @@ const config: PlaywrightTestConfig = {
|
||||
timeout: 10000,
|
||||
projects: [
|
||||
{
|
||||
name: 'ChromeStable',
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
channel: 'chrome'
|
||||
browserName: 'chromium'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@xterm/addon-search",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"author": {
|
||||
"name": "The xterm.js authors",
|
||||
"url": "https://xtermjs.org/"
|
||||
@@ -20,8 +20,5 @@
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
|
||||
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
||||
import { dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
* Interface for managing a highlight decoration.
|
||||
*/
|
||||
interface IHighlight extends IDisposable {
|
||||
decoration: IDecoration;
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for managing multiple decorations for a single match.
|
||||
*/
|
||||
interface IMultiHighlight extends IDisposable {
|
||||
decorations: IDecoration[];
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages visual decorations for search results including highlighting and active selection
|
||||
* indicators. This class handles the creation, styling, and disposal of search-related decorations.
|
||||
*/
|
||||
export class DecorationManager extends Disposable {
|
||||
private _highlightDecorations: IHighlight[] = [];
|
||||
private _highlightedLines: Set<number> = new Set();
|
||||
|
||||
constructor(private readonly _terminal: Terminal) {
|
||||
super();
|
||||
this._register(toDisposable(() => this.clearHighlightDecorations()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates decorations for all provided search results.
|
||||
* @param results The search results to create decorations for.
|
||||
* @param options The decoration options.
|
||||
*/
|
||||
public createHighlightDecorations(results: ISearchResult[], options: ISearchDecorationOptions): void {
|
||||
this.clearHighlightDecorations();
|
||||
|
||||
for (const match of results) {
|
||||
const decorations = this._createResultDecorations(match, options, false);
|
||||
if (decorations) {
|
||||
for (const decoration of decorations) {
|
||||
this._storeDecoration(decoration, match);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates decorations for the currently active search result.
|
||||
* @param result The active search result.
|
||||
* @param options The decoration options.
|
||||
* @returns The multi-highlight decoration or undefined if creation failed.
|
||||
*/
|
||||
public createActiveDecoration(result: ISearchResult, options: ISearchDecorationOptions): IMultiHighlight | undefined {
|
||||
const decorations = this._createResultDecorations(result, options, true);
|
||||
if (decorations) {
|
||||
return { decorations, match: result, dispose() { dispose(decorations); } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all highlight decorations.
|
||||
*/
|
||||
public clearHighlightDecorations(): void {
|
||||
dispose(this._highlightDecorations);
|
||||
this._highlightDecorations = [];
|
||||
this._highlightedLines.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a decoration and tracks it for management.
|
||||
* @param decoration The decoration to store.
|
||||
* @param match The search result this decoration represents.
|
||||
*/
|
||||
private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {
|
||||
this._highlightedLines.add(decoration.marker.line);
|
||||
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies styles to the decoration when it is rendered.
|
||||
* @param element The decoration's element.
|
||||
* @param borderColor The border color to apply.
|
||||
* @param isActiveResult Whether the element is part of the active search result.
|
||||
*/
|
||||
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
|
||||
if (!element.classList.contains('xterm-find-result-decoration')) {
|
||||
element.classList.add('xterm-find-result-decoration');
|
||||
if (borderColor) {
|
||||
element.style.outline = `1px solid ${borderColor}`;
|
||||
}
|
||||
}
|
||||
if (isActiveResult) {
|
||||
element.classList.add('xterm-find-active-result-decoration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a decoration for the result and applies styles
|
||||
* @param result the search result for which to create the decoration
|
||||
* @param options the options for the decoration
|
||||
* @param isActiveResult whether this is the currently active result
|
||||
* @returns the decorations or undefined if the marker has already been disposed of
|
||||
*/
|
||||
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
|
||||
// Gather decoration ranges for this match as it could wrap
|
||||
const decorationRanges: [number, number, number][] = [];
|
||||
let currentCol = result.col;
|
||||
let remainingSize = result.size;
|
||||
let markerOffset = -this._terminal.buffer.active.baseY - this._terminal.buffer.active.cursorY + result.row;
|
||||
while (remainingSize > 0) {
|
||||
const amountThisRow = Math.min(this._terminal.cols - currentCol, remainingSize);
|
||||
decorationRanges.push([markerOffset, currentCol, amountThisRow]);
|
||||
currentCol = 0;
|
||||
remainingSize -= amountThisRow;
|
||||
markerOffset++;
|
||||
}
|
||||
|
||||
// Create the decorations
|
||||
const decorations: IDecoration[] = [];
|
||||
for (const range of decorationRanges) {
|
||||
const marker = this._terminal.registerMarker(range[0]);
|
||||
const decoration = this._terminal.registerDecoration({
|
||||
marker,
|
||||
x: range[1],
|
||||
width: range[2],
|
||||
layer: isActiveResult ? 'top' : 'bottom',
|
||||
backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,
|
||||
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
|
||||
color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,
|
||||
position: 'center'
|
||||
}
|
||||
});
|
||||
if (decoration) {
|
||||
const disposables: IDisposable[] = [];
|
||||
disposables.push(marker);
|
||||
disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));
|
||||
disposables.push(decoration.onDispose(() => dispose(disposables)));
|
||||
decorations.push(decoration);
|
||||
}
|
||||
}
|
||||
|
||||
return decorations.length === 0 ? undefined : decorations;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,129 +3,92 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal, IDisposable, ITerminalAddon, IDecoration } from '@xterm/xterm';
|
||||
import type { SearchAddon as ISearchApi } from '@xterm/addon-search';
|
||||
import { Emitter } from 'vs/base/common/event';
|
||||
import { combinedDisposable, Disposable, dispose, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
|
||||
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { disposableTimeout } from 'vs/base/common/async';
|
||||
import { SearchLineCache } from './SearchLineCache';
|
||||
import { SearchState } from './SearchState';
|
||||
import { SearchEngine, type ISearchResult } from './SearchEngine';
|
||||
import { DecorationManager } from './DecorationManager';
|
||||
import { SearchResultTracker } from './SearchResultTracker';
|
||||
|
||||
export interface ISearchOptions {
|
||||
regex?: boolean;
|
||||
wholeWord?: boolean;
|
||||
caseSensitive?: boolean;
|
||||
incremental?: boolean;
|
||||
decorations?: ISearchDecorationOptions;
|
||||
noScroll?: boolean;
|
||||
interface IInternalSearchOptions {
|
||||
noScroll: boolean;
|
||||
}
|
||||
|
||||
interface ISearchDecorationOptions {
|
||||
matchBackground?: string;
|
||||
matchBorder?: string;
|
||||
matchOverviewRuler: string;
|
||||
activeMatchBackground?: string;
|
||||
activeMatchBorder?: string;
|
||||
activeMatchColorOverviewRuler: string;
|
||||
}
|
||||
|
||||
export interface ISearchPosition {
|
||||
startCol: number;
|
||||
startRow: number;
|
||||
}
|
||||
|
||||
export interface ISearchAddonOptions {
|
||||
highlightLimit: number;
|
||||
}
|
||||
|
||||
export interface ISearchResult {
|
||||
term: string;
|
||||
col: number;
|
||||
row: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
type LineCacheEntry = [
|
||||
/**
|
||||
* Configuration constants for the search addon functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* The string representation of a line (as opposed to the buffer cell representation).
|
||||
* Default maximum number of search results to highlight simultaneously. This limit prevents
|
||||
* performance degradation when searching for very common terms that would result in excessive
|
||||
* highlighting decorations.
|
||||
*/
|
||||
lineAsString: string,
|
||||
/**
|
||||
* The offsets where each line starts when the entry describes a wrapped line.
|
||||
*/
|
||||
lineOffsets: number[]
|
||||
];
|
||||
|
||||
interface IHighlight extends IDisposable {
|
||||
decoration: IDecoration;
|
||||
match: ISearchResult;
|
||||
DEFAULT_HIGHLIGHT_LIMIT = 1000
|
||||
}
|
||||
|
||||
interface IMultiHighlight extends IDisposable {
|
||||
decorations: IDecoration[];
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?';
|
||||
const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs
|
||||
const DEFAULT_HIGHLIGHT_LIMIT = 1000;
|
||||
|
||||
export class SearchAddon extends Disposable implements ITerminalAddon , ISearchApi {
|
||||
export class SearchAddon extends Disposable implements ITerminalAddon, ISearchApi {
|
||||
private _terminal: Terminal | undefined;
|
||||
private _cachedSearchTerm: string | undefined;
|
||||
private _highlightedLines: Set<number> = new Set();
|
||||
private _highlightDecorations: IHighlight[] = [];
|
||||
private _selectedDecoration: MutableDisposable<IMultiHighlight> = this._register(new MutableDisposable());
|
||||
private _highlightLimit: number;
|
||||
private _lastSearchOptions: ISearchOptions | undefined;
|
||||
private _highlightTimeout: number | undefined;
|
||||
/**
|
||||
* translateBufferLineToStringWithWrap is a fairly expensive call.
|
||||
* We memoize the calls into an array that has a time based ttl.
|
||||
* _linesCache is also invalidated when the terminal cursor moves.
|
||||
*/
|
||||
private _linesCache: LineCacheEntry[] | undefined;
|
||||
private _linesCacheTimeoutId = 0;
|
||||
private _linesCacheDisposables = new MutableDisposable();
|
||||
private _highlightTimeout = this._register(new MutableDisposable<IDisposable>());
|
||||
private _lineCache = this._register(new MutableDisposable<SearchLineCache>());
|
||||
|
||||
private readonly _onDidChangeResults = this._register(new Emitter<{ resultIndex: number, resultCount: number }>());
|
||||
public readonly onDidChangeResults = this._onDidChangeResults.event;
|
||||
// Component instances
|
||||
private _state = new SearchState();
|
||||
private _engine: SearchEngine | undefined;
|
||||
private _decorationManager: DecorationManager | undefined;
|
||||
private _resultTracker = this._register(new SearchResultTracker());
|
||||
|
||||
private readonly _onAfterSearch = this._register(new Emitter<void>());
|
||||
public readonly onAfterSearch = this._onAfterSearch.event;
|
||||
private readonly _onBeforeSearch = this._register(new Emitter<void>());
|
||||
public readonly onBeforeSearch = this._onBeforeSearch.event;
|
||||
|
||||
public get onDidChangeResults(): Event<ISearchResultChangeEvent> {
|
||||
return this._resultTracker.onDidChangeResults;
|
||||
}
|
||||
|
||||
constructor(options?: Partial<ISearchAddonOptions>) {
|
||||
super();
|
||||
|
||||
this._highlightLimit = options?.highlightLimit ?? DEFAULT_HIGHLIGHT_LIMIT;
|
||||
this._highlightLimit = options?.highlightLimit ?? Constants.DEFAULT_HIGHLIGHT_LIMIT;
|
||||
}
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this._terminal = terminal;
|
||||
this._lineCache.value = new SearchLineCache(terminal);
|
||||
this._engine = new SearchEngine(terminal, this._lineCache.value);
|
||||
this._decorationManager = new DecorationManager(terminal);
|
||||
this._register(this._terminal.onWriteParsed(() => this._updateMatches()));
|
||||
this._register(this._terminal.onResize(() => this._updateMatches()));
|
||||
this._register(toDisposable(() => this.clearDecorations()));
|
||||
}
|
||||
|
||||
private _updateMatches(): void {
|
||||
if (this._highlightTimeout) {
|
||||
window.clearTimeout(this._highlightTimeout);
|
||||
}
|
||||
if (this._cachedSearchTerm && this._lastSearchOptions?.decorations) {
|
||||
this._highlightTimeout = setTimeout(() => {
|
||||
const term = this._cachedSearchTerm;
|
||||
this._cachedSearchTerm = undefined;
|
||||
this.findPrevious(term!, { ...this._lastSearchOptions, incremental: true, noScroll: true });
|
||||
this._highlightTimeout.clear();
|
||||
if (this._state.cachedSearchTerm && this._state.lastSearchOptions?.decorations) {
|
||||
this._highlightTimeout.value = disposableTimeout(() => {
|
||||
const term = this._state.cachedSearchTerm;
|
||||
this._state.clearCachedTerm();
|
||||
this.findPrevious(term!, { ...this._state.lastSearchOptions, incremental: true }, { noScroll: true });
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
public clearDecorations(retainCachedSearchTerm?: boolean): void {
|
||||
this._selectedDecoration.clear();
|
||||
dispose(this._highlightDecorations);
|
||||
this._highlightDecorations = [];
|
||||
this._highlightedLines.clear();
|
||||
this._resultTracker.clearSelectedDecoration();
|
||||
this._decorationManager?.clearHighlightDecorations();
|
||||
this._resultTracker.clearResults();
|
||||
if (!retainCachedSearchTerm) {
|
||||
this._cachedSearchTerm = undefined;
|
||||
this._state.clearCachedTerm();
|
||||
}
|
||||
}
|
||||
|
||||
public clearActiveDecoration(): void {
|
||||
this._selectedDecoration.clear();
|
||||
this._resultTracker.clearSelectedDecoration();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,174 +98,78 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
||||
* @param searchOptions Search options.
|
||||
* @returns Whether a result was found.
|
||||
*/
|
||||
public findNext(term: string, searchOptions?: ISearchOptions): boolean {
|
||||
if (!this._terminal) {
|
||||
public findNext(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
||||
if (!this._terminal || !this._engine) {
|
||||
throw new Error('Cannot use addon until it has been loaded');
|
||||
}
|
||||
const didOptionsChanged = this._lastSearchOptions ? this._didOptionsChange(this._lastSearchOptions, searchOptions) : true;
|
||||
this._lastSearchOptions = searchOptions;
|
||||
if (searchOptions?.decorations) {
|
||||
if (this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm || didOptionsChanged) {
|
||||
this._highlightAllMatches(term, searchOptions);
|
||||
}
|
||||
|
||||
this._onBeforeSearch.fire();
|
||||
|
||||
this._state.lastSearchOptions = searchOptions;
|
||||
|
||||
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
|
||||
this._highlightAllMatches(term, searchOptions!);
|
||||
}
|
||||
|
||||
const found = this._findNextAndSelect(term, searchOptions);
|
||||
const found = this._findNextAndSelect(term, searchOptions, internalSearchOptions);
|
||||
this._fireResults(searchOptions);
|
||||
this._cachedSearchTerm = term;
|
||||
this._state.cachedSearchTerm = term;
|
||||
|
||||
this._onAfterSearch.fire();
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {
|
||||
if (!this._terminal) {
|
||||
if (!this._terminal || !this._engine || !this._decorationManager) {
|
||||
throw new Error('Cannot use addon until it has been loaded');
|
||||
}
|
||||
if (!term || term.length === 0) {
|
||||
if (!this._state.isValidSearchTerm(term)) {
|
||||
this.clearDecorations();
|
||||
return;
|
||||
}
|
||||
searchOptions = searchOptions || {};
|
||||
|
||||
// new search, clear out the old decorations
|
||||
this.clearDecorations(true);
|
||||
|
||||
const searchResultsWithHighlight: ISearchResult[] = [];
|
||||
const results: ISearchResult[] = [];
|
||||
let prevResult: ISearchResult | undefined = undefined;
|
||||
let result = this._find(term, 0, 0, searchOptions);
|
||||
let result = this._engine.find(term, 0, 0, searchOptions);
|
||||
|
||||
while (result && (prevResult?.row !== result.row || prevResult?.col !== result.col)) {
|
||||
if (searchResultsWithHighlight.length >= this._highlightLimit) {
|
||||
if (results.length >= this._highlightLimit) {
|
||||
break;
|
||||
}
|
||||
prevResult = result;
|
||||
searchResultsWithHighlight.push(prevResult);
|
||||
result = this._find(
|
||||
results.push(prevResult);
|
||||
result = this._engine.find(
|
||||
term,
|
||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
|
||||
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
|
||||
searchOptions
|
||||
);
|
||||
}
|
||||
for (const match of searchResultsWithHighlight) {
|
||||
const decorations = this._createResultDecorations(match, searchOptions.decorations!, false);
|
||||
if (decorations) {
|
||||
for (const decoration of decorations) {
|
||||
this._storeDecoration(decoration, match);
|
||||
}
|
||||
}
|
||||
|
||||
this._resultTracker.updateResults(results, this._highlightLimit);
|
||||
if (searchOptions.decorations) {
|
||||
this._decorationManager.createHighlightDecorations(results, searchOptions.decorations);
|
||||
}
|
||||
}
|
||||
|
||||
private _storeDecoration(decoration: IDecoration, match: ISearchResult): void {
|
||||
this._highlightedLines.add(decoration.marker.line);
|
||||
this._highlightDecorations.push({ decoration, match, dispose() { decoration.dispose(); } });
|
||||
}
|
||||
|
||||
private _find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
|
||||
if (!this._terminal || !term || term.length === 0) {
|
||||
this._terminal?.clearSelection();
|
||||
this.clearDecorations();
|
||||
return undefined;
|
||||
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
||||
if (!this._terminal || !this._engine) {
|
||||
return false;
|
||||
}
|
||||
if (startCol > this._terminal.cols) {
|
||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
||||
}
|
||||
|
||||
let result: ISearchResult | undefined = undefined;
|
||||
|
||||
this._initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
// Search startRow
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
// If the current line is wrapped line, increase index of column to ignore the previous scan
|
||||
// Otherwise, reset beginning column index to zero with set new unwrapped line index
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions): boolean {
|
||||
if (!this._terminal || !term || term.length === 0) {
|
||||
this._terminal?.clearSelection();
|
||||
if (!this._state.isValidSearchTerm(term)) {
|
||||
this._terminal.clearSelection();
|
||||
this.clearDecorations();
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startCol = 0;
|
||||
let startRow = 0;
|
||||
if (prevSelectedPos) {
|
||||
if (this._cachedSearchTerm === term) {
|
||||
startCol = prevSelectedPos.end.x;
|
||||
startRow = prevSelectedPos.end.y;
|
||||
} else {
|
||||
startCol = prevSelectedPos.start.x;
|
||||
startRow = prevSelectedPos.start.y;
|
||||
}
|
||||
}
|
||||
|
||||
this._initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
// If the current line is wrapped line, increase index of column to ignore the previous scan
|
||||
// Otherwise, reset beginning column index to zero with set new unwrapped line index
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the bottom and didn't search from the very top wrap back up
|
||||
if (!result && startRow !== 0) {
|
||||
for (let y = 0; y < startRow; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is only one result, wrap back and return selection if it exists.
|
||||
if (!result && prevSelectedPos) {
|
||||
searchPosition.startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
}
|
||||
|
||||
// Set selection and scroll if a result was found
|
||||
return this._selectResult(result, searchOptions?.decorations, searchOptions?.noScroll);
|
||||
const result = this._engine.findNextWithSelection(term, searchOptions, this._state.cachedSearchTerm);
|
||||
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the previous instance of the term, then scroll to and select it. If it
|
||||
* doesn't exist, do nothing.
|
||||
@@ -310,357 +177,44 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
||||
* @param searchOptions Search options.
|
||||
* @returns Whether a result was found.
|
||||
*/
|
||||
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean {
|
||||
if (!this._terminal) {
|
||||
public findPrevious(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
||||
if (!this._terminal || !this._engine) {
|
||||
throw new Error('Cannot use addon until it has been loaded');
|
||||
}
|
||||
const didOptionsChanged = this._lastSearchOptions ? this._didOptionsChange(this._lastSearchOptions, searchOptions) : true;
|
||||
this._lastSearchOptions = searchOptions;
|
||||
if (searchOptions?.decorations) {
|
||||
if (this._cachedSearchTerm === undefined || term !== this._cachedSearchTerm || didOptionsChanged) {
|
||||
this._highlightAllMatches(term, searchOptions);
|
||||
}
|
||||
|
||||
this._onBeforeSearch.fire();
|
||||
|
||||
this._state.lastSearchOptions = searchOptions;
|
||||
|
||||
if (this._state.shouldUpdateHighlighting(term, searchOptions)) {
|
||||
this._highlightAllMatches(term, searchOptions!);
|
||||
}
|
||||
|
||||
const found = this._findPreviousAndSelect(term, searchOptions);
|
||||
const found = this._findPreviousAndSelect(term, searchOptions, internalSearchOptions);
|
||||
this._fireResults(searchOptions);
|
||||
this._cachedSearchTerm = term;
|
||||
this._state.cachedSearchTerm = term;
|
||||
|
||||
this._onAfterSearch.fire();
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private _didOptionsChange(lastSearchOptions: ISearchOptions, searchOptions?: ISearchOptions): boolean {
|
||||
if (!searchOptions) {
|
||||
private _fireResults(searchOptions?: ISearchOptions): void {
|
||||
this._resultTracker.fireResultsChanged(!!searchOptions?.decorations);
|
||||
}
|
||||
|
||||
private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions, internalSearchOptions?: IInternalSearchOptions): boolean {
|
||||
if (!this._terminal || !this._engine) {
|
||||
return false;
|
||||
}
|
||||
if (lastSearchOptions.caseSensitive !== searchOptions.caseSensitive) {
|
||||
return true;
|
||||
}
|
||||
if (lastSearchOptions.regex !== searchOptions.regex) {
|
||||
return true;
|
||||
}
|
||||
if (lastSearchOptions.wholeWord !== searchOptions.wholeWord) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _fireResults(searchOptions?: ISearchOptions): void {
|
||||
if (searchOptions?.decorations) {
|
||||
let resultIndex = -1;
|
||||
if (this._selectedDecoration.value) {
|
||||
const selectedMatch = this._selectedDecoration.value.match;
|
||||
for (let i = 0; i < this._highlightDecorations.length; i++) {
|
||||
const match = this._highlightDecorations[i].match;
|
||||
if (match.row === selectedMatch.row && match.col === selectedMatch.col && match.size === selectedMatch.size) {
|
||||
resultIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._onDidChangeResults.fire({ resultIndex, resultCount: this._highlightDecorations.length });
|
||||
}
|
||||
}
|
||||
|
||||
private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions): boolean {
|
||||
if (!this._terminal) {
|
||||
throw new Error('Cannot use addon until it has been loaded');
|
||||
}
|
||||
if (!this._terminal || !term || term.length === 0) {
|
||||
this._terminal?.clearSelection();
|
||||
if (!this._state.isValidSearchTerm(term)) {
|
||||
this._terminal.clearSelection();
|
||||
this.clearDecorations();
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
||||
let startCol = this._terminal.cols;
|
||||
const isReverseSearch = true;
|
||||
|
||||
this._initLinesCache();
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
let result: ISearchResult | undefined;
|
||||
if (prevSelectedPos) {
|
||||
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = startCol = prevSelectedPos.start.x;
|
||||
if (this._cachedSearchTerm !== term) {
|
||||
// Try to expand selection to right first.
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false);
|
||||
if (!result) {
|
||||
// If selection was not able to be expanded to the right, then try reverse search
|
||||
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
||||
searchPosition.startCol = startCol = prevSelectedPos.end.x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
}
|
||||
|
||||
// Search from startRow - 1 to top
|
||||
if (!result) {
|
||||
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
|
||||
for (let y = startRow - 1; y >= 0; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the top and didn't search from the very bottom wrap back down
|
||||
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
|
||||
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set selection and scroll if a result was found
|
||||
return this._selectResult(result, searchOptions?.decorations, searchOptions?.noScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a line cache with a ttl
|
||||
*/
|
||||
private _initLinesCache(): void {
|
||||
const terminal = this._terminal!;
|
||||
if (!this._linesCache) {
|
||||
this._linesCache = new Array(terminal.buffer.active.length);
|
||||
this._linesCacheDisposables.value = combinedDisposable(
|
||||
terminal.onLineFeed(() => this._destroyLinesCache()),
|
||||
terminal.onCursorMove(() => this._destroyLinesCache()),
|
||||
terminal.onResize(() => this._destroyLinesCache())
|
||||
);
|
||||
}
|
||||
|
||||
window.clearTimeout(this._linesCacheTimeoutId);
|
||||
this._linesCacheTimeoutId = window.setTimeout(() => this._destroyLinesCache(), LINES_CACHE_TIME_TO_LIVE);
|
||||
}
|
||||
|
||||
private _destroyLinesCache(): void {
|
||||
this._linesCache = undefined;
|
||||
this._linesCacheDisposables.clear();
|
||||
if (this._linesCacheTimeoutId) {
|
||||
window.clearTimeout(this._linesCacheTimeoutId);
|
||||
this._linesCacheTimeoutId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A found substring is a whole word if it doesn't have an alphanumeric character directly
|
||||
* adjacent to it.
|
||||
* @param searchIndex starting indext of the potential whole word substring
|
||||
* @param line entire string in which the potential whole word was found
|
||||
* @param term the substring that starts at searchIndex
|
||||
*/
|
||||
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
|
||||
return ((searchIndex === 0) || (NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
|
||||
(((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
|
||||
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
|
||||
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
|
||||
* be properly searched when the terminal line that the text starts on is searched.
|
||||
* @param term The search term.
|
||||
* @param searchPosition The position to start the search.
|
||||
* @param searchOptions Search options.
|
||||
* @param isReverseSearch Whether the search should start from the right side of the terminal and
|
||||
* search to the left.
|
||||
* @returns The search result if it was found.
|
||||
*/
|
||||
protected _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
|
||||
const terminal = this._terminal!;
|
||||
const row = searchPosition.startRow;
|
||||
const col = searchPosition.startCol;
|
||||
|
||||
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
|
||||
const firstLine = terminal.buffer.active.getLine(row);
|
||||
if (firstLine?.isWrapped) {
|
||||
if (isReverseSearch) {
|
||||
searchPosition.startCol += terminal.cols;
|
||||
return;
|
||||
}
|
||||
|
||||
// This will iterate until we find the line start.
|
||||
// When we find it, we will search using the calculated start column.
|
||||
searchPosition.startRow--;
|
||||
searchPosition.startCol += terminal.cols;
|
||||
return this._findInLine(term, searchPosition, searchOptions);
|
||||
}
|
||||
let cache = this._linesCache?.[row];
|
||||
if (!cache) {
|
||||
cache = this._translateBufferLineToStringWithWrap(row, true);
|
||||
if (this._linesCache) {
|
||||
this._linesCache[row] = cache;
|
||||
}
|
||||
}
|
||||
const [stringLine, offsets] = cache;
|
||||
|
||||
const offset = this._bufferColsToStringOffset(row, col);
|
||||
let searchTerm = term;
|
||||
let searchStringLine = stringLine;
|
||||
if (!searchOptions.regex) {
|
||||
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
|
||||
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (searchOptions.regex) {
|
||||
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
|
||||
let foundTerm: RegExpExecArray | null;
|
||||
if (isReverseSearch) {
|
||||
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
|
||||
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
|
||||
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
term = foundTerm[0];
|
||||
searchRegex.lastIndex -= (term.length - 1);
|
||||
}
|
||||
} else {
|
||||
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
|
||||
if (foundTerm && foundTerm[0].length > 0) {
|
||||
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
|
||||
term = foundTerm[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isReverseSearch) {
|
||||
if (offset - searchTerm.length >= 0) {
|
||||
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
|
||||
}
|
||||
} else {
|
||||
resultIndex = searchStringLine.indexOf(searchTerm, offset);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultIndex >= 0) {
|
||||
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
||||
// rows
|
||||
let startRowOffset = 0;
|
||||
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
|
||||
startRowOffset++;
|
||||
}
|
||||
let endRowOffset = startRowOffset;
|
||||
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
|
||||
endRowOffset++;
|
||||
}
|
||||
const startColOffset = resultIndex - offsets[startRowOffset];
|
||||
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
|
||||
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
|
||||
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
|
||||
const size = endColIndex - startColIndex + terminal.cols * (endRowOffset - startRowOffset);
|
||||
|
||||
return {
|
||||
term,
|
||||
col: startColIndex,
|
||||
row: row + startRowOffset,
|
||||
size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _stringLengthToBufferSize(row: number, offset: number): number {
|
||||
const line = this._terminal!.buffer.active.getLine(row);
|
||||
if (!line) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
// Adjust the searchIndex to normalize emoji into single chars
|
||||
const char = cell.getChars();
|
||||
if (char.length > 1) {
|
||||
offset -= char.length - 1;
|
||||
}
|
||||
// Adjust the searchIndex for empty characters following wide unicode
|
||||
// chars (eg. CJK)
|
||||
const nextCell = line.getCell(i + 1);
|
||||
if (nextCell && nextCell.getWidth() === 0) {
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private _bufferColsToStringOffset(startRow: number, cols: number): number {
|
||||
const terminal = this._terminal!;
|
||||
let lineIndex = startRow;
|
||||
let offset = 0;
|
||||
let line = terminal.buffer.active.getLine(lineIndex);
|
||||
while (cols > 0 && line) {
|
||||
for (let i = 0; i < cols && i < terminal.cols; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
if (cell.getWidth()) {
|
||||
// Treat null characters as whitespace to align with the translateToString API
|
||||
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
|
||||
}
|
||||
}
|
||||
lineIndex++;
|
||||
line = terminal.buffer.active.getLine(lineIndex);
|
||||
if (line && !line.isWrapped) {
|
||||
break;
|
||||
}
|
||||
cols -= terminal.cols;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a buffer line to a string, including subsequent lines if they are wraps.
|
||||
* Wide characters will count as two columns in the resulting string. This
|
||||
* function is useful for getting the actual text underneath the raw selection
|
||||
* position.
|
||||
* @param lineIndex The index of the line being translated.
|
||||
* @param trimRight Whether to trim whitespace to the right.
|
||||
*/
|
||||
private _translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
||||
const terminal = this._terminal!;
|
||||
const strings = [];
|
||||
const lineOffsets = [0];
|
||||
let line = terminal.buffer.active.getLine(lineIndex);
|
||||
while (line) {
|
||||
const nextLine = terminal.buffer.active.getLine(lineIndex + 1);
|
||||
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
|
||||
let string = line.translateToString(!lineWrapsToNext && trimRight);
|
||||
if (lineWrapsToNext && nextLine) {
|
||||
const lastCell = line.getCell(line.length - 1);
|
||||
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
|
||||
// a wide character wrapped to the next line
|
||||
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
|
||||
string = string.slice(0, -1);
|
||||
}
|
||||
}
|
||||
strings.push(string);
|
||||
if (lineWrapsToNext) {
|
||||
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
lineIndex++;
|
||||
line = nextLine;
|
||||
}
|
||||
return [strings.join(''), lineOffsets];
|
||||
const result = this._engine.findPreviousWithSelection(term, searchOptions, this._state.cachedSearchTerm);
|
||||
return this._selectResult(result, searchOptions?.decorations, internalSearchOptions?.noScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -669,95 +223,32 @@ export class SearchAddon extends Disposable implements ITerminalAddon , ISearchA
|
||||
* @returns Whether a result was selected.
|
||||
*/
|
||||
private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {
|
||||
const terminal = this._terminal!;
|
||||
this._selectedDecoration.clear();
|
||||
if (!result) {
|
||||
terminal.clearSelection();
|
||||
if (!this._terminal || !this._decorationManager) {
|
||||
return false;
|
||||
}
|
||||
terminal.select(result.col, result.row, result.size);
|
||||
|
||||
this._resultTracker.clearSelectedDecoration();
|
||||
if (!result) {
|
||||
this._terminal.clearSelection();
|
||||
return false;
|
||||
}
|
||||
|
||||
this._terminal.select(result.col, result.row, result.size);
|
||||
if (options) {
|
||||
const decorations = this._createResultDecorations(result, options, true);
|
||||
if (decorations) {
|
||||
this._selectedDecoration.value = { decorations, match: result, dispose() { dispose(decorations); } };
|
||||
const activeDecoration = this._decorationManager.createActiveDecoration(result, options);
|
||||
if (activeDecoration) {
|
||||
this._resultTracker.selectedDecoration = activeDecoration;
|
||||
}
|
||||
}
|
||||
|
||||
if (!noScroll) {
|
||||
// If it is not in the viewport then we scroll else it just gets selected
|
||||
if (result.row >= (terminal.buffer.active.viewportY + terminal.rows) || result.row < terminal.buffer.active.viewportY) {
|
||||
let scroll = result.row - terminal.buffer.active.viewportY;
|
||||
scroll -= Math.floor(terminal.rows / 2);
|
||||
terminal.scrollLines(scroll);
|
||||
if (result.row >= (this._terminal.buffer.active.viewportY + this._terminal.rows) || result.row < this._terminal.buffer.active.viewportY) {
|
||||
let scroll = result.row - this._terminal.buffer.active.viewportY;
|
||||
scroll -= Math.floor(this._terminal.rows / 2);
|
||||
this._terminal.scrollLines(scroll);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies styles to the decoration when it is rendered.
|
||||
* @param element The decoration's element.
|
||||
* @param borderColor The border color to apply.
|
||||
* @param isActiveResult Whether the element is part of the active search result.
|
||||
* @returns
|
||||
*/
|
||||
private _applyStyles(element: HTMLElement, borderColor: string | undefined, isActiveResult: boolean): void {
|
||||
if (!element.classList.contains('xterm-find-result-decoration')) {
|
||||
element.classList.add('xterm-find-result-decoration');
|
||||
if (borderColor) {
|
||||
element.style.outline = `1px solid ${borderColor}`;
|
||||
}
|
||||
}
|
||||
if (isActiveResult) {
|
||||
element.classList.add('xterm-find-active-result-decoration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a decoration for the result and applies styles
|
||||
* @param result the search result for which to create the decoration
|
||||
* @param options the options for the decoration
|
||||
* @returns the {@link IDecoration} or undefined if the marker has already been disposed of
|
||||
*/
|
||||
private _createResultDecorations(result: ISearchResult, options: ISearchDecorationOptions, isActiveResult: boolean): IDecoration[] | undefined {
|
||||
const terminal = this._terminal!;
|
||||
|
||||
// Gather decoration ranges for this match as it could wrap
|
||||
const decorationRanges: [number, number, number][] = [];
|
||||
let currentCol = result.col;
|
||||
let remainingSize = result.size;
|
||||
let markerOffset = -terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row;
|
||||
while (remainingSize > 0) {
|
||||
const amountThisRow = Math.min(terminal.cols - currentCol, remainingSize);
|
||||
decorationRanges.push([markerOffset, currentCol, amountThisRow]);
|
||||
currentCol = 0;
|
||||
remainingSize -= amountThisRow;
|
||||
markerOffset++;
|
||||
}
|
||||
|
||||
// Create the decorations
|
||||
const decorations: IDecoration[] = [];
|
||||
for (const range of decorationRanges) {
|
||||
const marker = terminal.registerMarker(range[0]);
|
||||
const decoration = terminal.registerDecoration({
|
||||
marker,
|
||||
x: range[1],
|
||||
width: range[2],
|
||||
backgroundColor: isActiveResult ? options.activeMatchBackground : options.matchBackground,
|
||||
overviewRulerOptions: this._highlightedLines.has(marker.line) ? undefined : {
|
||||
color: isActiveResult ? options.activeMatchColorOverviewRuler : options.matchOverviewRuler,
|
||||
position: 'center'
|
||||
}
|
||||
});
|
||||
if (decoration) {
|
||||
const disposables: IDisposable[] = [];
|
||||
disposables.push(marker);
|
||||
disposables.push(decoration.onRender((e) => this._applyStyles(e, isActiveResult ? options.activeMatchBorder : options.matchBorder, false)));
|
||||
disposables.push(decoration.onDispose(() => dispose(disposables)));
|
||||
decorations.push(decoration);
|
||||
}
|
||||
}
|
||||
|
||||
return decorations.length === 0 ? undefined : decorations;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchEngine } from './SearchEngine';
|
||||
import { SearchLineCache } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
describe('SearchEngine', () => {
|
||||
let store: DisposableStore;
|
||||
let terminal: Terminal;
|
||||
let lineCache: SearchLineCache;
|
||||
let searchEngine: SearchEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new DisposableStore();
|
||||
terminal = store.add(new Terminal({ cols: 80, rows: 24 }));
|
||||
lineCache = store.add(new SearchLineCache(terminal));
|
||||
searchEngine = new SearchEngine(terminal, lineCache);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.dispose();
|
||||
});
|
||||
|
||||
describe('find', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should find basic text in terminal content', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text starting from specified position', async () => {
|
||||
await writeP(terminal, 'Hello Hello Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 7), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should search across multiple rows', async () => {
|
||||
await writeP(terminal, 'Line 1\r\nLine 2 target\r\nLine 3');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 7,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when text is not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('NotFound', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should throw error for invalid column position', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.throws(() => {
|
||||
searchEngine.find('Hello', 0, 100);
|
||||
}, /Invalid col: 100 to search in terminal of 80 cols/);
|
||||
});
|
||||
|
||||
it('should handle search starting from last column', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('Hello', 0, 79), undefined);
|
||||
});
|
||||
|
||||
it('should handle search from middle of match', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('llo', 0, 3), undefined); // Should not find partial match that starts before search position
|
||||
});
|
||||
});
|
||||
|
||||
describe('search options', () => {
|
||||
describe('caseSensitive', () => {
|
||||
it('should find text with case-insensitive search (default)', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text with case-sensitive search when enabled', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { caseSensitive: true }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not find text with case-sensitive search when case differs', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { caseSensitive: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wholeWord', () => {
|
||||
it('should find whole word when enabled', async () => {
|
||||
await writeP(terminal, 'Hello world wonderful');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not find partial word when wholeWord is enabled', async () => {
|
||||
await writeP(terminal, 'Hello wonderful');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
||||
});
|
||||
|
||||
it('should find word at beginning of line with wholeWord', async () => {
|
||||
await writeP(terminal, 'world is great');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find word at end of line with wholeWord', async () => {
|
||||
await writeP(terminal, 'hello world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle word boundaries with punctuation', async () => {
|
||||
await writeP(terminal, 'hello,world!test');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), {
|
||||
term: 'world',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should not match when not whole word', async () => {
|
||||
await writeP(terminal, 'helloworld');
|
||||
|
||||
assert.strictEqual(searchEngine.find('world', 0, 0, { wholeWord: true }), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex', () => {
|
||||
it('should find text using simple regex pattern', async () => {
|
||||
await writeP(terminal, 'Hello 123 World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[0-9]+', 0, 0, { regex: true }), {
|
||||
term: '123',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text using regex with case-insensitive flag', async () => {
|
||||
await writeP(terminal, 'Hello WORLD');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('world', 0, 0, { regex: true, caseSensitive: false }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find text using regex with case-sensitive flag', async () => {
|
||||
await writeP(terminal, 'Hello WORLD world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('WORLD', 0, 0, { regex: true, caseSensitive: true }), {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex regex patterns', async () => {
|
||||
await writeP(terminal, 'Email: test@example.com and another@domain.org');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', 0, 0, { regex: true }), {
|
||||
term: 'test@example.com',
|
||||
col: 7,
|
||||
row: 0,
|
||||
size: 16
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for invalid regex pattern', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Invalid regex should be handled gracefully
|
||||
assert.throws(() => {
|
||||
searchEngine.find('[invalid', 0, 0, { regex: true });
|
||||
}, /Invalid regular expression/);
|
||||
});
|
||||
|
||||
it('should handle empty regex matches', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.find('.*?', 0, 0, { regex: true }), undefined); // Empty matches should be ignored
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined options', () => {
|
||||
it('should handle regex + caseSensitive combination', async () => {
|
||||
await writeP(terminal, 'Hello WORLD world');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('[A-Z]+', 0, 0, { regex: true, caseSensitive: true }), {
|
||||
term: 'H',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle wholeWord + caseSensitive combination', async () => {
|
||||
await writeP(terminal, 'Hello WORLD wonderful');
|
||||
|
||||
const result1 = searchEngine.find('WORLD', 0, 0, { wholeWord: true, caseSensitive: true });
|
||||
assert.deepStrictEqual(result1, {
|
||||
term: 'WORLD',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
|
||||
const result2 = searchEngine.find('world', 0, 0, { wholeWord: true, caseSensitive: true });
|
||||
assert.strictEqual(result2, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextWithSelection', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findNextWithSelection(''), undefined);
|
||||
});
|
||||
|
||||
it('should find first occurrence when no selection exists', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find next occurrence after current selection', async () => {
|
||||
await writeP(terminal, 'Hello World Hello Again');
|
||||
|
||||
// Mock the getSelectionPosition to return a selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap around to beginning when reaching end', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at the end
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello', undefined, 'Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
}); // Should wrap to first occurrence
|
||||
});
|
||||
|
||||
it('should wrap across multiple rows', async () => {
|
||||
await writeP(terminal, 'Line 1 test\r\nLine 2\r\nLine 3 test');
|
||||
|
||||
// Mock selection at first "test"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 7, y: 0 }, end: { x: 11, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('test', undefined, 'test'), {
|
||||
term: 'test',
|
||||
col: 7,
|
||||
row: 2,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
it('should return same selection if only one match exists', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Mock selection at "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findNextWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear selection and return undefined when term not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findNextWithSelection('NotFound'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPreviousWithSelection', () => {
|
||||
it('should return undefined for empty search term', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection(''), undefined);
|
||||
});
|
||||
|
||||
it('should find last occurrence when no selection exists', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.findPreviousWithSelection('Hello'), {
|
||||
term: 'Hello',
|
||||
col: 12,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should find previous occurrence before current selection', async () => {
|
||||
await writeP(terminal, 'Hello World Hello Again');
|
||||
|
||||
// Mock selection at second "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 12, y: 0 }, end: { x: 17, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// It may find the same selection first due to expansion attempt
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should wrap around to end when reaching beginning', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// Due to the expansion attempt, it may find the same Hello first
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should work across multiple rows in reverse', async () => {
|
||||
await writeP(terminal, 'test Line 1\r\nLine 2\r\ntest Line 3');
|
||||
|
||||
// Mock selection at last "test"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 2 }, end: { x: 4, y: 2 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('test');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// The algorithm will find the current selection first due to expansion attempt
|
||||
assert.strictEqual(typeof result!.row, 'number');
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
});
|
||||
|
||||
it('should handle selection expansion correctly', async () => {
|
||||
await writeP(terminal, 'Hello World Hello');
|
||||
|
||||
// Mock selection at first "Hello"
|
||||
terminal.getSelectionPosition = () => ({ start: { x: 0, y: 0 }, end: { x: 5, y: 0 } });
|
||||
|
||||
const result = searchEngine.findPreviousWithSelection('Hello');
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// The algorithm tries expansion first, so it may find the same Hello
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
assert.strictEqual(result!.row, 0);
|
||||
});
|
||||
|
||||
it('should clear selection and return undefined when term not found', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.strictEqual(searchEngine.findPreviousWithSelection('NotFound'), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
describe('unicode and special characters', () => {
|
||||
it('should handle unicode characters correctly', async () => {
|
||||
await writeP(terminal, 'Hello 世界 World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('世界', 0, 0), {
|
||||
term: '世界',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
it('should handle wide characters', async () => {
|
||||
await writeP(terminal, '中文测试');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('测试', 0, 0), {
|
||||
term: '测试',
|
||||
col: 4,
|
||||
row: 0,
|
||||
size: 4
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('wrapped lines', () => {
|
||||
it('should handle search across wrapped lines', async () => {
|
||||
const longText = 'A'.repeat(100) + 'target' + 'B'.repeat(50);
|
||||
await writeP(terminal, longText);
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 20,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle wrapped lines with unicode', async () => {
|
||||
const longText = '中'.repeat(50) + 'target' + '文'.repeat(30);
|
||||
await writeP(terminal, longText);
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 20,
|
||||
row: 1,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip wrapped lines correctly in findInLine', async () => {
|
||||
const longText = 'A'.repeat(200);
|
||||
await writeP(terminal, longText + '\r\nNext line with target');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('target', 0, 0), {
|
||||
term: 'target',
|
||||
col: 15,
|
||||
row: 3,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer boundaries', () => {
|
||||
|
||||
|
||||
it('should handle empty buffer gracefully', () => {
|
||||
assert.strictEqual(searchEngine.find('anything', 0, 0), undefined);
|
||||
});
|
||||
|
||||
it('should handle search beyond buffer size', () => {
|
||||
assert.strictEqual(searchEngine.find('test', 1000, 0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('should handle undefined search options gracefully', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, undefined), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle negative start positions', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', -1, -1), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search options with undefined properties', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
const options: ISearchOptions = {
|
||||
caseSensitive: undefined,
|
||||
regex: undefined,
|
||||
wholeWord: undefined
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('Hello', 0, 0, options), {
|
||||
term: 'Hello',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private method behaviors (tested indirectly)', () => {
|
||||
describe('_isWholeWord behavior', () => {
|
||||
it('should recognize word boundaries with various punctuation', async () => {
|
||||
await writeP(terminal, 'word1 word2,word3(word4)word5[word6]word7{word8}');
|
||||
|
||||
const tests = [
|
||||
{ term: 'word1', expected: true },
|
||||
{ term: 'word2', expected: true },
|
||||
{ term: 'word3', expected: true },
|
||||
{ term: 'word4', expected: true },
|
||||
{ term: 'word5', expected: true },
|
||||
{ term: 'word6', expected: true },
|
||||
{ term: 'word7', expected: true },
|
||||
{ term: 'word8', expected: true }
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const result = searchEngine.find(test.term, 0, 0, { wholeWord: true });
|
||||
if (test.expected) {
|
||||
assert.notStrictEqual(result, undefined, `Should find whole word: ${test.term}`);
|
||||
} else {
|
||||
assert.strictEqual(result, undefined, `Should not find non-whole word: ${test.term}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle word boundaries at line start and end', async () => {
|
||||
await writeP(terminal, 'start middle end');
|
||||
|
||||
const startResult = searchEngine.find('start', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(startResult, {
|
||||
term: 'start',
|
||||
col: 0,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
|
||||
const endResult = searchEngine.find('end', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(endResult, {
|
||||
term: 'end',
|
||||
col: 13,
|
||||
row: 0,
|
||||
size: 3
|
||||
});
|
||||
|
||||
const middleResult = searchEngine.find('middle', 0, 0, { wholeWord: true });
|
||||
assert.deepStrictEqual(middleResult, {
|
||||
term: 'middle',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buffer offset calculations', () => {
|
||||
it('should handle wide character offset calculations', async () => {
|
||||
await writeP(terminal, '中文 test 测试');
|
||||
|
||||
const result = searchEngine.find('test', 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
assert.strictEqual(result!.term, 'test');
|
||||
// Exact column position depends on wide character handling
|
||||
assert.strictEqual(typeof result!.col, 'number');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
});
|
||||
|
||||
describe('string to buffer size conversions', () => {
|
||||
it('should correctly calculate size for simple text', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly calculate size for unicode text', async () => {
|
||||
await writeP(terminal, 'Hello 世界');
|
||||
|
||||
const result = searchEngine.find('世界', 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
// Size should account for wide characters
|
||||
assert.strictEqual(typeof result!.size, 'number');
|
||||
assert.strictEqual(result!.size >= 2, true);
|
||||
});
|
||||
|
||||
it('should handle size calculation across wrapped lines', async () => {
|
||||
const longMatch = 'A'.repeat(100);
|
||||
await writeP(terminal, longMatch);
|
||||
|
||||
const result = searchEngine.find(longMatch, 0, 0);
|
||||
assert.notStrictEqual(result, undefined);
|
||||
assert.strictEqual(result!.size >= 100, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with SearchLineCache', () => {
|
||||
it('should use cache for line translation', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Initialize cache
|
||||
lineCache.initLinesCache();
|
||||
|
||||
const result1 = searchEngine.find('World', 0, 0);
|
||||
const result2 = searchEngine.find('World', 0, 0);
|
||||
|
||||
assert.notStrictEqual(result1, undefined);
|
||||
assert.notStrictEqual(result2, undefined);
|
||||
assert.deepStrictEqual(result1, result2);
|
||||
});
|
||||
|
||||
it('should handle cache misses gracefully', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
|
||||
// Don't initialize cache
|
||||
assert.deepStrictEqual(searchEngine.find('World', 0, 0), {
|
||||
term: 'World',
|
||||
col: 6,
|
||||
row: 0,
|
||||
size: 5
|
||||
});
|
||||
});
|
||||
|
||||
it('should work correctly with cache invalidation', async () => {
|
||||
await writeP(terminal, 'Initial text');
|
||||
lineCache.initLinesCache();
|
||||
|
||||
const result1 = searchEngine.find('Initial', 0, 0);
|
||||
assert.notStrictEqual(result1, undefined);
|
||||
|
||||
// Change terminal content which should invalidate cache
|
||||
await writeP(terminal, '\r\nNew line');
|
||||
|
||||
const result2 = searchEngine.find('New', 0, 0);
|
||||
assert.deepStrictEqual(result2, {
|
||||
term: 'New',
|
||||
col: 0,
|
||||
row: 1,
|
||||
size: 3
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import type { SearchLineCache } from './SearchLineCache';
|
||||
|
||||
/**
|
||||
* Represents the position to start a search from.
|
||||
*/
|
||||
interface ISearchPosition {
|
||||
startCol: number;
|
||||
startRow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a search result with its position and content.
|
||||
*/
|
||||
export interface ISearchResult {
|
||||
term: string;
|
||||
col: number;
|
||||
row: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration constants for the search engine functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Characters that are considered non-word characters for search boundary detection. These
|
||||
* characters are used to determine word boundaries when performing whole-word searches. Includes
|
||||
* common punctuation, symbols, and whitespace characters.
|
||||
*/
|
||||
NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\\;:"\',./<>?'
|
||||
}
|
||||
|
||||
/**
|
||||
* Core search engine that handles finding text within terminal content.
|
||||
* This class is responsible for the actual search algorithms and position calculations.
|
||||
*/
|
||||
export class SearchEngine {
|
||||
constructor(
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _lineCache: SearchLineCache
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find the first occurrence of a term starting from a specific position.
|
||||
* @param term The search term.
|
||||
* @param startRow The row to start searching from.
|
||||
* @param startCol The column to start searching from.
|
||||
* @param searchOptions Search options.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
if (startCol > this._terminal.cols) {
|
||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next occurrence of a term with wrapping and selection management.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @param cachedSearchTerm The cached search term to determine incremental behavior.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public findNextWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startCol = 0;
|
||||
let startRow = 0;
|
||||
if (prevSelectedPos) {
|
||||
if (cachedSearchTerm === term) {
|
||||
startCol = prevSelectedPos.end.x;
|
||||
startRow = prevSelectedPos.end.y;
|
||||
} else {
|
||||
startCol = prevSelectedPos.start.x;
|
||||
startRow = prevSelectedPos.start.y;
|
||||
}
|
||||
}
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
// Search startRow
|
||||
let result = this._findInLine(term, searchPosition, searchOptions);
|
||||
// Search from startRow + 1 to end
|
||||
if (!result) {
|
||||
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the bottom and didn't search from the very top wrap back up
|
||||
if (!result && startRow !== 0) {
|
||||
for (let y = 0; y < startRow; y++) {
|
||||
searchPosition.startRow = y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there is only one result, wrap back and return selection if it exists.
|
||||
if (!result && prevSelectedPos) {
|
||||
searchPosition.startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = 0;
|
||||
result = this._findInLine(term, searchPosition, searchOptions);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the previous occurrence of a term with wrapping and selection management.
|
||||
* @param term The search term.
|
||||
* @param searchOptions Search options.
|
||||
* @param cachedSearchTerm The cached search term to determine if expansion should occur.
|
||||
* @returns The search result if found, undefined otherwise.
|
||||
*/
|
||||
public findPreviousWithSelection(term: string, searchOptions?: ISearchOptions, cachedSearchTerm?: string): ISearchResult | undefined {
|
||||
if (!term || term.length === 0) {
|
||||
this._terminal.clearSelection();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prevSelectedPos = this._terminal.getSelectionPosition();
|
||||
this._terminal.clearSelection();
|
||||
|
||||
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
|
||||
let startCol = this._terminal.cols;
|
||||
const isReverseSearch = true;
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
const searchPosition: ISearchPosition = {
|
||||
startRow,
|
||||
startCol
|
||||
};
|
||||
|
||||
let result: ISearchResult | undefined;
|
||||
if (prevSelectedPos) {
|
||||
searchPosition.startRow = startRow = prevSelectedPos.start.y;
|
||||
searchPosition.startCol = startCol = prevSelectedPos.start.x;
|
||||
if (cachedSearchTerm !== term) {
|
||||
// Try to expand selection to right first.
|
||||
result = this._findInLine(term, searchPosition, searchOptions, false);
|
||||
if (!result) {
|
||||
// If selection was not able to be expanded to the right, then try reverse search
|
||||
searchPosition.startRow = startRow = prevSelectedPos.end.y;
|
||||
searchPosition.startCol = startCol = prevSelectedPos.end.x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
|
||||
// Search from startRow - 1 to top
|
||||
if (!result) {
|
||||
searchPosition.startCol = Math.max(searchPosition.startCol, this._terminal.cols);
|
||||
for (let y = startRow - 1; y >= 0; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we hit the top and didn't search from the very bottom wrap back down
|
||||
if (!result && startRow !== (this._terminal.buffer.active.baseY + this._terminal.rows - 1)) {
|
||||
for (let y = (this._terminal.buffer.active.baseY + this._terminal.rows - 1); y >= startRow; y--) {
|
||||
searchPosition.startRow = y;
|
||||
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
|
||||
if (result) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* A found substring is a whole word if it doesn't have an alphanumeric character directly
|
||||
* adjacent to it.
|
||||
* @param searchIndex starting index of the potential whole word substring
|
||||
* @param line entire string in which the potential whole word was found
|
||||
* @param term the substring that starts at searchIndex
|
||||
*/
|
||||
private _isWholeWord(searchIndex: number, line: string, term: string): boolean {
|
||||
return ((searchIndex === 0) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex - 1]))) &&
|
||||
(((searchIndex + term.length) === line.length) || (Constants.NON_WORD_CHARACTERS.includes(line[searchIndex + term.length])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches a line for a search term. Takes the provided terminal line and searches the text line,
|
||||
* which may contain subsequent terminal lines if the text is wrapped. If the provided line number
|
||||
* is part of a wrapped text line that started on an earlier line then it is skipped since it will
|
||||
* be properly searched when the terminal line that the text starts on is searched.
|
||||
* @param term The search term.
|
||||
* @param searchPosition The position to start the search.
|
||||
* @param searchOptions Search options.
|
||||
* @param isReverseSearch Whether the search should start from the right side of the terminal and
|
||||
* search to the left.
|
||||
* @returns The search result if it was found.
|
||||
*/
|
||||
private _findInLine(term: string, searchPosition: ISearchPosition, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult | undefined {
|
||||
const row = searchPosition.startRow;
|
||||
const col = searchPosition.startCol;
|
||||
|
||||
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
|
||||
const firstLine = this._terminal.buffer.active.getLine(row);
|
||||
if (firstLine?.isWrapped) {
|
||||
if (isReverseSearch) {
|
||||
searchPosition.startCol += this._terminal.cols;
|
||||
return;
|
||||
}
|
||||
|
||||
// This will iterate until we find the line start.
|
||||
// When we find it, we will search using the calculated start column.
|
||||
searchPosition.startRow--;
|
||||
searchPosition.startCol += this._terminal.cols;
|
||||
return this._findInLine(term, searchPosition, searchOptions);
|
||||
}
|
||||
let cache = this._lineCache.getLineFromCache(row);
|
||||
if (!cache) {
|
||||
cache = this._lineCache.translateBufferLineToStringWithWrap(row, true);
|
||||
this._lineCache.setLineInCache(row, cache);
|
||||
}
|
||||
const [stringLine, offsets] = cache;
|
||||
|
||||
const offset = this._bufferColsToStringOffset(row, col);
|
||||
let searchTerm = term;
|
||||
let searchStringLine = stringLine;
|
||||
if (!searchOptions.regex) {
|
||||
searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
|
||||
searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase();
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (searchOptions.regex) {
|
||||
const searchRegex = RegExp(searchTerm, searchOptions.caseSensitive ? 'g' : 'gi');
|
||||
let foundTerm: RegExpExecArray | null;
|
||||
if (isReverseSearch) {
|
||||
// This loop will get the resultIndex of the _last_ regex match in the range 0..offset
|
||||
while (foundTerm = searchRegex.exec(searchStringLine.slice(0, offset))) {
|
||||
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
|
||||
term = foundTerm[0];
|
||||
searchRegex.lastIndex -= (term.length - 1);
|
||||
}
|
||||
} else {
|
||||
foundTerm = searchRegex.exec(searchStringLine.slice(offset));
|
||||
if (foundTerm && foundTerm[0].length > 0) {
|
||||
resultIndex = offset + (searchRegex.lastIndex - foundTerm[0].length);
|
||||
term = foundTerm[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isReverseSearch) {
|
||||
if (offset - searchTerm.length >= 0) {
|
||||
resultIndex = searchStringLine.lastIndexOf(searchTerm, offset - searchTerm.length);
|
||||
}
|
||||
} else {
|
||||
resultIndex = searchStringLine.indexOf(searchTerm, offset);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultIndex >= 0) {
|
||||
if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust the row number and search index if needed since a "line" of text can span multiple
|
||||
// rows
|
||||
let startRowOffset = 0;
|
||||
while (startRowOffset < offsets.length - 1 && resultIndex >= offsets[startRowOffset + 1]) {
|
||||
startRowOffset++;
|
||||
}
|
||||
let endRowOffset = startRowOffset;
|
||||
while (endRowOffset < offsets.length - 1 && resultIndex + term.length >= offsets[endRowOffset + 1]) {
|
||||
endRowOffset++;
|
||||
}
|
||||
const startColOffset = resultIndex - offsets[startRowOffset];
|
||||
const endColOffset = resultIndex + term.length - offsets[endRowOffset];
|
||||
const startColIndex = this._stringLengthToBufferSize(row + startRowOffset, startColOffset);
|
||||
const endColIndex = this._stringLengthToBufferSize(row + endRowOffset, endColOffset);
|
||||
const size = endColIndex - startColIndex + this._terminal.cols * (endRowOffset - startRowOffset);
|
||||
|
||||
return {
|
||||
term,
|
||||
col: startColIndex,
|
||||
row: row + startRowOffset,
|
||||
size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private _stringLengthToBufferSize(row: number, offset: number): number {
|
||||
const line = this._terminal.buffer.active.getLine(row);
|
||||
if (!line) {
|
||||
return 0;
|
||||
}
|
||||
for (let i = 0; i < offset; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
// Adjust the searchIndex to normalize emoji into single chars
|
||||
const char = cell.getChars();
|
||||
if (char.length > 1) {
|
||||
offset -= char.length - 1;
|
||||
}
|
||||
// Adjust the searchIndex for empty characters following wide unicode
|
||||
// chars (eg. CJK)
|
||||
const nextCell = line.getCell(i + 1);
|
||||
if (nextCell && nextCell.getWidth() === 0) {
|
||||
offset++;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
private _bufferColsToStringOffset(startRow: number, cols: number): number {
|
||||
let lineIndex = startRow;
|
||||
let offset = 0;
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (cols > 0 && line) {
|
||||
for (let i = 0; i < cols && i < this._terminal.cols; i++) {
|
||||
const cell = line.getCell(i);
|
||||
if (!cell) {
|
||||
break;
|
||||
}
|
||||
if (cell.getWidth()) {
|
||||
// Treat null characters as whitespace to align with the translateToString API
|
||||
offset += cell.getCode() === 0 ? 1 : cell.getChars().length;
|
||||
}
|
||||
}
|
||||
lineIndex++;
|
||||
line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
if (line && !line.isWrapped) {
|
||||
break;
|
||||
}
|
||||
cols -= this._terminal.cols;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Copyright (c) 2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import { timeout } from 'vs/base/common/async';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
}
|
||||
|
||||
describe('SearchLineCache', () => {
|
||||
let terminal: Terminal;
|
||||
let cache: SearchLineCache;
|
||||
|
||||
beforeEach(() => {
|
||||
terminal = new Terminal({ cols: 80, rows: 24 });
|
||||
cache = new SearchLineCache(terminal);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cache.dispose();
|
||||
terminal.dispose();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a SearchLineCache instance', () => {
|
||||
assert.instanceOf(cache, SearchLineCache);
|
||||
});
|
||||
|
||||
it('should start with no cache initialized', () => {
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initLinesCache', () => {
|
||||
it('should initialize the lines cache array', () => {
|
||||
cache.initLinesCache();
|
||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be initialized but empty');
|
||||
});
|
||||
|
||||
it('should not reinitialize if cache already exists', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
cache.initLinesCache();
|
||||
|
||||
const entry = cache.getLineFromCache(0);
|
||||
assert.deepEqual(entry, ['test', [0]], 'cache should still contain the previously set entry');
|
||||
});
|
||||
|
||||
it('should set up TTL timeout', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]], 'entry should exist after initialization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLineFromCache', () => {
|
||||
it('should return undefined when cache is not initialized', () => {
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
assert.equal(cache.getLineFromCache(10), undefined);
|
||||
});
|
||||
|
||||
it('should return undefined for unset entries when cache is initialized', () => {
|
||||
cache.initLinesCache();
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
assert.equal(cache.getLineFromCache(50), undefined);
|
||||
});
|
||||
|
||||
it('should return cached entries', () => {
|
||||
cache.initLinesCache();
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(5, entry);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(5), entry);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLineInCache', () => {
|
||||
it('should not set entries when cache is not initialized', () => {
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should set entries when cache is initialized', () => {
|
||||
cache.initLinesCache();
|
||||
const entry: LineCacheEntry = ['test content', [0]];
|
||||
cache.setLineInCache(10, entry);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(10), entry);
|
||||
});
|
||||
|
||||
it('should overwrite existing entries', () => {
|
||||
cache.initLinesCache();
|
||||
const entry1: LineCacheEntry = ['first content', [0]];
|
||||
const entry2: LineCacheEntry = ['second content', [0]];
|
||||
|
||||
cache.setLineInCache(0, entry1);
|
||||
assert.deepEqual(cache.getLineFromCache(0), entry1);
|
||||
|
||||
cache.setLineInCache(0, entry2);
|
||||
assert.deepEqual(cache.getLineFromCache(0), entry2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateBufferLineToStringWithWrap', () => {
|
||||
it('should translate a single line without wrapping', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], 'Hello World');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle trimRight parameter', async () => {
|
||||
await writeP(terminal, 'Hello World ');
|
||||
const resultTrimmed = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const resultNotTrimmed = cache.translateBufferLineToStringWithWrap(0, false);
|
||||
|
||||
assert.equal(resultTrimmed[0].trimEnd(), 'Hello World');
|
||||
assert.isTrue(resultNotTrimmed[0].startsWith('Hello World '));
|
||||
assert.isTrue(resultNotTrimmed[0].length > resultTrimmed[0].length, 'non-trimmed result should be longer');
|
||||
});
|
||||
|
||||
it('should handle wrapped lines', async () => {
|
||||
const longText = 'A'.repeat(200);
|
||||
await writeP(terminal, longText);
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], longText);
|
||||
assert.isTrue(result[1].length > 1, 'should have multiple offsets due to wrapping');
|
||||
assert.equal(result[1][0], 0, 'first offset should be 0');
|
||||
});
|
||||
|
||||
it('should handle wide characters', async () => {
|
||||
await writeP(terminal, 'Hello 世界');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], 'Hello 世界');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle empty lines', () => {
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], '');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle lines beyond buffer', () => {
|
||||
const result = cache.translateBufferLineToStringWithWrap(1000, true);
|
||||
assert.equal(result[0], '');
|
||||
assert.deepEqual(result[1], [0]);
|
||||
});
|
||||
|
||||
it('should handle complex wrapped content', async () => {
|
||||
await writeP(terminal, 'Line 1\r\n');
|
||||
await writeP(terminal, 'Line 2 with some longer content that might wrap\r\n');
|
||||
await writeP(terminal, 'Line 3');
|
||||
|
||||
const result1 = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const result2 = cache.translateBufferLineToStringWithWrap(1, true);
|
||||
const result3 = cache.translateBufferLineToStringWithWrap(2, true);
|
||||
|
||||
assert.equal(result1[0], 'Line 1');
|
||||
assert.equal(result2[0], 'Line 2 with some longer content that might wrap');
|
||||
assert.equal(result3[0], 'Line 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache invalidation', () => {
|
||||
it('should invalidate cache on line feed', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
terminal.write('test\r\n');
|
||||
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should invalidate cache on cursor move', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
await writeP(terminal, 'some text');
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
|
||||
it('should invalidate cache on resize', async () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
terminal.resize(100, 30);
|
||||
|
||||
await timeout(10);
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disposal', () => {
|
||||
it('should clean up resources on dispose', () => {
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, ['test', [0]]);
|
||||
|
||||
assert.deepEqual(cache.getLineFromCache(0), ['test', [0]]);
|
||||
|
||||
cache.dispose();
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined, 'cache should be destroyed after disposal');
|
||||
});
|
||||
|
||||
it('should be safe to dispose multiple times', () => {
|
||||
cache.initLinesCache();
|
||||
cache.dispose();
|
||||
cache.dispose();
|
||||
|
||||
assert.equal(cache.getLineFromCache(0), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LineCacheEntry type', () => {
|
||||
it('should handle complex line offsets', () => {
|
||||
const entry: LineCacheEntry = [
|
||||
'A very long line that wraps multiple times across several terminal lines',
|
||||
[0, 20, 40, 60]
|
||||
];
|
||||
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
const retrieved = cache.getLineFromCache(0);
|
||||
assert.deepEqual(retrieved, entry);
|
||||
assert.equal(retrieved![0].length, 72);
|
||||
assert.equal(retrieved![1].length, 4);
|
||||
});
|
||||
|
||||
it('should handle unicode characters in cache entries', () => {
|
||||
const entry: LineCacheEntry = [
|
||||
'Hello 世界 🌍 测试',
|
||||
[0]
|
||||
];
|
||||
|
||||
cache.initLinesCache();
|
||||
cache.setLineInCache(0, entry);
|
||||
|
||||
const retrieved = cache.getLineFromCache(0);
|
||||
assert.deepEqual(retrieved, entry);
|
||||
assert.equal(retrieved![0], 'Hello 世界 🌍 测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with real terminal content', () => {
|
||||
it('should correctly translate real buffer content', async () => {
|
||||
await writeP(terminal, 'Hello World');
|
||||
const cached = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
const directTranslation = terminal.buffer.active.getLine(0)?.translateToString(true) || '';
|
||||
|
||||
assert.equal(cached[0], directTranslation);
|
||||
});
|
||||
|
||||
it('should handle real wrapped content correctly', async () => {
|
||||
const longContent = 'This is a very long line that will definitely wrap around in an 80 column terminal and should be handled correctly by the cache';
|
||||
await writeP(terminal, longContent);
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.equal(result[0], longContent);
|
||||
assert.isTrue(result[1].length > 1, 'should have wrapped');
|
||||
});
|
||||
|
||||
it('should work with real escape sequences', async () => {
|
||||
await writeP(terminal, 'Before\x1b[31mRed Text\x1b[0mAfter');
|
||||
const result = cache.translateBufferLineToStringWithWrap(0, true);
|
||||
assert.include(result[0], 'Before');
|
||||
assert.include(result[0], 'Red Text');
|
||||
assert.include(result[0], 'After');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { disposableTimeout } from 'vs/base/common/async';
|
||||
|
||||
export type LineCacheEntry = [
|
||||
/**
|
||||
* The string representation of a line (as opposed to the buffer cell representation).
|
||||
*/
|
||||
lineAsString: string,
|
||||
/**
|
||||
* The offsets where each line starts when the entry describes a wrapped line.
|
||||
*/
|
||||
lineOffsets: number[]
|
||||
];
|
||||
|
||||
/**
|
||||
* Configuration constants for the search line cache functionality.
|
||||
*/
|
||||
const enum Constants {
|
||||
/**
|
||||
* Time-to-live for cached search results in milliseconds. After this duration, cached search
|
||||
* results will be invalidated to ensure they remain consistent with terminal content changes.
|
||||
*/
|
||||
LINES_CACHE_TIME_TO_LIVE = 15000
|
||||
}
|
||||
|
||||
export class SearchLineCache extends Disposable {
|
||||
/**
|
||||
* translateBufferLineToStringWithWrap is a fairly expensive call.
|
||||
* We memoize the calls into an array that has a time based ttl.
|
||||
* _linesCache is also invalidated when the terminal cursor moves.
|
||||
*/
|
||||
private _linesCache: LineCacheEntry[] | undefined;
|
||||
private _linesCacheTimeout = this._register(new MutableDisposable());
|
||||
private _linesCacheDisposables = this._register(new MutableDisposable());
|
||||
// Track access to avoid recreating a timeout on every init call which occurs once per search
|
||||
// result (findNext/findPrevious -> _highlightAllMatches -> find loop).
|
||||
private _lastAccessTimestamp = 0;
|
||||
|
||||
constructor(private readonly _terminal: Terminal) {
|
||||
super();
|
||||
this._register(toDisposable(() => this._destroyLinesCache()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a line cache with a ttl
|
||||
*/
|
||||
public initLinesCache(): void {
|
||||
if (!this._linesCache) {
|
||||
this._linesCache = new Array(this._terminal.buffer.active.length);
|
||||
this._linesCacheDisposables.value = combinedDisposable(
|
||||
this._terminal.onLineFeed(() => this._destroyLinesCache()),
|
||||
this._terminal.onCursorMove(() => this._destroyLinesCache()),
|
||||
this._terminal.onResize(() => this._destroyLinesCache())
|
||||
);
|
||||
}
|
||||
|
||||
this._lastAccessTimestamp = Date.now();
|
||||
if (!this._linesCacheTimeout.value) {
|
||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE);
|
||||
}
|
||||
}
|
||||
|
||||
private _destroyLinesCache(): void {
|
||||
this._linesCache = undefined;
|
||||
this._lastAccessTimestamp = 0;
|
||||
this._linesCacheDisposables.clear();
|
||||
this._linesCacheTimeout.clear();
|
||||
}
|
||||
|
||||
private _scheduleLinesCacheTimeout(delay: number): void {
|
||||
this._linesCacheTimeout.value = disposableTimeout(() => {
|
||||
if (!this._linesCache) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
const elapsed = now - this._lastAccessTimestamp;
|
||||
if (elapsed >= Constants.LINES_CACHE_TIME_TO_LIVE) {
|
||||
this._destroyLinesCache();
|
||||
return;
|
||||
}
|
||||
this._scheduleLinesCacheTimeout(Constants.LINES_CACHE_TIME_TO_LIVE - elapsed);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
public getLineFromCache(row: number): LineCacheEntry | undefined {
|
||||
return this._linesCache?.[row];
|
||||
}
|
||||
|
||||
public setLineInCache(row: number, entry: LineCacheEntry): void {
|
||||
if (this._linesCache) {
|
||||
this._linesCache[row] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a buffer line to a string, including subsequent lines if they are wraps.
|
||||
* Wide characters will count as two columns in the resulting string. This
|
||||
* function is useful for getting the actual text underneath the raw selection
|
||||
* position.
|
||||
* @param lineIndex The index of the line being translated.
|
||||
* @param trimRight Whether to trim whitespace to the right.
|
||||
*/
|
||||
public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): LineCacheEntry {
|
||||
const strings = [];
|
||||
const lineOffsets = [0];
|
||||
let line = this._terminal.buffer.active.getLine(lineIndex);
|
||||
while (line) {
|
||||
const nextLine = this._terminal.buffer.active.getLine(lineIndex + 1);
|
||||
const lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
|
||||
let string = line.translateToString(!lineWrapsToNext && trimRight);
|
||||
if (lineWrapsToNext && nextLine) {
|
||||
const lastCell = line.getCell(line.length - 1);
|
||||
const lastCellIsNull = lastCell && lastCell.getCode() === 0 && lastCell.getWidth() === 1;
|
||||
// a wide character wrapped to the next line
|
||||
if (lastCellIsNull && nextLine.getCell(0)?.getWidth() === 2) {
|
||||
string = string.slice(0, -1);
|
||||
}
|
||||
}
|
||||
strings.push(string);
|
||||
if (lineWrapsToNext) {
|
||||
lineOffsets.push(lineOffsets[lineOffsets.length - 1] + string.length);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
lineIndex++;
|
||||
line = nextLine;
|
||||
}
|
||||
return [strings.join(''), lineOffsets];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
|
||||
import type { IDisposable } from '@xterm/xterm';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
* Interface for managing a currently selected decoration.
|
||||
*/
|
||||
interface ISelectedDecoration extends IDisposable {
|
||||
match: ISearchResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks search results, manages result indexing, and fires events when results change.
|
||||
* This class provides centralized management of search result state and notifications.
|
||||
*/
|
||||
export class SearchResultTracker extends Disposable {
|
||||
private _searchResults: ISearchResult[] = [];
|
||||
private _selectedDecoration: ISelectedDecoration | undefined;
|
||||
|
||||
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
||||
public get onDidChangeResults(): Event<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
||||
|
||||
/**
|
||||
* Gets the current search results.
|
||||
*/
|
||||
public get searchResults(): ReadonlyArray<ISearchResult> {
|
||||
return this._searchResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently selected decoration.
|
||||
*/
|
||||
public get selectedDecoration(): ISelectedDecoration | undefined {
|
||||
return this._selectedDecoration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the currently selected decoration.
|
||||
*/
|
||||
public set selectedDecoration(decoration: ISelectedDecoration | undefined) {
|
||||
this._selectedDecoration = decoration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the search results with a new set of results.
|
||||
* @param results The new search results.
|
||||
* @param maxResults The maximum number of results to track.
|
||||
*/
|
||||
public updateResults(results: ISearchResult[], maxResults: number): void {
|
||||
this._searchResults = results.slice(0, maxResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all search results.
|
||||
*/
|
||||
public clearResults(): void {
|
||||
this._searchResults = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the selected decoration.
|
||||
*/
|
||||
public clearSelectedDecoration(): void {
|
||||
if (this._selectedDecoration) {
|
||||
this._selectedDecoration.dispose();
|
||||
this._selectedDecoration = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the index of a result in the current results array.
|
||||
* @param result The result to find.
|
||||
* @returns The index of the result, or -1 if not found.
|
||||
*/
|
||||
public findResultIndex(result: ISearchResult): number {
|
||||
for (let i = 0; i < this._searchResults.length; i++) {
|
||||
const match = this._searchResults[i];
|
||||
if (match.row === result.row && match.col === result.col && match.size === result.size) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires a result change event with the current state.
|
||||
* @param hasDecorations Whether decorations are enabled.
|
||||
*/
|
||||
public fireResultsChanged(hasDecorations: boolean): void {
|
||||
if (!hasDecorations) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resultIndex = -1;
|
||||
if (this._selectedDecoration) {
|
||||
resultIndex = this.findResultIndex(this._selectedDecoration.match);
|
||||
}
|
||||
|
||||
this._onDidChangeResults.fire({
|
||||
resultIndex,
|
||||
resultCount: this._searchResults.length
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this.clearSelectedDecoration();
|
||||
this.clearResults();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
|
||||
/**
|
||||
* Manages search state including cached search terms, options tracking, and validation.
|
||||
* This class provides a centralized way to handle search state consistency and option changes.
|
||||
*/
|
||||
export class SearchState {
|
||||
private _cachedSearchTerm: string | undefined;
|
||||
private _lastSearchOptions: ISearchOptions | undefined;
|
||||
|
||||
/**
|
||||
* Gets the currently cached search term.
|
||||
*/
|
||||
public get cachedSearchTerm(): string | undefined {
|
||||
return this._cachedSearchTerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the cached search term.
|
||||
*/
|
||||
public set cachedSearchTerm(term: string | undefined) {
|
||||
this._cachedSearchTerm = term;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last search options used.
|
||||
*/
|
||||
public get lastSearchOptions(): ISearchOptions | undefined {
|
||||
return this._lastSearchOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the last search options used.
|
||||
*/
|
||||
public set lastSearchOptions(options: ISearchOptions | undefined) {
|
||||
this._lastSearchOptions = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a search term to ensure it's not empty or invalid.
|
||||
* @param term The search term to validate.
|
||||
* @returns true if the term is valid for searching.
|
||||
*/
|
||||
public isValidSearchTerm(term: string): boolean {
|
||||
return !!(term && term.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if search options have changed compared to the last search.
|
||||
* @param newOptions The new search options to compare.
|
||||
* @returns true if the options have changed.
|
||||
*/
|
||||
public didOptionsChange(newOptions?: ISearchOptions): boolean {
|
||||
if (!this._lastSearchOptions) {
|
||||
return true;
|
||||
}
|
||||
if (!newOptions) {
|
||||
return false;
|
||||
}
|
||||
if (this._lastSearchOptions.caseSensitive !== newOptions.caseSensitive) {
|
||||
return true;
|
||||
}
|
||||
if (this._lastSearchOptions.regex !== newOptions.regex) {
|
||||
return true;
|
||||
}
|
||||
if (this._lastSearchOptions.wholeWord !== newOptions.wholeWord) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a new search should trigger highlighting updates.
|
||||
* @param term The search term.
|
||||
* @param options The search options.
|
||||
* @returns true if highlighting should be updated.
|
||||
*/
|
||||
public shouldUpdateHighlighting(term: string, options?: ISearchOptions): boolean {
|
||||
if (!options?.decorations) {
|
||||
return false;
|
||||
}
|
||||
return this._cachedSearchTerm === undefined ||
|
||||
term !== this._cachedSearchTerm ||
|
||||
this.didOptionsChange(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached search term.
|
||||
*/
|
||||
public clearCachedTerm(): void {
|
||||
this._cachedSearchTerm = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state.
|
||||
*/
|
||||
public reset(): void {
|
||||
this._cachedSearchTerm = undefined;
|
||||
this._lastSearchOptions = undefined;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"vs/*": [
|
||||
"../../../src/vs/*"
|
||||
],
|
||||
@@ -34,6 +37,9 @@
|
||||
{
|
||||
"path": "../../../src/common"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/vs"
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ test.afterAll(async () => await ctx.page.close());
|
||||
test.describe('Search Tests', () => {
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await ctx.proxy.reset();
|
||||
await ctx.page.evaluate(`
|
||||
window.term.reset()
|
||||
window.search?.dispose();
|
||||
window.search = new SearchAddon();
|
||||
window.term.loadAddon(window.search);
|
||||
@@ -385,6 +385,34 @@ test.describe('Search Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('onBeforeSearch and onAfterSearch', () => {
|
||||
test.beforeEach(async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.events = [];
|
||||
window.search.onBeforeSearch(() => window.events.push('before'));
|
||||
window.search.onAfterSearch(() => window.events.push('after'));
|
||||
`);
|
||||
});
|
||||
test('should fire before and after findNext', async () => {
|
||||
await ctx.proxy.write('abc');
|
||||
await ctx.page.evaluate(`window.search.findNext('a')`);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.events'), ['before', 'after']);
|
||||
});
|
||||
|
||||
test('should fire before and after findPrevious', async () => {
|
||||
await ctx.proxy.write('abc');
|
||||
await ctx.page.evaluate(`window.search.findPrevious('a')`);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.events'), ['before', 'after']);
|
||||
});
|
||||
|
||||
test('should fire for each search call', async () => {
|
||||
await ctx.proxy.write('abc abc');
|
||||
await ctx.page.evaluate(`window.search.findNext('abc')`);
|
||||
await ctx.page.evaluate(`window.search.findNext('abc')`);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.events'), ['before', 'after', 'before', 'after']);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Regression tests', () => {
|
||||
test.describe('#2444 wrapped line content not being found', () => {
|
||||
let fixture: string;
|
||||
@@ -480,6 +508,57 @@ test.describe('Search Tests', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Wrapped line search functionality', () => {
|
||||
test('should correctly count matches across multiple wrapped lines', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.calls = [];
|
||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||
`);
|
||||
|
||||
const content = 'a'.repeat(300);
|
||||
await ctx.proxy.write(content);
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('${content}', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle reverse search across wrapped lines', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.calls = [];
|
||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||
`);
|
||||
|
||||
const content = 'x'.repeat(300);
|
||||
await ctx.proxy.write(content);
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('${content}', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('should update counts when content changes across wrapped lines', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.calls = [];
|
||||
window.search.onDidChangeResults(e => window.calls.push(e));
|
||||
`);
|
||||
|
||||
const content = 'z'.repeat(300);
|
||||
await ctx.proxy.write(content);
|
||||
strictEqual(await ctx.page.evaluate(`window.search.findNext('${content}', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 }
|
||||
]);
|
||||
|
||||
await ctx.proxy.write('\\n\\r' + content);
|
||||
await timeout(300);
|
||||
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
|
||||
{ resultCount: 1, resultIndex: 0 },
|
||||
{ resultCount: 2, resultIndex: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function makeData(length: number): string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user