mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 01:37:53 +02:00
Compare commits
967 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96fd239608 | ||
| 61de7e9e22 | |||
|
|
24c4cdc39f | ||
| fadbf77866 | |||
| 03819a3d90 | |||
| d6d0777113 | |||
| 1aa83e0ef1 | |||
|
|
b09c644e01 | ||
| e403870874 | |||
| 1586ce2d6c | |||
| 576353cfe8 | |||
|
|
cefc415c98 | ||
| bc0ef7893e | |||
|
|
0e802d8194 | ||
| d7718d4dcb | |||
|
|
4c2e02e912 | ||
| b8774e0b0b | |||
| 6e75642090 | |||
| aaa0d1003d | |||
| 5960918137 | |||
| 3dc0532df0 | |||
| 96863adf53 | |||
|
|
08425a623e | ||
| b787759f44 | |||
|
|
25ef7c05e6 | ||
| c36bb80d6a | |||
|
|
c069f3e1b3 | ||
| 215d59c8bf | |||
| 008a33a9b1 | |||
| 3e787234c7 | |||
| 1173510105 | |||
| a391f3018c | |||
| b6e1e20b7c | |||
| 572f2fb811 | |||
| 2e2d422910 | |||
| f0556e4411 | |||
| 4a97105e4b | |||
| 797f73c39a | |||
| b8f796fd3f | |||
| 78673ea11a | |||
| c6a14c0768 | |||
|
|
70a966d8dc | ||
| c42511dd44 | |||
|
|
db62f9e998 | ||
| 0610d2f9f0 | |||
| c1dd0ee190 | |||
| a45c407568 | |||
|
|
813f57861c | ||
| 3faee98ec8 | |||
| ca02132c8d | |||
|
|
cb4ef25b73 | ||
| c8b7367815 | |||
| a268caaa30 | |||
| 6b25abff70 | |||
| 21c807f358 | |||
| 56fdae4275 | |||
| e6a06c9f43 | |||
| f979a63d3d | |||
|
|
327bc54e22 | ||
| a51b15da3f | |||
| 7271b422f9 | |||
| 1866ba66c8 | |||
|
|
6175a04a90 | ||
| 7120f3e93b | |||
| acc13183e2 | |||
| f75fc19c5b | |||
|
|
2650c8b8cf | ||
| 1de3cbf65a | |||
|
|
4a9d0c9e44 | ||
| 88ecd05b95 | |||
| df812eaad5 | |||
|
|
d62da494c8 | ||
| e631fc15d8 | |||
|
|
ecbf1ce0c8 | ||
| e5c0087c9a | |||
| 4348ed1bb2 | |||
|
|
5c11fde0a9 | ||
| 4ca1efeeb8 | |||
| aa7ce2ea27 | |||
|
|
174f0cdcb6 | ||
| 860517a321 | |||
|
|
66daae6d9e | ||
| 83001a0d82 | |||
| 1b7921a7f2 | |||
| 8badb6adc1 | |||
| 37682e7b8a | |||
| 56e74a0e7d | |||
| ec4574ed5c | |||
| 21d20e0fc7 | |||
| 7ce3a83c58 | |||
| 6dff1879c4 | |||
| c09644b29d | |||
| d8cf44134c | |||
| ca856384f3 | |||
| 4e2c9df6a4 | |||
| 8b822e0fa8 | |||
| 67d398caf7 | |||
|
|
c2c27f8279 | ||
| 50b3422528 | |||
| 4639eee0b9 | |||
| b4b27aea3d | |||
| e483b282db | |||
| 36391db607 | |||
| 5362334ff3 | |||
| fdf11d8147 | |||
|
|
204f653b72 | ||
| 48ae950d57 | |||
| 925c893f3f | |||
|
|
b54423a151 | ||
| ce374163ca | |||
| 3644f344da | |||
| d1266a1ce1 | |||
| f7d0b0768a | |||
| 630616ec72 | |||
|
|
7f7bef7581 | ||
| d2f2b206bb | |||
| 6fa1c06053 | |||
| 5d4ca816cd | |||
| 443b6c1d7b | |||
| 505a5ec833 | |||
|
|
3a7289bf5e | ||
| 2718bc6247 | |||
|
|
515d2651bf | ||
| ef25f56380 | |||
|
|
5b280ccc1e | ||
| cbbd23aa33 | |||
|
|
860d0ad014 | ||
| fa344a5799 | |||
|
|
3919de5bd5 | ||
| 1a0a98a453 | |||
| d79f7e9ccd | |||
| 50e41ff261 | |||
| 430b282039 | |||
|
|
17133771bb | ||
| e5a7d47b21 | |||
|
|
71ec61e27b | ||
| b3575eb068 | |||
| 216511b951 | |||
| 6dabbf874f | |||
|
|
d5aad06c88 | ||
| 5d6672069e | |||
| 140ad83380 | |||
| ea805d1362 | |||
| 9e16f2faf9 | |||
| 2a36d9364f | |||
| 27426ce7a5 | |||
|
|
69adadd6d7 | ||
| 6f96498de6 | |||
| 836b6e64f6 | |||
|
|
fab7dd7eec | ||
| 9263f8ef5c | |||
|
|
658728efef | ||
| 6b8432f5b2 | |||
| bc709c4184 | |||
| b49462abeb | |||
| d9d4e3c9bf | |||
| fe04dd80e5 | |||
|
|
718950cf0d | ||
| 17a0068757 | |||
| abc6caa2d0 | |||
|
|
99fb82561b | ||
| 61ba08d0b8 | |||
| 40b5688158 | |||
|
|
0a4e253cbd | ||
| 6428e38ab9 | |||
| fc4f4f81ad | |||
| f6629852eb | |||
| 3adf6cfd58 | |||
| b15816ca9f | |||
| 6b1d5827d6 | |||
| f0391f59c9 | |||
| 006a0894b8 | |||
| 9c5a471234 | |||
| 1c7f4912ce | |||
| df1be10057 | |||
| 954c576131 | |||
| 867720a897 | |||
| 2b40602bdc | |||
| 11173b9c0a | |||
|
|
52d46e77db | ||
| e7838b0f2f | |||
|
|
2ae3810cf6 | ||
| 178fe4d2da | |||
| 2d79ef8fe5 | |||
| d56c5493cd | |||
| cf6e5a40fc | |||
| 64abd67b9b | |||
|
|
c19e856800 | ||
| 02a26086c4 | |||
|
|
35f880bc2f | ||
| c0ddeceeea | |||
| 67fd5e8581 | |||
| bf699ec1fb | |||
| 3094632134 | |||
| 6985ff0fce | |||
| 33f7be42c5 | |||
|
|
36fac70361 | ||
| ca5e8d2fbb | |||
| 828067f486 | |||
|
|
e5f4c0b952 | ||
| edb1775967 | |||
|
|
d0d6908a74 | ||
| c037b87675 | |||
| 52bc322b2b | |||
| 8479caf53a | |||
| 82e2c898d2 | |||
|
|
4852076e4a | ||
| 15cbc21e5b | |||
| ffae5ee54e | |||
|
|
10d77f20d1 | ||
| 4be0d14b74 | |||
| e883dbad81 | |||
| a2abad344f | |||
| d44b1cf8b1 | |||
| c5b6499e41 | |||
| a951ebf1be | |||
| 32da803df9 | |||
| 07d60cf735 | |||
|
|
fceb851c32 | ||
| e1af5ca60f | |||
| 7fb31fc4d7 | |||
| 5c6ba65469 | |||
| cd9fc46ff8 | |||
| 2a88e17b23 | |||
| 69f4371007 | |||
|
|
af2c816d35 | ||
| f51b25f0af | |||
| c3f4845b4f | |||
| 8ae323f5c3 | |||
|
|
a3805a765b | ||
| 8c03034acf | |||
| 4160f3d6d7 | |||
|
|
4742e1ff6a | ||
| 4af1abe4e1 | |||
| 131f49da8e | |||
|
|
f9bf496bd3 | ||
| 9648e3ea96 | |||
| 4be756a867 | |||
| 46face0ee5 | |||
| 6f3b1ea985 | |||
| 3c9181d93d | |||
|
|
a05f24785e | ||
| 9d615c915c | |||
| d2539918b2 | |||
|
|
ed264cb528 | ||
| ad208a5ef8 | |||
| ddc9510c2b | |||
| 855be3551a | |||
| db301b1be2 | |||
| 07b99d91a5 | |||
| 0fea8d6065 | |||
|
|
2a67f1667a | ||
| 76bd0d339a | |||
|
|
43759082dd | ||
| fc4d0f3bb2 | |||
| a47a8ec413 | |||
| 3455c60236 | |||
| edc25fbf9d | |||
|
|
dc38f2308b | ||
| 7d64cac661 | |||
|
|
ab4f1acd75 | ||
| 9f8fbdd5fc | |||
| d1e6cd388c | |||
| 5d09a13d88 | |||
| 0490e80c48 | |||
|
|
48553ba9b1 | ||
| 0f6a5e5fa9 | |||
| 8ff36105d1 | |||
|
|
ce78271af4 | ||
| 57ee735e5c | |||
| 32e1a9d847 | |||
| 5cc816d0af | |||
| 4117fd7b5b | |||
| c86ce302a9 | |||
|
|
c33ce05951 | ||
| 7f2f7cd07a | |||
|
|
1454f6192b | ||
| ceae979f37 | |||
| fcd6ef0975 | |||
| d8ff8afcd4 | |||
| 03fa1f26d0 | |||
| e65c7f3be8 | |||
|
|
20d6352351 | ||
| 5ece269adb | |||
| e0851250ee | |||
| 799ea554de | |||
| df323504fe | |||
| 0ab8aa3a2f | |||
|
|
dae8a3409a | ||
| 0dfcaa4b70 | |||
|
|
98cb2c08ea | ||
| 57cb136a09 | |||
|
|
1d84bca753 | ||
| 4f261be4c7 | |||
| 40eb75f85a | |||
| 13c018a797 | |||
| 8f20a0b3b1 | |||
| 6b6a6b2249 | |||
| 2ca32675ec | |||
| 381d713837 | |||
| a898e7e4f1 | |||
|
|
6d13a3283b | ||
| ab8537483d | |||
| a22229849c | |||
|
|
1ba266080c | ||
| 6500a00682 | |||
| 9602085f82 | |||
|
|
a1c369de9b | ||
| 6238693ffb | |||
|
|
f3a387e77f | ||
| 71cb80d544 | |||
| 77ff7962cc | |||
|
|
a516b1b247 | ||
| 67a99a1a19 | |||
|
|
e55daee756 | ||
| 1111610f32 | |||
| 81484e8160 | |||
|
|
2e349bd705 | ||
| a156803389 | |||
| 2955b5ec02 | |||
| ff52100e23 | |||
| 026c0792be | |||
| b632ed1095 | |||
|
|
98beea37e6 | ||
| 4bcae0f921 | |||
| 22fb5a5656 | |||
| 4da625e439 | |||
| 05e268d466 | |||
| 42a9a0ca15 | |||
| b6feb9adb3 | |||
| 1bc18a201c | |||
| c12f2cee80 | |||
| c2c583fce6 | |||
| 5600624c57 | |||
| 66c0649d7e | |||
| 2446c401d9 | |||
| 4d0df364d3 | |||
| ecdf0f122b | |||
| df5234aa52 | |||
| 62080e6b40 | |||
| 2e3f46ea36 | |||
| be9847e9d2 | |||
| 2f7317b328 | |||
| bd3b1ba043 | |||
|
|
59e82dfd00 | ||
| 0b86a0009d | |||
| 49327a8dbd | |||
| 301bb916da | |||
| 285bf0164b | |||
| 90907e0a9c | |||
| 9def3734af | |||
|
|
3a241e897b | ||
| ee617b73a2 | |||
| 92cea90971 | |||
|
|
452ba20216 | ||
| cf29035e28 | |||
|
|
3fc09dd2aa | ||
| fe101f9328 | |||
| 754d81edf3 | |||
| 3d399ba1f5 | |||
| 6dc1000de5 | |||
|
|
89b4deb5cd | ||
| 6e0e69b9f7 | |||
| b8519e8770 | |||
| 0f69c346cd | |||
| 88014d24c1 | |||
| ea4d743a25 | |||
|
|
5935d4865c | ||
| c5826f8887 | |||
| 62f0b15193 | |||
| d846266332 | |||
|
|
06d0331dee | ||
| e6b065767c | |||
| f3a96dedd7 | |||
|
|
016324e71c | ||
| a92aead769 | |||
| 882cf55fc5 | |||
|
|
ee02c13d5d | ||
|
|
9ccd4ea235 | ||
|
|
86416d50cb | ||
| 1d5442ac08 | |||
|
|
f3c7196921 | ||
|
|
14f901f1be | ||
|
|
9f93c01ff7 | ||
| 203ae09606 | |||
| 2d39c5e4d1 | |||
| 9049e0d27f | |||
|
|
d5d41fc759 | ||
|
|
d0f9bf1733 | ||
|
|
7d46d1160d | ||
|
|
b8d4e697ac | ||
|
|
4664661cfb | ||
|
|
d99fd76c0b | ||
|
|
fcf918c488 | ||
|
|
32747baa27 | ||
|
|
9e974eda27 | ||
|
|
598479bb55 | ||
|
|
4ef6ae90f2 | ||
|
|
4865b10ced | ||
|
|
3362fabed7 | ||
|
|
4076698530 | ||
|
|
a21bfec3d9 | ||
|
|
7ffedd9ceb | ||
|
|
9ad0055336 | ||
|
|
70c4e9bc5e | ||
|
|
43770e2967 | ||
|
|
f3b3c2f526 | ||
|
|
279ac03dc3 | ||
| 4c0a7bbec7 | |||
|
|
f5f9158779 | ||
| c319dacb24 | |||
| 814768525f | |||
|
|
38d056570f | ||
|
|
f386563aa1 | ||
|
|
110506c9a9 | ||
|
|
7e0058a611 | ||
|
|
d89f596a5d | ||
|
|
5de2dfefcb | ||
|
|
bb1f066c3c | ||
|
|
44b451e66b | ||
| a2ed2ebe00 | |||
| 8127fc2960 | |||
|
|
6171790f66 | ||
|
|
ebb36f62dd | ||
|
|
644f1031f6 | ||
|
|
fd711b475f | ||
|
|
57132a4721 | ||
| f71dc5c5ab | |||
| 4630d78fc2 | |||
| da640e888d | |||
|
|
35cd4fd6f1 | ||
|
|
f06e652b82 | ||
|
|
5fc8047c8f | ||
|
|
0363fd5194 | ||
|
|
826a5e9874 | ||
|
|
f668eb8b9b | ||
|
|
5964778a64 | ||
|
|
8135f68230 | ||
|
|
24c77376b2 | ||
|
|
f364afcb42 | ||
|
|
4051902f09 | ||
|
|
a28b9c8981 | ||
|
|
9a5c86ea35 | ||
|
|
08534a4739 | ||
|
|
1db77b969b | ||
|
|
99dce077c4 | ||
|
|
402adc44e8 | ||
|
|
c6bdf0b6a5 | ||
|
|
1c2fb8b972 | ||
| a61bf36df5 | |||
|
|
d678a85957 | ||
|
|
684592ae37 | ||
|
|
f0ed243c91 | ||
|
|
cba3863e5a | ||
|
|
1d26b23221 | ||
|
|
b827e9eaa7 | ||
|
|
60d150a411 | ||
|
|
c781b1b4e4 | ||
|
|
565e475ace | ||
|
|
7c15d75011 | ||
|
|
b676877242 | ||
|
|
7768e594b5 | ||
|
|
9ef331c272 | ||
|
|
4a1792c209 | ||
|
|
91447a2d62 | ||
|
|
ed5bdd99e6 | ||
|
|
feca7a3dcd | ||
|
|
2d9020358d | ||
|
|
51259097fa | ||
|
|
8a4aeb8dfe | ||
|
|
4b0542a513 | ||
|
|
bf04a4e04a | ||
|
|
fa4ca935bb | ||
|
|
b52e22d81f | ||
|
|
2f96e10b9d | ||
|
|
031cb094e7 | ||
|
|
8afc5f0c0c | ||
|
|
17f14581d7 | ||
|
|
8361736679 | ||
|
|
0b9927fcf5 | ||
|
|
8139e271de | ||
|
|
6fe08e6b82 | ||
|
|
968da6f558 | ||
|
|
11ae0b1054 | ||
| 5ebfd2a3c2 | |||
| b36131eed5 | |||
|
|
a7bfcc12b9 | ||
|
|
ab275b8e5f | ||
|
|
d211b47f4c | ||
|
|
812ffaf8ea | ||
|
|
f7a496723c | ||
|
|
48847a19c7 | ||
|
|
8d0083c4aa | ||
|
|
3c143274c5 | ||
|
|
747e97e0c9 | ||
|
|
c6fe9d2026 | ||
|
|
75090b8575 | ||
|
|
8f76c789cf | ||
| 4664568672 | |||
| 3fb6644543 | |||
| d909673071 | |||
|
|
d281d6576c | ||
|
|
8bebc4f692 | ||
|
|
1cd273c375 | ||
|
|
249170ea30 | ||
|
|
1a429b3024 | ||
|
|
e05cab812a | ||
|
|
7607d7a3b6 | ||
|
|
e51be04b95 | ||
|
|
de1f5c968a | ||
|
|
bf819bcf48 | ||
|
|
6f26e5cc3d | ||
|
|
f9c5c82381 | ||
|
|
79487dbec2 | ||
| 58721bea1a | |||
|
|
03e96669da | ||
|
|
eb529d24d2 | ||
|
|
ebd4fccda2 | ||
|
|
97dcc5ac76 | ||
|
|
9c7a189beb | ||
|
|
6061b3150e | ||
|
|
3982c5d498 | ||
|
|
404ca49821 | ||
|
|
6e4775a124 | ||
|
|
5ab82bc133 | ||
|
|
00ef3ae925 | ||
|
|
90d8069cc3 | ||
|
|
457567ef74 | ||
|
|
1128ca5252 | ||
|
|
86c5f25205 | ||
|
|
a706da2490 | ||
|
|
d67bdd2616 | ||
|
|
c3f2ad45c3 | ||
|
|
26c07c3205 | ||
|
|
c995e0d235 | ||
|
|
463a60a99c | ||
|
|
98a46a85b2 | ||
|
|
186c42d667 | ||
|
|
f3a47a5b08 | ||
|
|
af995a74f3 | ||
|
|
3abd955465 | ||
|
|
cba8131367 | ||
|
|
831eddc136 | ||
| 9e852d1afc | |||
| 3ec9caae09 | |||
| 11281fef53 | |||
|
|
9d497b70bf | ||
|
|
2a334156a8 | ||
|
|
086804780d | ||
|
|
731fba55ec | ||
|
|
a3b24f9242 | ||
|
|
af71e35e73 | ||
|
|
3e8996a024 | ||
| 03bdf980bc | |||
| 1084bc0a80 | |||
| 504944f696 | |||
|
|
b53f72f0ad | ||
|
|
aad754f472 | ||
|
|
f5d1127d21 | ||
|
|
080c258d15 | ||
|
|
5adde23a45 | ||
|
|
2359a08519 | ||
|
|
d1f9979ab1 | ||
|
|
bcc47f3740 | ||
|
|
4f700976dd | ||
| 039f963661 | |||
|
|
3ebdb4bed0 | ||
|
|
016b26f5cf | ||
|
|
e5010c7772 | ||
|
|
a4d9713785 | ||
|
|
b21c1db2a9 | ||
|
|
d967fafe3c | ||
|
|
7d15397ce3 | ||
|
|
d978740f98 | ||
|
|
c174326762 | ||
|
|
3d9dc5c008 | ||
|
|
3cc05cde14 | ||
|
|
f96caccfcb | ||
|
|
d7a2c6830f | ||
|
|
045b1baa60 | ||
|
|
fb555b278a | ||
|
|
d865e2f1af | ||
|
|
c70ddb3cb1 | ||
|
|
8ad3059592 | ||
|
|
ee3b616ec1 | ||
|
|
286e62df92 | ||
|
|
b07bb3dde2 | ||
|
|
10dfe9fb65 | ||
|
|
a0d172e3dc | ||
|
|
94878448c8 | ||
|
|
745aa6e812 | ||
|
|
b14d95ad2b | ||
| 65cbd6ef28 | |||
|
|
bb64088282 | ||
|
|
b6f6bc5b20 | ||
|
|
7957d2c566 | ||
|
|
84ef7e59c9 | ||
|
|
cae4f8b934 | ||
|
|
53494c7327 | ||
|
|
05c822617a | ||
|
|
6b114c2461 | ||
|
|
cd9cd9ef9d | ||
|
|
37278e363c | ||
|
|
92a5325aad | ||
|
|
7fec0c7e44 | ||
|
|
59bc40427c | ||
| 5ec2b08e34 | |||
|
|
b38e942acb | ||
|
|
832a438b24 | ||
|
|
43777f58f6 | ||
|
|
aa4c7c3385 | ||
|
|
b85cc898d5 | ||
|
|
da9025e032 | ||
|
|
5c67026637 | ||
|
|
ee2f36fb40 | ||
|
|
5ac3526384 | ||
|
|
3be9c974b5 | ||
|
|
18a702572f | ||
|
|
63f23cf78e | ||
|
|
2e42ba174f | ||
|
|
8bc88ca195 | ||
|
|
f5ff15fb9a | ||
|
|
4b7592c279 | ||
|
|
0fe06ade5b | ||
|
|
27f6a89a29 | ||
|
|
b311069722 | ||
|
|
26c6e1f4b8 | ||
|
|
088fa516a8 | ||
|
|
975aadbf07 | ||
|
|
1f0103480d | ||
|
|
679d3e1980 | ||
|
|
3c28cf0e01 | ||
|
|
9308f60b88 | ||
|
|
ebd0e588d4 | ||
|
|
42fe859fca | ||
|
|
850f02338e | ||
|
|
10539f0ba5 | ||
|
|
4a6e73f4f7 | ||
|
|
f396f98e73 | ||
|
|
de23c28e40 | ||
|
|
fd49f1b484 | ||
|
|
ff1d918d43 | ||
|
|
d52aa15aac | ||
|
|
3a4cbb1bb6 | ||
|
|
3866d7ce4d | ||
|
|
989cd51162 | ||
|
|
1fd018512f | ||
|
|
1cdd760e40 | ||
|
|
1333e6cbca | ||
|
|
4e710dda5e | ||
|
|
77e1d0925d | ||
|
|
60e864b259 | ||
|
|
a3a72b9b93 | ||
|
|
6a5e0adfb2 | ||
|
|
5ad19b4d7b | ||
|
|
cb6fb9d78b | ||
|
|
e4336cca30 | ||
|
|
a785bca880 | ||
|
|
afab283988 | ||
|
|
644a97aee8 | ||
|
|
12469c8c1e | ||
|
|
93db0c21ef | ||
|
|
7e99920fc5 | ||
|
|
cda8daeb35 | ||
|
|
2b29b6cfe2 | ||
|
|
7d5429a162 | ||
|
|
e41d81cbd9 | ||
|
|
55b5ca7381 | ||
|
|
ec88564e65 | ||
|
|
fbb7a918cc | ||
|
|
8ffb7d8961 | ||
|
|
7db9e0ef16 | ||
|
|
f1d7abeb25 | ||
|
|
d78940da3f | ||
|
|
a6616f5986 | ||
|
|
f94a29bf4b | ||
|
|
bf2a09e630 | ||
|
|
486ffa2505 | ||
|
|
c9e5dd542c | ||
| 9d36b9686e | |||
|
|
282f2db756 | ||
|
|
2925a5f20e | ||
| 7152c5b229 | |||
|
|
17ea7ab703 | ||
|
|
8f83115efc | ||
|
|
6d6b1e9155 | ||
|
|
144e56cdd9 | ||
|
|
28908dd07c | ||
|
|
ad2b798f11 | ||
|
|
f022153fa2 | ||
|
|
141b49ff39 | ||
|
|
59bba1429c | ||
|
|
f3f55a7ee0 | ||
|
|
75af0404b3 | ||
|
|
483e18259a | ||
| f7cbdbc5ca | |||
| 7335aa9597 | |||
| f01078fc21 | |||
| 616de26150 | |||
| 78b666ffdb | |||
|
|
68be2a0418 | ||
|
|
78a2b21466 | ||
|
|
5814113f73 | ||
|
|
eb1f1d481e | ||
|
|
6c3dfddd28 | ||
|
|
5162270d28 | ||
|
|
ac2a41d2d8 | ||
|
|
5637c938cf | ||
|
|
d2c12a9f1c | ||
|
|
51c3a9e9ee | ||
|
|
9750039097 | ||
|
|
824ce821cd | ||
|
|
90f22c2288 | ||
|
|
fbd299c7e7 | ||
|
|
36942b316a | ||
|
|
6c773c7c94 | ||
|
|
c525eba885 | ||
|
|
0338462a85 | ||
|
|
fc6098414e | ||
|
|
daf4ee190e | ||
|
|
0ec65a0b41 | ||
|
|
ae79faa7ed | ||
|
|
d356cf734b | ||
|
|
9e7224e0ae | ||
|
|
2faeb639be | ||
|
|
b76df1b583 | ||
|
|
835bda0a53 | ||
|
|
aed65b411a | ||
|
|
8050bdf82d | ||
|
|
d623cf9539 | ||
|
|
89a52a0948 | ||
|
|
5a7ac860a8 | ||
|
|
fc31960c61 | ||
|
|
ece1859a63 | ||
|
|
7b3a873800 | ||
|
|
a0a89fe704 | ||
|
|
dafb6fae7a | ||
|
|
69aaea24f9 | ||
|
|
82bebe6b41 | ||
|
|
f8d30c9b0e | ||
|
|
126451a7a9 | ||
|
|
cf15163bd9 | ||
|
|
6322c4720f | ||
|
|
08d956940e | ||
|
|
f74a6a0b8b | ||
|
|
779f34f500 | ||
|
|
c827a25dab | ||
|
|
0a0d51d278 | ||
|
|
70684d119f | ||
|
|
153c5f4f9d | ||
|
|
8b3a0baaa6 | ||
|
|
5e9deae765 | ||
|
|
4772c244c2 | ||
|
|
80190ccba7 | ||
|
|
abd3ebec1f | ||
|
|
7485aa999f | ||
|
|
ad1d69fa66 | ||
| 977ce3ae93 | |||
|
|
93f21eefd7 | ||
|
|
44cc881ac9 | ||
|
|
ee3cae6472 | ||
|
|
b78152b149 | ||
|
|
85841cdf1f | ||
|
|
ed3f656d5e | ||
|
|
a4fb6bd1d2 | ||
|
|
05f48de3f1 | ||
|
|
401fec8539 | ||
|
|
b13509e9eb | ||
|
|
a15860abac | ||
|
|
673ed325d1 | ||
|
|
63f52fc841 | ||
|
|
200e8b2351 | ||
|
|
e4f23f5101 | ||
|
|
b41d63ea4d | ||
|
|
418480f1fc | ||
|
|
6955b6e292 | ||
|
|
abe35bf967 | ||
|
|
174ab8fd8b | ||
|
|
7ff72b4086 | ||
|
|
cb144c7c2c | ||
|
|
5f3d55b760 | ||
|
|
a6940235be | ||
|
|
4287ac8885 | ||
|
|
08f508f4c3 | ||
|
|
6124eab971 | ||
|
|
65b045e1a2 | ||
|
|
bd28aa0361 | ||
|
|
a5c6ffaa02 | ||
|
|
34c785b92c | ||
|
|
7ad1cb47f3 | ||
|
|
7cb56e9e7f | ||
|
|
4fabee69d8 | ||
|
|
230ccba909 | ||
|
|
b867f25c78 | ||
|
|
9b715c69c0 | ||
|
|
cacc076959 | ||
|
|
7df7aadea8 | ||
|
|
56e619d239 | ||
|
|
0e634ee2ac | ||
|
|
19746c0b76 | ||
|
|
7b844c805d | ||
|
|
723503851b | ||
|
|
57e69907d5 | ||
|
|
f03dac0167 | ||
|
|
8ff983f16e | ||
|
|
10ccf0cc97 | ||
|
|
c510f4eb63 | ||
|
|
12b46a71a2 | ||
|
|
8d860ec3d1 | ||
|
|
265744076c | ||
|
|
2123361ada | ||
|
|
f2fde2cf5c | ||
|
|
14a0c92fb9 | ||
|
|
702e758812 | ||
|
|
63e3896725 | ||
|
|
ddaafa6a04 | ||
|
|
f79a143417 | ||
|
|
3b12f1bc1d | ||
|
|
a7934d58d8 | ||
|
|
ae040727fc | ||
|
|
7998a67e09 | ||
|
|
ade893d33d | ||
|
|
ea64afdbc5 | ||
|
|
3774b1ae81 | ||
|
|
b984f0f36e | ||
|
|
b83a4926dc | ||
|
|
87d5467643 | ||
|
|
562b28365a | ||
|
|
43f03b5430 | ||
|
|
5e579b5bc4 | ||
|
|
d95c42eafc | ||
|
|
ef42921c9a | ||
|
|
33d1193c96 | ||
|
|
794f2aec3a | ||
|
|
ae8fc94979 | ||
|
|
acd7a3bc92 | ||
|
|
2398ee3e8c | ||
|
|
c46b0024f6 | ||
|
|
6733371c2c | ||
|
|
8cb3c377ad | ||
|
|
9f80f0f4d4 | ||
|
|
00385abbf9 | ||
|
|
f0ddfe47ca | ||
|
|
66da042882 | ||
|
|
b36e154b8b | ||
|
|
f2ee03fa08 | ||
|
|
ff640ddc24 | ||
|
|
29c983fb26 | ||
|
|
327f6b3df3 | ||
|
|
4fa8d46631 | ||
|
|
5cbedec5d9 | ||
|
|
c2ae974bbb | ||
|
|
ba5a7f1248 | ||
|
|
70d74c774d | ||
|
|
177421b4ea | ||
|
|
46a3981e7d | ||
|
|
9e63f9228d | ||
|
|
fb52b2a8e5 | ||
|
|
c368871919 | ||
|
|
9271b91113 | ||
|
|
b7136e769f | ||
|
|
7d996ec8e7 | ||
|
|
bfef71382e | ||
|
|
a40f0a7070 | ||
|
|
b62509a28e | ||
|
|
3a68744b9a | ||
|
|
a1264fe4e2 | ||
|
|
2397af140f | ||
|
|
63744b0fbf | ||
|
|
92c81c6dd7 | ||
|
|
197ebad765 | ||
|
|
d7c9a82fa4 | ||
|
|
f45512e0ae | ||
|
|
ad8ec4c8ff | ||
|
|
8a6e2daaf9 | ||
|
|
8086fad450 | ||
|
|
9fba0334a0 | ||
|
|
2087d19d3c | ||
|
|
bf12963247 | ||
|
|
15d995f66b | ||
|
|
772f18fa09 | ||
|
|
74884a3707 | ||
|
|
3132b4fb4d | ||
|
|
1235294b03 | ||
|
|
fa76acbd6d | ||
|
|
03bc363cbf | ||
|
|
8011da2f6a | ||
|
|
7e6244c5d3 | ||
|
|
75f5c8fcd6 | ||
|
|
1241fc7516 | ||
|
|
0aa667b70d | ||
|
|
ca099eced3 | ||
|
|
be1bd81d60 | ||
|
|
9d83a455e8 | ||
|
|
63e6d61c2e | ||
|
|
9beaa8f8cf | ||
|
|
aede1988ec | ||
|
|
16ab746f54 | ||
|
|
b9920f3b6c | ||
|
|
6324199299 | ||
|
|
3cfc8a919a | ||
|
|
b7a1b8bca1 | ||
|
|
c456bd7120 | ||
|
|
d854fa611a | ||
|
|
3c74fa59b7 | ||
|
|
701a38e2f0 | ||
|
|
f67bb9a940 | ||
|
|
5b30dfd43f | ||
|
|
84155d2264 | ||
|
|
3fb8651dd5 | ||
|
|
fb105513e5 | ||
|
|
a178c434b1 | ||
|
|
c32e95a57d | ||
|
|
0ca665a1e9 | ||
|
|
1ecbf20715 | ||
|
|
8de08cf9cc | ||
|
|
9228e5aea3 | ||
|
|
e9ef1e315b | ||
|
|
172ccc6905 | ||
|
|
5d6a328728 | ||
|
|
83bd86e020 | ||
|
|
0f74782d74 | ||
|
|
bf93b02cdc | ||
|
|
4afaa1b0ce | ||
|
|
1a67758470 | ||
|
|
34212d4d45 | ||
|
|
3c5f90e0e3 | ||
|
|
5fad25286c | ||
|
|
359f9e37d2 | ||
|
|
8e7885f36d | ||
|
|
6d2e1c9d08 | ||
|
|
2c6719cf39 | ||
|
|
14f54da852 | ||
|
|
8ad7ad23ae | ||
|
|
0ebe35ac7a | ||
|
|
aac6e172f6 | ||
|
|
e527353974 | ||
|
|
fbd71c1313 | ||
|
|
583e643dac | ||
|
|
415c4ee3f2 | ||
|
|
96a88d2315 | ||
|
|
1a06dd7534 | ||
|
|
7bcf88d5eb | ||
|
|
b8aa37321d | ||
|
|
2f7c1b92a9 | ||
|
|
2d851b6b4e | ||
|
|
65bfccce8f | ||
|
|
69c38d67e4 | ||
|
|
f7db33f2c5 | ||
|
|
a9f16884b0 | ||
|
|
0eff18f5a0 | ||
|
|
abd88f7109 | ||
|
|
7dca0c09ff | ||
|
|
f0589b79ec | ||
|
|
687a68287d | ||
|
|
8b202852a5 | ||
|
|
bdaeef831b | ||
|
|
cab53543e6 | ||
|
|
3344f1b92a | ||
|
|
3af57abc48 | ||
|
|
49ba6feb3a | ||
|
|
2ed5d7208c | ||
|
|
223f102aa9 | ||
|
|
118f6af2b9 | ||
|
|
fc4b54239e | ||
|
|
ff545bf5c9 | ||
|
|
98c0c64e85 |
@@ -1,2 +1,3 @@
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '***.py')
|
||||
black --line-length=100 $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
isort --line-length=100 --profile=black --multi-line=3 --trailing-comma $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
git add $(git diff --cached --name-only --diff-filter=ACM -- '*.py')
|
||||
227
.gitlab-ci.yml
227
.gitlab-ci.yml
@@ -1,33 +1,80 @@
|
||||
# This file is a template, and might need editing before it works on your project.
|
||||
# Official language image. Look for the different tagged releases at:
|
||||
# https://hub.docker.com/r/library/python/tags/
|
||||
image: $CI_DOCKER_REGISTRY/python:3.9
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:3.11
|
||||
#commands to run in the Docker container before starting each job.
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
BEC_CORE_BRANCH: "main"
|
||||
OPHYD_DEVICES_BRANCH: "main"
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "schedule"
|
||||
- if: $CI_PIPELINE_SOURCE == "web"
|
||||
- if: $CI_PIPELINE_SOURCE == "pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "parent_pipeline"
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
|
||||
when: never
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
include:
|
||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
||||
|
||||
- project: "bec/awi_utils"
|
||||
file: "/templates/check-packages-job.yml"
|
||||
inputs:
|
||||
stage: test
|
||||
path: "."
|
||||
pytest_args: "-v --random-order tests/"
|
||||
exclude_packages: ""
|
||||
|
||||
# different stages in the pipeline
|
||||
stages:
|
||||
- Formatter
|
||||
- test
|
||||
- AdditionalTests
|
||||
- End2End
|
||||
- Deploy
|
||||
|
||||
.install-qt-webengine-deps: &install-qt-webengine-deps
|
||||
- apt-get -y install libnss3 libxdamage1 libasound2 libatomic1 libxcursor1
|
||||
- export QTWEBENGINE_DISABLE_SANDBOX=1
|
||||
|
||||
.clone-repos: &clone-repos
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
|
||||
.install-os-packages: &install-os-packages
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx libegl1-mesa x11-utils libxkbcommon-x11-0 libdbus-1-3
|
||||
- *install-qt-webengine-deps
|
||||
|
||||
before_script:
|
||||
- if [[ "$CI_PROJECT_PATH" != "bec/bec_widgets" ]]; then
|
||||
echo -e "\033[35;1m Using branch $CHILD_PIPELINE_BRANCH of BEC Widgets \033[0;m";
|
||||
test -d bec_widgets || git clone --branch $CHILD_PIPELINE_BRANCH https://gitlab.psi.ch/bec/bec_widgets.git; cd bec_widgets;
|
||||
fi
|
||||
|
||||
formatter:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
- pip install black
|
||||
- black --check --diff --color --line-length=100 ./
|
||||
- pip install black isort
|
||||
- isort --check --diff ./
|
||||
- black --check --diff --color ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
script:
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev]
|
||||
- pip install -e .[dev,pyqt6]
|
||||
script:
|
||||
- mkdir ./pylint
|
||||
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
|
||||
@@ -37,25 +84,135 @@ pylint:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint-check:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
allow_failure: true
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- apt-get update
|
||||
- apt-get install -y bc
|
||||
script:
|
||||
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
|
||||
# Identify changed Python files
|
||||
- if [ "$CI_PIPELINE_SOURCE" == "merge_request_event" ]; then
|
||||
TARGET_BRANCH_COMMIT_SHA=$(git rev-parse origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME);
|
||||
CHANGED_FILES=$(git diff --name-only $TARGET_BRANCH_COMMIT_SHA HEAD | grep '\.py$' || true);
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only $CI_COMMIT_BEFORE_SHA $CI_COMMIT_SHA | grep '\.py$' || true);
|
||||
fi
|
||||
- if [ -z "$CHANGED_FILES" ]; then echo "No Python files changed."; exit 0; fi
|
||||
|
||||
- echo "Changed Python files:"
|
||||
- $CHANGED_FILES
|
||||
# Run pylint only on changed files
|
||||
- mkdir ./pylint
|
||||
- pylint $CHANGED_FILES --output-format=text | tee ./pylint/pylint_changed_files.log || pylint-exit $?
|
||||
- PYLINT_SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint_changed_files.log)
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
|
||||
# Fail the job if the pylint score is below 9
|
||||
- if [ "$(echo "$PYLINT_SCORE < 9" | bc)" -eq 1 ]; then echo "Your pylint score is below the acceptable threshold (9)."; exit 1; fi
|
||||
artifacts:
|
||||
paths:
|
||||
- ./pylint/
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- apt-get update
|
||||
- apt-get install -y libgl1-mesa-glx x11-utils libxkbcommon-x11-0
|
||||
- pip install .[dev]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --random-order --full-trace ./tests/unit_tests
|
||||
- coverage report
|
||||
- coverage xml
|
||||
coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
|
||||
artifacts:
|
||||
reports:
|
||||
junit: report.xml
|
||||
cobertura: coverage.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
|
||||
test-matrix:
|
||||
parallel:
|
||||
matrix:
|
||||
- PYTHON_VERSION:
|
||||
- "3.10"
|
||||
- "3.11"
|
||||
- "3.12"
|
||||
QT_PCKG:
|
||||
- "pyside6"
|
||||
- "pyqt6"
|
||||
|
||||
stage: AdditionalTests
|
||||
needs: []
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
PYTHON_VERSION: ""
|
||||
QT_PCKG: ""
|
||||
image: $CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX/python:$PYTHON_VERSION
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
- pip install -e .[dev,$QT_PCKG]
|
||||
- pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
allow_failure: true
|
||||
|
||||
end-2-end-conda:
|
||||
stage: End2End
|
||||
needs: []
|
||||
image: continuumio/miniconda3
|
||||
allow_failure: false
|
||||
variables:
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- conda config --prepend channels conda-forge
|
||||
- conda config --set channel_priority strict
|
||||
- conda config --set always_yes yes --set changeps1 no
|
||||
- conda create -q -n test-environment python=3.11
|
||||
- conda init bash
|
||||
- source ~/.bashrc
|
||||
- conda activate test-environment
|
||||
|
||||
- cd ./bec
|
||||
- source ./bin/install_bec_dev.sh -t
|
||||
|
||||
- pip install -e ./bec_lib[dev]
|
||||
- pip install -e ./bec_ipython_client[dev]
|
||||
- cd ../
|
||||
- pip install -e .[dev,pyqt6]
|
||||
- cd ./tests/end-2-end
|
||||
- pytest -v --start-servers --flush-redis --random-order
|
||||
|
||||
artifacts:
|
||||
when: on_failure
|
||||
paths:
|
||||
- ./logs/*.log
|
||||
expire_in: 1 week
|
||||
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "schedule"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "pipeline"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
|
||||
|
||||
semver:
|
||||
stage: Deploy
|
||||
@@ -71,31 +228,29 @@ semver:
|
||||
- git fetch --tags
|
||||
- git tag
|
||||
|
||||
# build
|
||||
- pip install python-semantic-release==7.* wheel
|
||||
# build and publish package
|
||||
- pip install python-semantic-release==9.* wheel build twine
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- export REPOSITORY_USERNAME=__token__
|
||||
- export REPOSITORY_PASSWORD=$CI_PYPI_TOKEN
|
||||
- >
|
||||
semantic-release publish -v DEBUG
|
||||
-D version_variable=./setup.py:__version__
|
||||
-D hvcs=gitlab
|
||||
|
||||
- semantic-release -vv version
|
||||
|
||||
# check if any artifacts were created
|
||||
- if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
- twine upload dist/* -u __token__ -p $CI_PYPI_TOKEN --skip-existing
|
||||
- semantic-release publish
|
||||
|
||||
allow_failure: false
|
||||
rules:
|
||||
- if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
|
||||
# pages:
|
||||
# stage: Deploy
|
||||
# needs: ["tests"]
|
||||
# script:
|
||||
# - git clone --branch $OPHYD_DEVICES_BRANCH https://oauth2:$CI_OPHYD_DEVICES_KEY@gitlab.psi.ch/bec/ophyd_devices.git
|
||||
# - export OPHYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
# - pip install -r ./docs/source/requirements.txt
|
||||
# - apt-get install -y gcc
|
||||
# - *install-bec-services
|
||||
# - cd ./docs/source; make html
|
||||
# - curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/beamline-experiment-control/221870/
|
||||
# rules:
|
||||
# - if: '$CI_COMMIT_REF_NAME == "master"'
|
||||
# - if: '$CI_COMMIT_REF_NAME == "production"'
|
||||
pages:
|
||||
stage: Deploy
|
||||
needs: ["semver"]
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_REF_NAME
|
||||
rules:
|
||||
- if: "$CI_COMMIT_TAG != null"
|
||||
variables:
|
||||
TARGET_BRANCH: $CI_COMMIT_TAG
|
||||
- if: '$CI_COMMIT_REF_NAME == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
script:
|
||||
- curl -X POST -d "branches=$CI_COMMIT_REF_NAME" -d "token=$RTD_TOKEN" https://readthedocs.org/api/v2/webhook/bec-widgets/253243/
|
||||
|
||||
17
.gitlab/issue_templates/bug_report_template.md
Normal file
17
.gitlab/issue_templates/bug_report_template.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## Bug report
|
||||
|
||||
## Summary
|
||||
|
||||
[Provide a brief description of the bug.]
|
||||
|
||||
## Expected Behavior vs Actual Behavior
|
||||
|
||||
[Describe what you expected to happen and what actually happened.]
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
[Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[Paste links to any related issues or feature requests.]
|
||||
27
.gitlab/issue_templates/documentation_update_template.md
Normal file
27
.gitlab/issue_templates/documentation_update_template.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## Documentation Section
|
||||
|
||||
[Specify the section or page of the documentation that needs updating]
|
||||
|
||||
## Current Information
|
||||
|
||||
[Provide the current information in the documentation that needs to be updated]
|
||||
|
||||
## Proposed Update
|
||||
|
||||
[Describe the proposed update or correction. Be specific about the changes that need to be made]
|
||||
|
||||
## Reason for Update
|
||||
|
||||
[Explain the reason for the documentation update. Include any recent changes, new features, or corrections that necessitate the update]
|
||||
|
||||
## Additional Context
|
||||
|
||||
[Include any additional context or information that can help the documentation team understand the update better]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Attach any files, screenshots, or references that can assist in making the documentation update]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the documentation update based on its urgency. Use a scale such as Low, Medium, High]
|
||||
40
.gitlab/issue_templates/feature_request_template.md
Normal file
40
.gitlab/issue_templates/feature_request_template.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Feature Summary
|
||||
|
||||
[Provide a brief and clear summary of the new feature you are requesting]
|
||||
|
||||
## Problem Description
|
||||
|
||||
[Explain the problem or need that this feature aims to address. Be specific about the issues or gaps in the current functionality]
|
||||
|
||||
## Use Case
|
||||
|
||||
[Describe a real-world scenario or use case where this feature would be beneficial. Explain how it would improve the user experience or workflow]
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
[If you have a specific solution in mind, describe it here. Explain how it would work and how it would address the problem described above]
|
||||
|
||||
## Benefits
|
||||
|
||||
[Explain the benefits and advantages of implementing this feature. Highlight how it adds value to the product or improves user satisfaction]
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
[If you've considered alternative solutions or workarounds, mention them here. Explain why the proposed feature is the preferred option]
|
||||
|
||||
## Impact on Existing Functionality
|
||||
|
||||
[Discuss how the new feature might impact or interact with existing features. Address any potential conflicts or dependencies]
|
||||
|
||||
## Priority
|
||||
|
||||
[Assign a priority level to the feature request based on its importance. Use a scale such as Low, Medium, High]
|
||||
|
||||
## Attachments
|
||||
|
||||
[Include any relevant attachments, such as sketches, diagrams, or references that can help the development team understand your feature request better]
|
||||
|
||||
## Additional Information
|
||||
|
||||
[Provide any additional information that might be relevant to the feature request, such as user feedback, market trends, or similar features in other products]
|
||||
|
||||
28
.gitlab/merge_request_templates/default.md
Normal file
28
.gitlab/merge_request_templates/default.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Description
|
||||
|
||||
[Provide a brief description of the changes introduced by this merge request.]
|
||||
|
||||
## Related Issues
|
||||
|
||||
[Cite any related issues or feature requests that are addressed or resolved by this merge request. Use the gitlab syntax for linking issues, for example, `fixes #123` or `closes #123`.]
|
||||
|
||||
## Type of Change
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## Potential side effects
|
||||
|
||||
[Describe any potential side effects or risks of merging this MR.]
|
||||
|
||||
## Screenshots / GIFs (if applicable)
|
||||
|
||||
[Include any relevant screenshots or GIFs to showcase the changes made.]
|
||||
|
||||
## Additional Comments
|
||||
|
||||
[Add any additional comments or information that may be helpful for reviewers.]
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Documentation is up-to-date.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
# run arbitrary code.
|
||||
extension-pkg-allow-list=PyQt5, pyqtgraph
|
||||
extension-pkg-allow-list=PyQt6, PySide6, pyqtgraph
|
||||
|
||||
# A comma-separated list of package or module names from where C extensions may
|
||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
||||
@@ -52,7 +52,7 @@ persistent=yes
|
||||
|
||||
# Minimum Python version to use for version dependent checks. Will default to
|
||||
# the version used to run pylint.
|
||||
py-version=3.9
|
||||
py-version=3.10
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
|
||||
25
.readthedocs.yaml
Normal file
25
.readthedocs.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# .readthedocs.yaml
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
tools:
|
||||
python: "3.10"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
# If using Sphinx, optionally build your docs in additional formats such as PDF
|
||||
# formats:
|
||||
# - pdf
|
||||
|
||||
# Optionally declare the Python requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
183
CHANGELOG.md
183
CHANGELOG.md
@@ -1,142 +1,143 @@
|
||||
# Changelog
|
||||
# CHANGELOG
|
||||
|
||||
<!--next-version-placeholder-->
|
||||
## v0.80.1 (2024-07-06)
|
||||
|
||||
## v0.7.0 (2023-08-28)
|
||||
### Fix
|
||||
|
||||
* fix(entry_validator): check for entry == "" ([`61de7e9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/61de7e9e221c766b9fb3ec23246da6a11c96a986))
|
||||
|
||||
## v0.80.0 (2024-07-06)
|
||||
|
||||
### Feature
|
||||
|
||||
* Labels of current motors are shown in motors limits ([`413e435`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/413e4356cfde6e2432682332e470eb69427ad397))
|
||||
* Total number of points, scatter size and number of point to dim after last position can be changed from GUI ([`e0b52fc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e0b52fcedca46d913d1677b45f9815eccd92e8f7))
|
||||
* Speed and frequency can be updated from GUI ([`f391a2f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f391a2fd004f1dc8187cfe12d60f856427ae01ec))
|
||||
* Speed and frequency is retrieved from devices ([`ce98164`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ce9816480b82373895b602d1a1bca7d1d9725f01))
|
||||
* Delete coordinate table row by DELETE or BACKSPACE key ([`5dd0af6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5dd0af6894a5d97457d60ef18b098e40856e4875))
|
||||
* Motor selection ([`cab32be`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cab32be0092185870b5a12398103475342c8b1fd))
|
||||
* New GUI ([`0226188`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0226188079f1dac4eece6b1a6fa330620f1504bc))
|
||||
* Keyboard shortcut to go to coordinates ([`3c0e595`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3c0e5955d40a67935b8fb064d5c52fd3f29bd1a1))
|
||||
* Ability to choose how many points should be dimmed before reaching the threshold + total number of point which should be stored. ([`9eae697`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/9eae697df8a2f3961454db9ed397353f110c0e67))
|
||||
* Stop movement function, one callback function for 2 motors, move_finished is emitted in move_motor function not in callback ([`187c748`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/187c748e87264448d5026d9fa2f15b5fc9a55949))
|
||||
* Controls are disabled while motor is moving and enabled when motor movement is finished ([`ed84293`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ed842931971fbf87ed2f3e366eb822531ef5aacc))
|
||||
* Motor coordinates are now scatter instead of image ([`3f6d5c6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3f6d5c66411459703c402f7449e8b1abae9a2b08))
|
||||
* Going to absolute coordinates saves coordinate in the table for later use with tag ([`8be98c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8be98c9bb6af941a69c593c62d5c52339d2262bc))
|
||||
* Table with coordinates getting initial coordinates of motor ([`92388c3`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/92388c3cab7e024978aaa2906afbd698015dec66))
|
||||
* Motor move to absolute (X,Y) coordinates ([`cbe27e4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cbe27e46cfb6282c71844641e1ed6059e8fa96bf))
|
||||
* Motor limits can be changed by spinBoxes ([`2d1665c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/2d1665c76b8174d9fffa3442afa98fe1ea6ac207))
|
||||
* Switch for keyboard shortcuts for motor movement ([`cac4562`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cac45626fc9a315f9012b110760a92e27e5ed226))
|
||||
* Setting map according to motor limits ([`512e698`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/512e698e26d9eef05b4f430475ccc268b68ad632))
|
||||
* Map of motor position ([`e6952a6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e6952a6d13c84487fd6ab08056f1f5b46d594b8a))
|
||||
* Motor_example.py created, motor samx and samy can be moved by buttons ([`947ba9f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/947ba9f8b730e96082cb51ff6894734a0e119ca1))
|
||||
* feat(qt5): dropped support for qt5; pyside2 and pyqt5 ([`fadbf77`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/fadbf77866903beff6580802bc203d53367fc7e7))
|
||||
|
||||
* feat(plugins): moved plugin dict to dataclass and container ([`03819a3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/03819a3d902b4a51f3e882d52aedd971b2a8e127))
|
||||
|
||||
* feat(plugins): added support for pyqt6 ui files ([`d6d0777`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d6d07771135335cb78dc648508ce573b8970261a))
|
||||
|
||||
* feat(plugins): added bec widgets base class ([`1aa83e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1aa83e0ef1ffe45b01677b0b4590535cb0ca1cff))
|
||||
|
||||
## v0.79.3 (2024-07-05)
|
||||
|
||||
### Fix
|
||||
|
||||
* Line_plot.py default changed back to "gauss_bpm" ([`64708bc`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/64708bc1b2e6a4256da9123d0215fc87e0afa455))
|
||||
* Motor selection is disabled while motor is moving ([`c7e35d7`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c7e35d7da69853343aa7eee53c8ad988eb490d93))
|
||||
* Init_motor_map receive motor position from motor_thread ([`95ead71`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/95ead7117e59e0979aec51b85b49537ab728cad4))
|
||||
* Motor movement absolute fixed - movement by thread ([`11aa15f`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/11aa15fefda7433e885cc8586f93c97af83b0c48))
|
||||
* fix: changed inheritance to adress qt designer bug in rendering ([`e403870`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e403870874bd5e45840a034d6f1b3dd576d9c846))
|
||||
|
||||
## v0.6.3 (2023-08-17)
|
||||
* fix: add designer plugin classes ([`1586ce2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1586ce2d6cba2bb086b2ef596e724bb9e40ab4f2))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor: simplify logic in bec_status_box ([`576353c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/576353cfe8c6fd64db561f0b6e2bc951300643d3))
|
||||
|
||||
## v0.79.2 (2024-07-04)
|
||||
|
||||
### Fix
|
||||
|
||||
* Crosshair handles dynamic changes of number of curves in 1D plot ([`242737b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/242737b516af7c524a6c8a98db566815f0f4ab65))
|
||||
* fix: overwrite closeEvent and call super class ([`bc0ef78`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bc0ef7893ef100b71b62101c459655509b534a56))
|
||||
|
||||
### Documentation
|
||||
|
||||
* Crosshair class documentation ([`8a60cad`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8a60cad9187df2b2bc93dc78dd01ceb42df9c9af))
|
||||
|
||||
## v0.6.2 (2023-08-17)
|
||||
## v0.79.1 (2024-07-03)
|
||||
|
||||
### Fix
|
||||
|
||||
* Correct coordinates for cursor table ([`ce54daf`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/ce54daf754cb2410790216585467c0ffcc8e3587))
|
||||
* fix: use libdir env var to preload Python library, also for Linux platform ([`d7718d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d7718d4dcb9728c050b6421388af4d484f3741f2))
|
||||
|
||||
## v0.6.1 (2023-08-14)
|
||||
|
||||
### Fix
|
||||
|
||||
* Crosshair snaps to correct coordinates also with logx and logy ([`167a891`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/167a891c474b09ef7738e473c4a2e89dbbcbe881))
|
||||
|
||||
## v0.6.0 (2023-08-11)
|
||||
## v0.79.0 (2024-07-03)
|
||||
|
||||
### Feature
|
||||
|
||||
* New GUI for line_plot.py ([`b57b3bb`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/b57b3bb1afc7c85acc7ed328ac8a219f392869f1))
|
||||
* Cursor universal signals ([`20e9516`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/20e951659558b7fc023e357bfe07d812c5fd020a))
|
||||
* feat(motor_map_widget): standalone MotorMap Widget with toolbar + plugin ([`6e75642`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6e756420907d7093557e945bc92bc4cfc0138d07))
|
||||
|
||||
## v0.5.0 (2023-08-11)
|
||||
|
||||
### Feature
|
||||
|
||||
* Add generic connect function for slots ([`6a3df34`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/6a3df34cdfbec2434153362ded630305e5dc5e28))
|
||||
* Add possibility to provide service config ([`8c9a9c9`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8c9a9c93535ee77c0622b483a3157af367ebce1f))
|
||||
* feat(motor_map): method to reset history trace ([`5960918`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5960918137dd41cdeb94e50f8abc4f169cf45c11))
|
||||
|
||||
### Fix
|
||||
|
||||
* Dispatcher argparse and scan_plot tests ([`67f619e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/67f619ee897e0040c6310e67d69fbb2e0685293d))
|
||||
* Gui event removing bugs ([`a9dd191`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a9dd191629295ca476e2f9a1b9944ff355216583))
|
||||
* fix(toolbar): change default color to black to match BECFigure theme ([`b8774e0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8774e0b0bc43dcd00f94f42539a778e507ca27d))
|
||||
|
||||
## v0.4.0 (2023-08-11)
|
||||
* fix(motor_map): fixed bug with residual trace after changing motors ([`aaa0d10`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/aaa0d1003d2e94b45bafe4f700852c2c05288aea))
|
||||
|
||||
### Feature
|
||||
* fix(widget_io): widget handler adjusted for spinboxes and comboboxes ([`3dc0532`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3dc0532df05b6ec0a2522107fa0b1e210ce7d91b))
|
||||
|
||||
* Cursor universal for 1D and 2D ([`f75554b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/f75554bd7b072207847956a8720b9a62c20ba2c8))
|
||||
* Added qt_utils package with general Crosshair function ([`5353fed`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/5353fed7bfe1819819fa3348ec93d2d0ba540628))
|
||||
* 2D plot updating ([`d32088b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d32088b643a4d0613c32fb464a0a55a3b6b684d6))
|
||||
* Metadata available on_dap_update ([`18b5d46`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/18b5d46678619a972815532629ce96c121f5fcc9))
|
||||
* Plotting from streamer ([`bb806c1`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/bb806c149dee88023ecb647b523cbd5189ea9001))
|
||||
* Added Legend to plot ([`0feca4b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0feca4b1578820ec1f5f3ead3073e4d45c23798b))
|
||||
* Cursor coordinate as a QTable ([`a999f76`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a999f7669a12910ad66e10a6d2e75197b2dce1c2))
|
||||
* Changed from PlotItem to GraphicsLayoutWidget, added LabelItem ([`075cc79`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/075cc79d6fa011803cf4a06fbff8faa951c1b59f))
|
||||
* Add display_ui_file.py ([`91d8ffa`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/91d8ffacffcbeebdf7623caf62e07244c4dcee16))
|
||||
* Add disconnect_dap_slot ([`1325704`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1325704750ebab897e3dcae80c9d455bfbbf886f))
|
||||
* Inherit from GraphicsView for consistency with 2D plot ([`d8c101c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d8c101cdd7f960a152a1f318911cac6eecf6bad4))
|
||||
* Add BECScanPlot2D ([`67905e8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/67905e896c81383f57c268db544b3314104bda38))
|
||||
* Emit the full bec message to slots ([`1bb3020`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/1bb30207038f3a54c0e96dbbbcd1ea7f6c70eca2))
|
||||
### Refactor
|
||||
|
||||
* refactor(toolbar): cleanup and adjusted colors ([`96863ad`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/96863adf53c15112645d20eb6200733617801c6d))
|
||||
|
||||
## v0.78.1 (2024-07-02)
|
||||
|
||||
### Fix
|
||||
|
||||
* Q selection for gui_event signal ([`0bf452a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/0bf452ad1b7d9ad941e2ef4b8d61ec4ed5266415))
|
||||
* Fixed logic in data subscription ([`c2d469b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/c2d469b4543fcf237b274399b83969cc2213b61b))
|
||||
* Scan_plot to accept metadata from dap signal ([`7bec0b5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/7bec0b5e6c1663670f8fcc2fc6aa6c8b6df28b61))
|
||||
* Plotting latest 1d curves ([`378be81`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/378be81bf6dd5e9239f8f1fb908cafc97161c79d))
|
||||
* Testing the data structure of plotting ([`4fb0a3b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/4fb0a3b058957f5b37227ff7c8e9bdf5259a1cde))
|
||||
* Fix examples when run directly as a script ([`cd11ee5`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/cd11ee51c1c725255e748a32b89a74487e84a631))
|
||||
* Module paths ([`e7f644c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/e7f644c5079a8665d7d872eb0b27ed7da6cbd078))
|
||||
* fix(ui_loader): ui loader is compatible with bec plugins ([`b787759`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b787759f44486dc7af2c03811efb156041e4b6cb))
|
||||
|
||||
## v0.3.0 (2023-07-19)
|
||||
## v0.78.0 (2024-07-02)
|
||||
|
||||
### Feature
|
||||
|
||||
* Add auto-computed color_list from colormaps ([`3e1708b`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/3e1708bf48bc15a25c0d01242fff28d6db868e02))
|
||||
* Add functionality for plotting multiple signals ([`10e2906`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/10e29064455f50bc3b66c55b4361575957db1489))
|
||||
* Added lineplot widget ([`989a3f0`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/989a3f080839b98f1e1c2118600cddf449120124))
|
||||
* Added ctrl_c from grum ([`8fee13a`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/8fee13a67bef3ed6ed6de9d47438f04687f548d8))
|
||||
* feat(color_button): patched ColorButton from pyqtgraph to be able to be opened in another QDialog ([`c36bb80`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c36bb80d6a4939802a4a1c8e5452c7b94bac185e))
|
||||
|
||||
## v0.77.0 (2024-07-02)
|
||||
|
||||
### Feature
|
||||
|
||||
* feat(bec_connector): export config to yaml ([`a391f30`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a391f3018c50fee6a4a06884491b957df80c3cd3))
|
||||
|
||||
* feat(utils): colors added convertor for rgba to hex ([`572f2fb`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/572f2fb8110d5cb0e80f3ca45ce57ef405572456))
|
||||
|
||||
### Fix
|
||||
|
||||
* Add warning for non-existing signalz ([`48075e4`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/48075e4fe3187f6ac8d0b61f94f8df73b8fd6daf))
|
||||
* Documentation and bugfix for mouse_moved ([`a460f3c`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/a460f3c0bd7b9e106a758bc330f361868407b1e3))
|
||||
* fix(waveform): scatter 2D brush error ([`215d59c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/215d59c8bfe7fda9aff8cec8353bef9e1ce2eca1))
|
||||
|
||||
### Documentation
|
||||
* fix(figure): API cleanup ([`008a33a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/008a33a9b192473cc58e90cd6d98c5bcb5f7b8c0))
|
||||
|
||||
* Add notes about qt designer install via conda-forge ([`d8038a8`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/d8038a8cd0efa3a16df403390164603e4e8afdd8))
|
||||
* Added license ([`db2d33e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/db2d33e8912dc493cce9ee7f09d8336155110079))
|
||||
* fix(figure): if/else logic corrected in subplot_factory ([`3e78723`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3e787234c7274b0698423d7bf9a4c54ec46bad5f))
|
||||
|
||||
## v0.2.1 (2023-07-13)
|
||||
* fix(image): processing of already displayed data; closes #106 ([`1173510`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/1173510105d2d70d7e498c2ac1e122cea3a16597))
|
||||
|
||||
* fix(bec_figure): full reconstruction with config from other bec figure ([`b6e1e20`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b6e1e20b7c8549bb092e981062329e601411dda6))
|
||||
|
||||
* fix(motor_map): API changes updates current visualisation; motor_map can be initialised from config ([`2e2d422`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2e2d422910685a2527a3d961a468c787f771ca44))
|
||||
|
||||
* fix(image): image add_custom_image fixed, closes #225 ([`f0556e4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f0556e44113ffee66cf735aa2dd758c62cb634f4))
|
||||
|
||||
* fix(figure): subplot methods consolidated; added subplot factory ([`4a97105`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/4a97105e4bd2ce77d72dfe5f8307dd9ee65b21b0))
|
||||
|
||||
* fix(image): image can be fully reconstructed from config ([`797f73c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/797f73c39aa73e07d6311f3de4baea53f6c380e0))
|
||||
|
||||
* fix(image_item): vrange added int for pydantic model check ([`b8f796f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b8f796fd3fcc15641e8fc6a3ca75c344ce90fc45))
|
||||
|
||||
* fix(bec_figure): waveforms can be initialised from the config; widgets are deleteLater after removal ([`78673ea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/78673ea11a47aad878128197ae6213925228ed59))
|
||||
|
||||
### Unknown
|
||||
|
||||
* Resolve "add VT100 console executing BEC as a widget" ([`c6a14c0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c6a14c0768a90695567a83a7895247ed0c64f3ce))
|
||||
|
||||
## v0.76.1 (2024-06-29)
|
||||
|
||||
### Fix
|
||||
|
||||
* Fixed setup config (wrong name) ([`947db1e`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/947db1e0f32b067e67f94a7c8321da5194b1547b))
|
||||
* Fixed bec_lib dependency ([`86f4def`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/86f4deffd899111e8997010487ec54c6c62c43ab))
|
||||
* fix(plugins): fixes and tests for auto-gen plugins ([`c42511d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c42511dd44cc13577e108a6cef3166376e594f54))
|
||||
|
||||
## v0.2.0 (2023-07-13)
|
||||
## v0.76.0 (2024-06-28)
|
||||
|
||||
### Feature
|
||||
|
||||
* Move ivan's qtwidgets to bec-widgets ([`34e5ed2`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/34e5ed2cf7e6128a51c110db8870d9560f2b2831))
|
||||
* feat(designer): added support for creating designer plugins automatically ([`c1dd0ee`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c1dd0ee1906dba1f2e2ae9ce40a84d55c26a1cce))
|
||||
|
||||
## v0.1.0 (2023-07-11)
|
||||
### Fix
|
||||
|
||||
* fix: fixed qwidget inheritance for ring progress bar ([`0610d2f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0610d2f9f027f8659e7149f2dfbb316ff30e337d))
|
||||
|
||||
### Unknown
|
||||
|
||||
* fix:parent set as first kwarg TextBox and WebsiteWidget ([`a45c407`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a45c4075684b93bfdcee03e5a416b84f61d3bc6f))
|
||||
|
||||
## v0.75.0 (2024-06-26)
|
||||
|
||||
### Feature
|
||||
|
||||
* Added config plotter ([`db274c6`](https://gitlab.psi.ch/bec/bec-widgets/-/commit/db274c644f643f830c35b6a92edd328bf7e24f59))
|
||||
* feat(widgets): added simple bec queue widget ([`3faee98`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3faee98ec80041a27e4c1f1156178de6f9dcdc63))
|
||||
|
||||
### Refactor
|
||||
|
||||
* refactor(dispatcher): cleanup ([`ca02132`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ca02132c8d18535b37e9192e00459d2aca6ba5cf))
|
||||
|
||||
## v0.74.1 (2024-06-26)
|
||||
|
||||
72
README.md
72
README.md
@@ -1,2 +1,74 @@
|
||||
# BEC Widgets
|
||||
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
## Installation
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets PyQt6
|
||||
```
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyqt6]
|
||||
```
|
||||
|
||||
BEC Widgets currently supports both Pyside6 and PyQt6, however, no default distribution is specified. As a result, users must install one of the supported
|
||||
Python Qt distributions manually.
|
||||
|
||||
To select a specific Python Qt distribution, install the package with an additional tag:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyqt6]
|
||||
```
|
||||
or
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
> │ │
|
||||
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
|
||||
> │ elements|forms|http|language-service|localize|platform-browser|
|
||||
> │ platform-browser-dynamic|platform-server|router|service-worker|
|
||||
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
|
||||
> │ devtools
|
||||
> │
|
||||
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
> ```
|
||||
>
|
||||
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
> ##### Type
|
||||
>
|
||||
> Must be one of the following:
|
||||
>
|
||||
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
> * **docs**: Documentation only changes
|
||||
> * **feat**: A new feature
|
||||
> * **fix**: A bug fix
|
||||
> * **perf**: A code change that improves performance
|
||||
> * **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
> * **test**: Adding missing tests or correcting existing tests
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
BIN
bec_widgets/assets/bec_widgets_icon.png
Normal file
BIN
bec_widgets/assets/bec_widgets_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
bec_widgets/assets/terminal_icon.png
Normal file
BIN
bec_widgets/assets/terminal_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,457 +0,0 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.core import BECMessage
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtWidgets import QCheckBox, QTableWidgetItem
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets, uic
|
||||
from pyqtgraph.Qt.QtCore import pyqtSignal
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
from bec_lib.core.redis_connector import MessageObject, RedisConnector
|
||||
|
||||
client = bec_dispatcher.client
|
||||
|
||||
|
||||
class BasicPlot(QtWidgets.QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
roi_signal = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
|
||||
"""
|
||||
Basic plot widget for displaying scan data.
|
||||
|
||||
Args:
|
||||
name (str, optional): Name of the plot. Defaults to "".
|
||||
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
|
||||
"""
|
||||
|
||||
super(BasicPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "basic_plot.ui"), self)
|
||||
|
||||
# Set splitter distribution of widgets
|
||||
self.splitter.setSizes([3, 1])
|
||||
|
||||
self._idle_time = 100
|
||||
self.title = ""
|
||||
self.label_bottom = ""
|
||||
self.label_left = ""
|
||||
self.producer = RedisConnector(["localhost:6379"]).producer()
|
||||
|
||||
self.scan_motors = []
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
self.curves = []
|
||||
self.pens = []
|
||||
self.brushs = []
|
||||
|
||||
self.plotter_scan_id = None
|
||||
|
||||
# TODO to be moved to utils function
|
||||
plotstyles = {
|
||||
"symbol": "o",
|
||||
"symbolSize": 10,
|
||||
}
|
||||
|
||||
color_list = BasicPlot.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
|
||||
|
||||
# setup plots - GraphicsLayoutWidget
|
||||
# LabelItem
|
||||
self.label = pg.LabelItem(justify="center")
|
||||
self.glw.addItem(self.label)
|
||||
self.label.setText("ROI region")
|
||||
|
||||
# PlotItem - main window
|
||||
self.glw.nextRow()
|
||||
self.plot = pg.PlotItem()
|
||||
self.plot.setLogMode(True, True)
|
||||
self.glw.addItem(self.plot)
|
||||
self.plot.addLegend()
|
||||
|
||||
# ImageItem - 2D view #TODO add 2D plot for ROI and 1D plot for mouse click
|
||||
self.glw.nextRow()
|
||||
self.plot_roi = pg.PlotItem()
|
||||
self.img = pg.ImageItem()
|
||||
self.glw.addItem(self.plot_roi)
|
||||
self.plot_roi.addItem(self.img)
|
||||
|
||||
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
|
||||
self.roi_selector = pg.LinearRegionItem([-1, 1])
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
brush = mkBrush(color=color_list[ii])
|
||||
curve = pg.PlotDataItem(
|
||||
**plotstyles, symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value
|
||||
)
|
||||
self.plot.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self.pens.append(pen)
|
||||
self.brushs.append(brush)
|
||||
|
||||
self.add_crosshair(self.plot)
|
||||
self.add_crosshair(self.plot_roi)
|
||||
|
||||
self.crosshair_v = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.crosshair_h = pg.InfiniteLine(angle=0, movable=False)
|
||||
#
|
||||
# for plot in (self.plot_roi, self.plot):
|
||||
# plot.addItem(self.crosshair_v, ignoreBounds=True)
|
||||
# plot.addItem(self.crosshair_h, ignoreBounds=True)
|
||||
|
||||
# self.plot.addItem(self.crosshair_v, ignoreBounds=True)
|
||||
# self.plot.addItem(self.crosshair_h, ignoreBounds=True)
|
||||
|
||||
# self.plot_roi.addItem(self.crosshair_v, ignoreBounds=True)
|
||||
# self.plot_roi.addItem(self.crosshair_h, ignoreBounds=True)
|
||||
|
||||
# Add textItems
|
||||
self.add_text_items()
|
||||
|
||||
# Manage signals
|
||||
self.proxy = pg.SignalProxy(
|
||||
self.plot.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
|
||||
)
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
|
||||
|
||||
# Debug functions
|
||||
self.pushButton_debug.clicked.connect(self.generate_2D_data_update)
|
||||
# self.generate_2D_data()
|
||||
|
||||
self._current_proj = None
|
||||
self._current_metadata_ep = "px_stream/projection_{}/metadata"
|
||||
|
||||
self.data_retriever = threading.Thread(target=self.on_projection, daemon=True)
|
||||
self.data_retriever.start()
|
||||
|
||||
def debug(self):
|
||||
"""
|
||||
Debug button just for quick testing
|
||||
"""
|
||||
|
||||
def generate_2D_data(self):
|
||||
data = np.random.normal(size=(1, 100))
|
||||
self.img.setImage(data)
|
||||
|
||||
def generate_2D_data_update(self):
|
||||
data = np.random.normal(size=(200, 300))
|
||||
self.img.setImage(data, levels=(0.2, 0.5))
|
||||
|
||||
def add_crosshair(self, plot):
|
||||
crosshair_v = pg.InfiniteLine(angle=90, movable=False)
|
||||
crosshair_h = pg.InfiniteLine(angle=0, movable=False)
|
||||
|
||||
plot.addItem(crosshair_v)
|
||||
plot.addItem(crosshair_h)
|
||||
|
||||
def get_roi_region(self):
|
||||
"""For testing purpose now, get roi region and print it to self.label as tuple"""
|
||||
region = self.roi_selector.getRegion()
|
||||
self.label.setText(f"x = {(10**region[0]):.4f}, y ={(10**region[1]):.4f}")
|
||||
return_dict = {
|
||||
"horiz_roi": [
|
||||
np.where(self.plotter_data_x[0] > 10 ** region[0])[0][0],
|
||||
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
|
||||
]
|
||||
}
|
||||
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
|
||||
self.producer.set_and_publish("px_stream/gui_event", msg=msg)
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
def add_text_items(self): # TODO probably can be removed
|
||||
"""Add text items to the plot"""
|
||||
|
||||
# self.mouse_box_data.setText("Mouse cursor")
|
||||
# TODO Via StyleSheet, one may set the color of the full QLabel
|
||||
# self.mouse_box_data.setStyleSheet(f"QLabel {{color : rgba{self.pens[0].color().getRgb()}}}")
|
||||
|
||||
def mouse_moved(self, event: tuple) -> None:
|
||||
"""
|
||||
Update the mouse table with the current mouse position and the corresponding data.
|
||||
|
||||
Args:
|
||||
event (tuple): Mouse event containing the position of the mouse cursor.
|
||||
The position is stored in first entry as horizontal, vertical pixel.
|
||||
"""
|
||||
pos = event[0]
|
||||
if not self.plot.sceneBoundingRect().contains(pos):
|
||||
return
|
||||
mousePoint = self.plot.vb.mapSceneToView(pos)
|
||||
self.crosshair_v.setPos(mousePoint.x())
|
||||
self.crosshair_h.setPos(mousePoint.y())
|
||||
if not self.plotter_data_x:
|
||||
return
|
||||
|
||||
closest_point = self.closest_x_y_value(
|
||||
mousePoint.x(), self.plotter_data_x[0], self.plotter_data_y[0]
|
||||
)
|
||||
# self.precision = 3
|
||||
# ii = 0
|
||||
# y_value = self.y_value_list[ii]
|
||||
# x_data = f"{10**closest_point[0]:.{self.precision}f}"
|
||||
# y_data = f"{10**closest_point[1]:.{self.precision}f}"
|
||||
#
|
||||
# # Write coordinate to QTable
|
||||
# self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
|
||||
# self.mouse_table.setItem(ii, 2, QTableWidgetItem(str(x_data)))
|
||||
# self.mouse_table.setItem(ii, 3, QTableWidgetItem(str(y_data)))
|
||||
#
|
||||
# self.mouse_table.resizeColumnsToContents()
|
||||
|
||||
def closest_x_y_value(self, input_value, list_x, list_y) -> tuple:
|
||||
"""
|
||||
Find the closest x and y value to the input value.
|
||||
|
||||
Args:
|
||||
input_value (float): Input value
|
||||
list_x (list): List of x values
|
||||
list_y (list): List of y values
|
||||
|
||||
Returns:
|
||||
tuple: Closest x and y value
|
||||
"""
|
||||
arr = np.asarray(list_x)
|
||||
i = (np.abs(arr - input_value)).argmin()
|
||||
return list_x[i], list_y[i]
|
||||
|
||||
def update(self):
|
||||
"""Update the plot with the new data."""
|
||||
# check if roi selector is in the plot
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
# check if QTable was initialised and if list of devices was changed
|
||||
if self.y_value_list != self.previous_y_value_list:
|
||||
self.setup_cursor_table()
|
||||
self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
|
||||
|
||||
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
|
||||
# if len(self.plotter_data_x[0]) <= 1:
|
||||
# return
|
||||
# self.plot.setLabel("bottom", self.label_bottom)
|
||||
# self.plot.setLabel("left", self.label_left)
|
||||
# for ii in range(len(self.y_value_list)):
|
||||
# self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, data: dict, metadata: dict) -> None:
|
||||
"""Update function that is called during the scan callback. To avoid
|
||||
too many renderings, the GUI is only processing events every <_idle_time> ms.
|
||||
|
||||
Args:
|
||||
data (dict): Dictionary containing a new scan segment
|
||||
metadata (dict): Scan metadata
|
||||
|
||||
"""
|
||||
if metadata["scanID"] != self.plotter_scan_id:
|
||||
self.plotter_scan_id = metadata["scanID"]
|
||||
self._reset_plot_data()
|
||||
|
||||
self.title = f"Scan {metadata['scan_number']}"
|
||||
|
||||
self.scan_motors = scan_motors = metadata.get("scan_report_devices")
|
||||
# client = BECClient()
|
||||
remove_y_value_index = [
|
||||
index
|
||||
for index, y_value in enumerate(self.y_value_list)
|
||||
if y_value not in client.device_manager.devices
|
||||
]
|
||||
if remove_y_value_index:
|
||||
for ii in sorted(remove_y_value_index, reverse=True):
|
||||
# TODO Use bec warning message??? to be discussed with Klaus
|
||||
warnings.warn(
|
||||
f"Warning: no matching signal for {self.y_value_list[ii]} found in list of devices. Removing from plot."
|
||||
)
|
||||
self.remove_curve_by_name(self.plot, self.y_value_list[ii])
|
||||
self.y_value_list.pop(ii)
|
||||
|
||||
self.precision = client.device_manager.devices[scan_motors[0]]._info["describe"][
|
||||
scan_motors[0]
|
||||
]["precision"]
|
||||
# TODO after update of bec_lib, this will be new way to access data
|
||||
# self.precision = client.device_manager.devices[scan_motors[0]].precision
|
||||
|
||||
x = data["data"][scan_motors[0]][scan_motors[0]]["value"]
|
||||
self.plotter_data_x.append(x)
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
y = data["data"][y_value][y_value]["value"]
|
||||
self.plotter_data_y[ii].append(y)
|
||||
self.label_bottom = scan_motors[0]
|
||||
self.label_left = f"{', '.join(self.y_value_list)}"
|
||||
|
||||
# print(f'metadata scan N{metadata["scan_number"]}') #TODO put as label on top of plot
|
||||
# print(f'Data point = {data["point_id"]}') #TODO can be used for progress bar
|
||||
|
||||
if len(self.plotter_data_x) <= 1:
|
||||
return
|
||||
self.update_signal.emit()
|
||||
|
||||
def _reset_plot_data(self):
|
||||
"""Reset the plot data."""
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
for ii in range(len(self.y_value_list)):
|
||||
self.curves[ii].setData([], [])
|
||||
self.plotter_data_y.append([])
|
||||
|
||||
def setup_cursor_table(self):
|
||||
"""QTable formatting according to N of devices displayed in plot."""
|
||||
|
||||
# Init number of rows in table according to n of devices
|
||||
self.mouse_table.setRowCount(len(self.y_value_list))
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setChecked(True)
|
||||
# TODO just for testing, will be replaced by removing/adding curve
|
||||
checkbox.stateChanged.connect(lambda: print("status Changed"))
|
||||
# checkbox.stateChanged.connect(lambda: self.remove_curve_by_name(plot=self.plot, checkbox=checkbox, name=y_value))
|
||||
self.mouse_table.setCellWidget(ii, 0, checkbox)
|
||||
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
|
||||
|
||||
self.mouse_table.resizeColumnsToContents()
|
||||
|
||||
@staticmethod
|
||||
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
|
||||
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
|
||||
"""Removes a curve from the given plot by the specified name.
|
||||
|
||||
Args:
|
||||
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
|
||||
name (str): The name of the curve to remove.
|
||||
"""
|
||||
# if checkbox.isChecked():
|
||||
for item in plot.items:
|
||||
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
|
||||
plot.removeItem(item)
|
||||
return
|
||||
|
||||
# else:
|
||||
# return
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = BasicPlot.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
def on_projection(self):
|
||||
while True:
|
||||
if self._current_proj is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
endpoint = f"px_stream/projection_{self._current_proj}/data"
|
||||
msgs = client.producer.lrange(topic=endpoint, start=-1, end=-1)
|
||||
data = [BECMessage.DeviceMessage.loads(msg) for msg in msgs]
|
||||
if not data:
|
||||
continue
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
self.plotter_data_y = [
|
||||
np.sum(
|
||||
np.sum(data[-1].content["signals"]["data"] * self._current_norm, axis=1)
|
||||
/ np.sum(self._current_norm, axis=0),
|
||||
axis=0,
|
||||
).squeeze()
|
||||
]
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_dap_update(self, data: dict, metadata: dict):
|
||||
self.img.setImage(data["z"].T)
|
||||
# time.sleep(0,1)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def new_proj(self, data):
|
||||
proj_nr = data["proj_nr"]
|
||||
endpoint = f"px_stream/projection_{proj_nr}/metadata"
|
||||
msg_raw = client.producer.get(topic=endpoint)
|
||||
msg = BECMessage.DeviceMessage.loads(msg_raw)
|
||||
self._current_q = msg.content["signals"]["q"]
|
||||
self._current_norm = msg.content["signals"]["norm_sum"]
|
||||
self._current_metadata = msg.content["signals"]["metadata"]
|
||||
|
||||
self.plotter_data_x = [self._current_q]
|
||||
self._current_proj = proj_nr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--signals",
|
||||
help="specify recorded signals",
|
||||
nargs="+",
|
||||
default=["gauss_bpm"],
|
||||
)
|
||||
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
|
||||
value = parser.parse_args()
|
||||
print(f"Plotting signals for: {', '.join(value.signals)}")
|
||||
client = bec_dispatcher.client
|
||||
# client.start()
|
||||
app = QtWidgets.QApplication([])
|
||||
ctrl_c.setup(app)
|
||||
plot = BasicPlot(y_value_list=value.signals)
|
||||
# bec_dispatcher.connect(plot)
|
||||
bec_dispatcher.connect_proj_id(plot.new_proj)
|
||||
bec_dispatcher.connect_dap_slot(plot.on_dap_update, "px_dap_worker")
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec_()
|
||||
@@ -1,77 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>845</width>
|
||||
<height>635</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Line Plot</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="opaqueResize">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="verticalLayoutWidget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_debug">
|
||||
<property name="text">
|
||||
<string>Debug</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="GraphicsLayoutWidget" name="glw"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QTableWidget" name="mouse_table">
|
||||
<property name="textElideMode">
|
||||
<enum>Qt::ElideMiddle</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Display</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Device</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,227 +0,0 @@
|
||||
import argparse
|
||||
import itertools
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from threading import RLock
|
||||
|
||||
from bec_lib import BECClient
|
||||
from bec_lib.core import BECMessage, MessageEndpoints, ServiceConfig
|
||||
from bec_lib.core.redis_connector import RedisConsumerThreaded
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BECDap:
|
||||
"""Utility class to keep track of slots associated with a particular dap redis consumer"""
|
||||
|
||||
consumer: RedisConsumerThreaded
|
||||
slots = set()
|
||||
|
||||
|
||||
# Adding a new pyqt signal requres a class factory, as they must be part of the class definition
|
||||
# and cannot be dynamically added as class attributes after the class has been defined.
|
||||
_signal_class_factory = (
|
||||
type(f"Signal{i}", (QObject,), dict(signal=pyqtSignal("PyQt_PyObject")))
|
||||
for i in itertools.count()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Connection:
|
||||
"""Utility class to keep track of slots connected to a particular redis consumer"""
|
||||
|
||||
consumer: RedisConsumerThreaded
|
||||
slots = set()
|
||||
# keep a reference to a new signal class, so it is not gc'ed
|
||||
_signal_container = next(_signal_class_factory)()
|
||||
|
||||
def __post_init__(self):
|
||||
self.signal = self._signal_container.signal
|
||||
|
||||
|
||||
class _BECDispatcher(QObject):
|
||||
new_scan = pyqtSignal(dict, dict)
|
||||
scan_segment = pyqtSignal(dict, dict)
|
||||
new_dap_data = pyqtSignal(dict, dict)
|
||||
|
||||
new_projection_id = pyqtSignal(dict)
|
||||
new_projection_data = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, bec_config=None):
|
||||
super().__init__()
|
||||
self.client = BECClient()
|
||||
|
||||
# TODO: this is a workaround for now to provide service config within qtdesigner, but is
|
||||
# it possible to provide config via a cli arg?
|
||||
if bec_config is None and os.path.isfile("bec_config.yaml"):
|
||||
bec_config = "bec_config.yaml"
|
||||
|
||||
self.client.initialize(config=ServiceConfig(config_path=bec_config))
|
||||
|
||||
self._slot_signal_map = {
|
||||
"on_scan_segment": self.scan_segment,
|
||||
"on_new_scan": self.new_scan,
|
||||
}
|
||||
self._daps = {}
|
||||
self._connections = {}
|
||||
|
||||
self._scan_id = None
|
||||
scan_lock = RLock()
|
||||
|
||||
# self.new_projection_id.connect(self.new_projection_data)
|
||||
|
||||
def _scan_segment_cb(msg):
|
||||
msg = BECMessage.ScanMessage.loads(msg.value)[0]
|
||||
with scan_lock:
|
||||
# TODO: use ScanStatusMessage instead?
|
||||
scan_id = msg.content["scanID"]
|
||||
if self._scan_id != scan_id:
|
||||
self._scan_id = scan_id
|
||||
self.new_scan.emit(msg.content, msg.metadata)
|
||||
self.scan_segment.emit(msg.content, msg.metadata)
|
||||
|
||||
scan_segment_topic = MessageEndpoints.scan_segment()
|
||||
self._scan_segment_thread = self.client.connector.consumer(
|
||||
topics=scan_segment_topic,
|
||||
cb=_scan_segment_cb,
|
||||
)
|
||||
self._scan_segment_thread.start()
|
||||
|
||||
def connect(self, widget):
|
||||
for slot_name, signal in self._slot_signal_map.items():
|
||||
slot = getattr(widget, slot_name, None)
|
||||
if callable(slot):
|
||||
signal.connect(slot)
|
||||
|
||||
def connect_slot(self, slot, topic):
|
||||
# create new connection for topic if it doesn't exist
|
||||
if topic not in self._connections:
|
||||
|
||||
def cb(msg):
|
||||
msg = BECMessage.MessageReader.loads(msg.value)
|
||||
self._connections[topic].signal.emit(msg)
|
||||
|
||||
consumer = self.client.connector.consumer(topics=topic, cb=cb)
|
||||
consumer.start()
|
||||
|
||||
self._connections[topic] = _Connection(consumer)
|
||||
|
||||
# connect slot if it's not connected
|
||||
if slot not in self._connections[topic].slots:
|
||||
self._connections[topic].signal.connect(slot)
|
||||
self._connections[topic].slots.add(slot)
|
||||
|
||||
def disconnect_slot(self, slot, topic):
|
||||
if topic not in self._connections:
|
||||
return
|
||||
|
||||
if slot not in self._connections[topic].slots:
|
||||
return
|
||||
|
||||
self._connections[topic].signal.disconnect(slot)
|
||||
self._connections[topic].slots.remove(slot)
|
||||
|
||||
if not self._connections[topic].slots:
|
||||
# shutdown consumer if there are no more connected slots
|
||||
self._connections[topic].consumer.shutdown()
|
||||
del self._connections[topic]
|
||||
|
||||
def connect_dap_slot(self, slot, dap_name):
|
||||
if dap_name not in self._daps:
|
||||
# create a new consumer and connect slot
|
||||
|
||||
def _dap_cb(msg):
|
||||
msg = BECMessage.ProcessedDataMessage.loads(msg.value)
|
||||
self.new_dap_data.emit(msg.content["data"], msg.metadata)
|
||||
|
||||
dap_ep = MessageEndpoints.processed_data(dap_name)
|
||||
consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
|
||||
consumer.start()
|
||||
|
||||
self.new_dap_data.connect(slot)
|
||||
|
||||
self._daps[dap_name] = _BECDap(consumer)
|
||||
self._daps[dap_name].slots.add(slot)
|
||||
|
||||
else:
|
||||
# connect slot if it's not yet connected
|
||||
if slot not in self._daps[dap_name].slots:
|
||||
self.new_dap_data.connect(slot)
|
||||
self._daps[dap_name].slots.add(slot)
|
||||
|
||||
def disconnect_dap_slot(self, slot, dap_name):
|
||||
if dap_name not in self._daps:
|
||||
return
|
||||
|
||||
if slot not in self._daps[dap_name].slots:
|
||||
return
|
||||
|
||||
self.new_dap_data.disconnect(slot)
|
||||
self._daps[dap_name].slots.remove(slot)
|
||||
|
||||
if not self._daps[dap_name].slots:
|
||||
# shutdown consumer if there are no more connected slots
|
||||
self._daps[dap_name].consumer.shutdown()
|
||||
del self._daps[dap_name]
|
||||
|
||||
# def connect_proj_data(self, slot):
|
||||
# keys = self.client.producer.keys("px_stream/projection_*")
|
||||
# keys = keys or []
|
||||
#
|
||||
# def _dap_cb(msg):
|
||||
# msg = BECMessage.DeviceMessage.loads(msg.value)
|
||||
# self.new_projection_data.emit(msg.content["data"])
|
||||
#
|
||||
# proj_numbers = set(key.decode().split("px_stream/projection_")[1].split("/")[0] for key in keys)
|
||||
# last_proj_id = sorted(proj_numbers)[-1]
|
||||
# dap_ep = MessageEndpoints.processed_data(f"px_stream/projection_{last_proj_id}/")
|
||||
#
|
||||
# consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
|
||||
# consumer.start()
|
||||
#
|
||||
# self.new_projection_data.connect(slot)
|
||||
|
||||
def connect_proj_id(self, slot):
|
||||
def _dap_cb(msg):
|
||||
msg = BECMessage.DeviceMessage.loads(msg.value)
|
||||
self.new_projection_id.emit(msg.content["signals"])
|
||||
|
||||
dap_ep = "px_stream/proj_nr"
|
||||
consumer = self.client.connector.consumer(topics=dap_ep, cb=_dap_cb)
|
||||
consumer.start()
|
||||
|
||||
self.new_projection_id.connect(slot)
|
||||
|
||||
def connect_proj_data(self, slot: object, data_ep: str) -> object:
|
||||
def _dap_cb(msg):
|
||||
msg = BECMessage.DeviceMessage.loads(msg.value)
|
||||
self.new_projection_data.emit(msg.content["signals"])
|
||||
|
||||
consumer = self.client.connector.consumer(topics=data_ep, cb=_dap_cb)
|
||||
consumer.start()
|
||||
self._daps[data_ep] = _BECDap(consumer)
|
||||
self._daps[data_ep].slots.add(slot)
|
||||
|
||||
self.new_projection_data.connect(slot)
|
||||
|
||||
def disconnect_proj_data(self, slot, data_ep):
|
||||
if data_ep not in self._daps:
|
||||
return
|
||||
|
||||
if slot not in self._daps[data_ep].slots:
|
||||
return
|
||||
|
||||
self.new_projection_data.disconnect(slot)
|
||||
self._daps[data_ep].slots.remove(slot)
|
||||
|
||||
if not self._daps[data_ep].slots:
|
||||
# shutdown consumer if there are no more connected slots
|
||||
self._daps[data_ep].consumer.shutdown()
|
||||
del self._daps[data_ep]
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bec-config", default=None)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
bec_dispatcher = _BECDispatcher(args.bec_config)
|
||||
1
bec_widgets/cli/__init__.py
Normal file
1
bec_widgets/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .client import *
|
||||
153
bec_widgets/cli/auto_updates.py
Normal file
153
bec_widgets/cli/auto_updates.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .client import BECDockArea, BECFigure
|
||||
|
||||
|
||||
class ScanInfo(BaseModel):
|
||||
scan_id: str
|
||||
scan_number: int
|
||||
scan_name: str
|
||||
scan_report_devices: list
|
||||
monitored_devices: list
|
||||
status: str
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
|
||||
class AutoUpdates:
|
||||
create_default_dock: bool = False
|
||||
enabled: bool = False
|
||||
dock_name: str = None
|
||||
|
||||
def __init__(self, gui: BECDockArea):
|
||||
self.gui = gui
|
||||
|
||||
def start_default_dock(self):
|
||||
"""
|
||||
Create a default dock for the auto updates.
|
||||
"""
|
||||
dock = self.gui.add_dock("default_figure")
|
||||
dock.add_widget("BECFigure")
|
||||
self.dock_name = "default_figure"
|
||||
|
||||
@staticmethod
|
||||
def get_scan_info(msg) -> ScanInfo:
|
||||
"""
|
||||
Update the script with the given data.
|
||||
"""
|
||||
info = msg.info
|
||||
status = msg.status
|
||||
scan_id = msg.scan_id
|
||||
scan_number = info.get("scan_number", 0)
|
||||
scan_name = info.get("scan_name", "Unknown")
|
||||
scan_report_devices = info.get("scan_report_devices", [])
|
||||
monitored_devices = info.get("readout_priority", {}).get("monitored", [])
|
||||
monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
|
||||
return ScanInfo(
|
||||
scan_id=scan_id,
|
||||
scan_number=scan_number,
|
||||
scan_name=scan_name,
|
||||
scan_report_devices=scan_report_devices,
|
||||
monitored_devices=monitored_devices,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def get_default_figure(self) -> BECFigure | None:
|
||||
"""
|
||||
Get the default figure from the GUI.
|
||||
"""
|
||||
dock = self.gui.panels.get(self.dock_name, [])
|
||||
if not dock:
|
||||
return None
|
||||
widgets = dock.widget_list
|
||||
if not widgets:
|
||||
return None
|
||||
return widgets[0]
|
||||
|
||||
def run(self, msg):
|
||||
"""
|
||||
Run the update function if enabled.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
if msg.status != "open":
|
||||
return
|
||||
info = self.get_scan_info(msg)
|
||||
self.handler(info)
|
||||
|
||||
@staticmethod
|
||||
def get_selected_device(monitored_devices, selected_device):
|
||||
"""
|
||||
Get the selected device for the plot. If no device is selected, the first
|
||||
device in the monitored devices list is selected.
|
||||
"""
|
||||
if selected_device:
|
||||
return selected_device
|
||||
if len(monitored_devices) > 0:
|
||||
sel_device = monitored_devices[0]
|
||||
return sel_device
|
||||
return None
|
||||
|
||||
def handler(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Default update function.
|
||||
"""
|
||||
if info.scan_name == "line_scan" and info.scan_report_devices:
|
||||
self.simple_line_scan(info)
|
||||
return
|
||||
if info.scan_name == "grid_scan" and info.scan_report_devices:
|
||||
self.simple_grid_scan(info)
|
||||
return
|
||||
if info.scan_report_devices:
|
||||
self.best_effort(info)
|
||||
return
|
||||
|
||||
def simple_line_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Simple line scan.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def simple_grid_scan(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Simple grid scan.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = info.scan_report_devices[1]
|
||||
dev_z = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
fig.clear_all()
|
||||
plt = fig.plot(
|
||||
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
|
||||
)
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
|
||||
def best_effort(self, info: ScanInfo) -> None:
|
||||
"""
|
||||
Best effort scan.
|
||||
"""
|
||||
fig = self.get_default_figure()
|
||||
if not fig:
|
||||
return
|
||||
dev_x = info.scan_report_devices[0]
|
||||
dev_y = self.get_selected_device(info.monitored_devices, self.gui.selected_device)
|
||||
if not dev_y:
|
||||
return
|
||||
fig.clear_all()
|
||||
plt = fig.plot(x_name=dev_x, y_name=dev_y, label=f"Scan {info.scan_number} - {dev_y}")
|
||||
plt.set(title=f"Scan {info.scan_number}", x_label=dev_x, y_label=dev_y)
|
||||
2191
bec_widgets/cli/client.py
Normal file
2191
bec_widgets/cli/client.py
Normal file
File diff suppressed because it is too large
Load Diff
359
bec_widgets/cli/client_utils.py
Normal file
359
bec_widgets/cli/client_utils.py
Normal file
@@ -0,0 +1,359 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata as imd
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
|
||||
from qtpy.QtCore import QEventLoop, QSocketNotifier, QTimer
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
from bec_widgets.cli.client import BECDockArea, BECFigure
|
||||
|
||||
from bec_lib.serialization import MsgpackSerialization
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
# from bec_lib.connector import MessageObject
|
||||
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
# we could rely on a strict type check here, but this is more flexible
|
||||
# moreover, it would anyway crash for objects...
|
||||
out = []
|
||||
for arg in args:
|
||||
if hasattr(arg, "name"):
|
||||
arg = arg.name
|
||||
out.append(arg)
|
||||
args = tuple(out)
|
||||
for key, val in kwargs.items():
|
||||
if hasattr(val, "name"):
|
||||
kwargs[key] = val.name
|
||||
if not self.gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
os.set_blocking(process.stderr.fileno(), False)
|
||||
while process.poll() is None:
|
||||
readylist, _, _ = select.select([process.stdout, process.stderr], [], [], 1)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
buf = stream_buffer[stream]
|
||||
if stream in readylist:
|
||||
buf.append(stream.read(4096))
|
||||
output, _, remaining = "".join(buf).rpartition("\n")
|
||||
if output:
|
||||
log_func[stream](output)
|
||||
buf.clear()
|
||||
buf.append(remaining)
|
||||
except Exception as e:
|
||||
print(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(gui_id, gui_class, config, logger=None) -> None:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
Logger must be a logger object with "debug" and "error" functions,
|
||||
or it can be left to "None" as default. None means output from the
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
|
||||
if config:
|
||||
command.extend(["--config", config])
|
||||
|
||||
env_dict = os.environ.copy()
|
||||
env_dict["PYTHONUNBUFFERED"] = "1"
|
||||
if logger is None:
|
||||
stdout_redirect = subprocess.DEVNULL
|
||||
stderr_redirect = subprocess.DEVNULL
|
||||
else:
|
||||
stdout_redirect = subprocess.PIPE
|
||||
stderr_redirect = subprocess.PIPE
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
stdout=stdout_redirect,
|
||||
stderr=stderr_redirect,
|
||||
env=env_dict,
|
||||
)
|
||||
if logger is None:
|
||||
process_output_processing_thread = None
|
||||
else:
|
||||
process_output_processing_thread = threading.Thread(
|
||||
target=_get_output, args=(process, logger)
|
||||
)
|
||||
process_output_processing_thread.start()
|
||||
return process, process_output_processing_thread
|
||||
|
||||
|
||||
class BECGuiClientMixin:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._process = None
|
||||
self._process_output_processing_thread = None
|
||||
self.auto_updates = self._get_update_script()
|
||||
self._target_endpoint = MessageEndpoints.scan_status()
|
||||
self._selected_device = None
|
||||
|
||||
def _get_update_script(self) -> AutoUpdates | None:
|
||||
eps = imd.entry_points(group="bec.widgets.auto_updates")
|
||||
for ep in eps:
|
||||
if ep.name == "plugin_widgets_update":
|
||||
try:
|
||||
spec = importlib.util.find_spec(ep.module)
|
||||
# if the module is not found, we skip it
|
||||
if spec is None:
|
||||
continue
|
||||
return ep.load()(gui=self)
|
||||
except Exception as e:
|
||||
print(f"Error loading auto update script from plugin: {str(e)}")
|
||||
return None
|
||||
|
||||
@property
|
||||
def selected_device(self):
|
||||
"""
|
||||
Selected device for the plot.
|
||||
"""
|
||||
return self._selected_device
|
||||
|
||||
@selected_device.setter
|
||||
def selected_device(self, device: str | DeviceBase):
|
||||
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
|
||||
self._selected_device = device.name
|
||||
elif isinstance(device, str):
|
||||
self._selected_device = device
|
||||
else:
|
||||
raise ValueError("Device must be a string or a device object")
|
||||
|
||||
def _start_update_script(self) -> None:
|
||||
self._client.connector.register(
|
||||
self._target_endpoint, cb=self._handle_msg_update, parent=self
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _handle_msg_update(msg: MessageObject, parent: BECGuiClientMixin) -> None:
|
||||
if parent.auto_updates is not None:
|
||||
# pylint: disable=protected-access
|
||||
parent._update_script_msg_parser(msg.value)
|
||||
|
||||
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
|
||||
if isinstance(msg, messages.ScanStatusMessage):
|
||||
if not self.gui_is_alive():
|
||||
return
|
||||
self.auto_updates.run(msg)
|
||||
|
||||
def show(self) -> None:
|
||||
"""
|
||||
Show the figure.
|
||||
"""
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
self._start_update_script()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id, self.__class__, self._client._service_config.config_path
|
||||
)
|
||||
while not self.gui_is_alive():
|
||||
print("Waiting for GUI to start...")
|
||||
time.sleep(1)
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the gui window.
|
||||
"""
|
||||
if self._process is None:
|
||||
return
|
||||
|
||||
self._client.shutdown()
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
if self._process_output_processing_thread:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
|
||||
|
||||
class RPCResponseTimeoutError(Exception):
|
||||
"""Exception raised when an RPC response is not received within the expected time."""
|
||||
|
||||
def __init__(self, request_id, timeout):
|
||||
super().__init__(
|
||||
f"RPC response not received within {timeout} seconds for request ID {request_id}"
|
||||
)
|
||||
|
||||
|
||||
class QtRedisMessageWaiter:
|
||||
def __init__(self, redis_connector, message_to_wait):
|
||||
self.ev_loop = QEventLoop()
|
||||
self.response = None
|
||||
self.connector = redis_connector
|
||||
self.message_to_wait = message_to_wait
|
||||
self.pubsub = redis_connector._redis_conn.pubsub()
|
||||
self.pubsub.subscribe(self.message_to_wait.endpoint)
|
||||
fd = self.pubsub.connection._sock.fileno()
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._pubsub_readable)
|
||||
|
||||
def _msg_received(self, msg_obj):
|
||||
self.response = msg_obj.value
|
||||
self.ev_loop.quit()
|
||||
|
||||
def wait(self, timeout=1):
|
||||
timer = QTimer()
|
||||
timer.singleShot(timeout * 1000, self.ev_loop.quit)
|
||||
self.ev_loop.exec_()
|
||||
timer.stop()
|
||||
self.notifier.setEnabled(False)
|
||||
self.pubsub.close()
|
||||
return self.response
|
||||
|
||||
def _pubsub_readable(self, fd):
|
||||
while True:
|
||||
msg = self.pubsub.get_message()
|
||||
if msg:
|
||||
if msg["type"] == "subscribe":
|
||||
# get_message buffers, so we may already have the answer
|
||||
# let's check...
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
return
|
||||
channel = msg["channel"].decode()
|
||||
msg = MessageObject(topic=channel, value=MsgpackSerialization.loads(msg["data"]))
|
||||
self.connector._execute_callback(self._msg_received, msg, {})
|
||||
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECDispatcher().client
|
||||
self._config = config if config is not None else {}
|
||||
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())
|
||||
self._parent = parent
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
"""
|
||||
parent = self
|
||||
# pylint: disable=protected-access
|
||||
while parent._parent is not None:
|
||||
parent = parent._parent
|
||||
return parent
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
Args:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
receiver = self._root._gui_id
|
||||
if wait_for_rpc_response:
|
||||
redis_msg = QtRedisMessageWaiter(
|
||||
self._client.connector, MessageEndpoints.gui_instruction_response(request_id)
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
|
||||
if wait_for_rpc_response:
|
||||
response = redis_msg.wait(timeout)
|
||||
|
||||
if response is None:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
|
||||
# get class name
|
||||
if not response.accepted:
|
||||
raise ValueError(response.message["error"])
|
||||
msg_result = response.message.get("result")
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
|
||||
def _create_widget_from_msg_result(self, msg_result):
|
||||
if msg_result is None:
|
||||
return None
|
||||
if isinstance(msg_result, list):
|
||||
return [self._create_widget_from_msg_result(res) for res in msg_result]
|
||||
if isinstance(msg_result, dict):
|
||||
if "__rpc__" not in msg_result:
|
||||
return {
|
||||
key: self._create_widget_from_msg_result(val) for key, val in msg_result.items()
|
||||
}
|
||||
cls = msg_result.pop("widget_class", None)
|
||||
msg_result.pop("__rpc__", None)
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
|
||||
def gui_is_alive(self):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
return heart is not None
|
||||
189
bec_widgets/cli/generate_cli.py
Normal file
189
bec_widgets/cli/generate_cli.py
Normal file
@@ -0,0 +1,189 @@
|
||||
# pylint: disable=missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
|
||||
import black
|
||||
import isort
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
else:
|
||||
print(
|
||||
"Python version is less than 3.11, using dummy function for get_overloads. "
|
||||
"If you want to use the real function 'typing.get_overloads()', please use Python 3.11 or later."
|
||||
)
|
||||
|
||||
def get_overloads(_obj):
|
||||
"""
|
||||
Dummy function for Python versions before 3.11.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class ClientGenerator:
|
||||
def __init__(self):
|
||||
self.header = """# This file was automatically generated by generate_cli.py\n
|
||||
import enum
|
||||
from typing import Literal, Optional, overload
|
||||
|
||||
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
self.content = ""
|
||||
|
||||
def generate_client(self, class_container: BECClassContainer):
|
||||
"""
|
||||
Generate the client for the published classes.
|
||||
|
||||
Args:
|
||||
class_container: The class container with the classes to generate the client for.
|
||||
"""
|
||||
rpc_top_level_classes = class_container.rpc_top_level_classes
|
||||
rpc_top_level_classes.sort(key=lambda x: x.__name__)
|
||||
connector_classes = class_container.connector_classes
|
||||
connector_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
def write_client_enum(self, published_classes: list[type]):
|
||||
"""
|
||||
Write the client enum to the content.
|
||||
"""
|
||||
self.content += """
|
||||
class Widgets(str, enum.Enum):
|
||||
\"\"\"
|
||||
Enum for the available widgets.
|
||||
\"\"\"
|
||||
"""
|
||||
for cls in published_classes:
|
||||
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
Generate the content for the class.
|
||||
|
||||
Args:
|
||||
cls: The class for which to generate the content.
|
||||
"""
|
||||
|
||||
class_name = cls.__name__
|
||||
|
||||
# Generate the content
|
||||
if cls.__name__ == "BECDockArea":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase, BECGuiClientMixin):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
for method in cls.USER_ACCESS:
|
||||
obj = getattr(cls, method)
|
||||
if isinstance(obj, property):
|
||||
self.content += """
|
||||
@property
|
||||
@rpc_call"""
|
||||
sig = str(inspect.signature(obj.fget))
|
||||
doc = inspect.getdoc(obj.fget)
|
||||
else:
|
||||
sig = str(inspect.signature(obj))
|
||||
doc = inspect.getdoc(obj)
|
||||
overloads = get_overloads(obj)
|
||||
for overload in overloads:
|
||||
sig_overload = str(inspect.signature(overload))
|
||||
self.content += f"""
|
||||
@overload
|
||||
def {method}{str(sig_overload)}: ...
|
||||
"""
|
||||
|
||||
self.content += """
|
||||
@rpc_call"""
|
||||
self.content += f"""
|
||||
def {method}{str(sig)}:
|
||||
\"\"\"
|
||||
{doc}
|
||||
\"\"\""""
|
||||
|
||||
def write(self, file_name: str):
|
||||
"""
|
||||
Write the content to a file, automatically formatted with black.
|
||||
|
||||
Args:
|
||||
file_name(str): The name of the file to write to.
|
||||
"""
|
||||
# Combine header and content, then format with black
|
||||
full_content = self.header + "\n" + self.content
|
||||
try:
|
||||
formatted_content = black.format_str(full_content, mode=black.FileMode(line_length=100))
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=True,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point for the script, controlled by command line arguments.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
|
||||
parser.add_argument("--core", action="store_true", help="Whether to generate the core client")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.core:
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
|
||||
rpc_classes = get_rpc_classes("bec_widgets")
|
||||
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(client_path)
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
# if the class directory already has a register, plugin and pyproject file, skip
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"register_{plugin.info.plugin_name_snake}.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}_plugin.py")
|
||||
):
|
||||
continue
|
||||
if os.path.exists(
|
||||
os.path.join(plugin.info.base_path, f"{plugin.info.plugin_name_snake}.pyproject")
|
||||
):
|
||||
continue
|
||||
plugin.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.argv = ["generate_cli.py", "--core"]
|
||||
main()
|
||||
80
bec_widgets/cli/rpc_register.py
Normal file
80
bec_widgets/cli/rpc_register.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from threading import Lock
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
|
||||
class RPCRegister:
|
||||
"""
|
||||
A singleton class that keeps track of all the RPC objects registered in the system for CLI usage.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(RPCRegister, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._rpc_register = WeakValueDictionary()
|
||||
self._initialized = True
|
||||
|
||||
def add_rpc(self, rpc: QObject):
|
||||
"""
|
||||
Add an RPC object to the register.
|
||||
|
||||
Args:
|
||||
rpc(QObject): The RPC object to be added to the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError("RPC object must have a 'gui_id' attribute.")
|
||||
self._rpc_register[rpc.gui_id] = rpc
|
||||
|
||||
def remove_rpc(self, rpc: str):
|
||||
"""
|
||||
Remove an RPC object from the register.
|
||||
|
||||
Args:
|
||||
rpc(str): The RPC object to be removed from the register.
|
||||
"""
|
||||
if not hasattr(rpc, "gui_id"):
|
||||
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
|
||||
self._rpc_register.pop(rpc.gui_id, None)
|
||||
|
||||
def get_rpc_by_id(self, gui_id: str) -> QObject:
|
||||
"""
|
||||
Get an RPC object by its ID.
|
||||
|
||||
Args:
|
||||
gui_id(str): The ID of the RPC object to be retrieved.
|
||||
|
||||
Returns:
|
||||
QObject: The RPC object with the given ID.
|
||||
"""
|
||||
rpc_object = self._rpc_register.get(gui_id, None)
|
||||
return rpc_object
|
||||
|
||||
def list_all_connections(self) -> dict:
|
||||
"""
|
||||
List all the registered RPC objects.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing all the registered RPC objects.
|
||||
"""
|
||||
with self._lock:
|
||||
connections = dict(self._rpc_register)
|
||||
return connections
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
"""
|
||||
Reset the singleton instance.
|
||||
"""
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
53
bec_widgets/cli/rpc_wigdet_handler.py
Normal file
53
bec_widgets/cli/rpc_wigdet_handler.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class RPCWidgetHandler:
|
||||
"""Handler class for creating widgets from RPC messages."""
|
||||
|
||||
def __init__(self):
|
||||
self._widget_classes = None
|
||||
|
||||
@property
|
||||
def widget_classes(self):
|
||||
"""
|
||||
Get the available widget classes.
|
||||
|
||||
Returns:
|
||||
dict: The available widget classes.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
return self._widget_classes
|
||||
|
||||
def update_available_widgets(self):
|
||||
"""
|
||||
Update the available widgets.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
|
||||
clss = get_rpc_classes("bec_widgets")
|
||||
self._widget_classes = {cls.__name__: cls for cls in clss.top_level_classes}
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECConnector:
|
||||
"""
|
||||
Create a widget from an RPC message.
|
||||
|
||||
Args:
|
||||
widget_type(str): The type of the widget.
|
||||
**kwargs: The keyword arguments for the widget.
|
||||
|
||||
Returns:
|
||||
widget(BECConnector): The created widget.
|
||||
"""
|
||||
if self._widget_classes is None:
|
||||
self.update_available_widgets()
|
||||
widget_class = self._widget_classes.get(widget_type)
|
||||
if widget_class:
|
||||
return widget_class(**kwargs)
|
||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
||||
|
||||
|
||||
widget_handler = RPCWidgetHandler()
|
||||
219
bec_widgets/cli/server.py
Normal file
219
bec_widgets/cli/server.py
Normal file
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Union
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_dispatcher import QtRedisConnector
|
||||
from bec_widgets.widgets.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
|
||||
messages = lazy_import("bec_lib.messages")
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class BECWidgetsCLIServer:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gui_id: str = None,
|
||||
dispatcher: BECDispatcher = None,
|
||||
client=None,
|
||||
config=None,
|
||||
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
|
||||
) -> None:
|
||||
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
|
||||
self.client = self.dispatcher.client if client is None else client
|
||||
self.client.start()
|
||||
self.gui_id = gui_id
|
||||
self.gui = gui_class(gui_id=self.gui_id)
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self.gui)
|
||||
|
||||
self.dispatcher.connect_slot(
|
||||
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
|
||||
)
|
||||
|
||||
# Setup QTimer for heartbeat
|
||||
self._shutdown_event = False
|
||||
self._heartbeat_timer = QTimer()
|
||||
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
|
||||
self._heartbeat_timer.start(200)
|
||||
|
||||
def on_rpc_update(self, msg: dict, metadata: dict):
|
||||
request_id = metadata.get("request_id")
|
||||
try:
|
||||
obj = self.get_object_from_config(msg["parameter"])
|
||||
method = msg["action"]
|
||||
args = msg["parameter"].get("args", [])
|
||||
kwargs = msg["parameter"].get("kwargs", {})
|
||||
res = self.run_rpc(obj, method, args, kwargs)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
self.send_response(request_id, False, {"error": str(e)})
|
||||
else:
|
||||
self.send_response(request_id, True, {"result": res})
|
||||
|
||||
def send_response(self, request_id: str, accepted: bool, msg: dict):
|
||||
self.client.connector.set_and_publish(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
messages.RequestResponseMessage(accepted=accepted, message=msg),
|
||||
expire=60,
|
||||
)
|
||||
|
||||
def get_object_from_config(self, config: dict):
|
||||
gui_id = config.get("gui_id")
|
||||
obj = self.rpc_register.get_rpc_by_id(gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Object with gui_id {gui_id} not found")
|
||||
return obj
|
||||
|
||||
def run_rpc(self, obj, method, args, kwargs):
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
res = method_obj
|
||||
else:
|
||||
sig = inspect.signature(method_obj)
|
||||
if sig.parameters:
|
||||
res = method_obj(*args, **kwargs)
|
||||
else:
|
||||
res = method_obj()
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
elif isinstance(res, dict):
|
||||
res = {key: self.serialize_object(val) for key, val in res.items()}
|
||||
else:
|
||||
res = self.serialize_object(res)
|
||||
return res
|
||||
|
||||
def serialize_object(self, obj):
|
||||
if isinstance(obj, BECConnector):
|
||||
return {
|
||||
"gui_id": obj.gui_id,
|
||||
"widget_class": obj.__class__.__name__,
|
||||
"config": obj.config.model_dump(),
|
||||
"__rpc__": True,
|
||||
}
|
||||
return obj
|
||||
|
||||
def emit_heartbeat(self):
|
||||
if self._shutdown_event is False:
|
||||
self.client.connector.set(
|
||||
MessageEndpoints.gui_heartbeat(self.gui_id),
|
||||
messages.StatusMessage(name=self.gui_id, status=1, info={}),
|
||||
expire=1,
|
||||
)
|
||||
|
||||
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
|
||||
self._shutdown_event = True
|
||||
self._heartbeat_timer.stop()
|
||||
self.gui.close()
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
class SimpleFileLikeFromLogOutputFunc:
|
||||
def __init__(self, log_func):
|
||||
self._log_func = log_func
|
||||
|
||||
def write(self, buffer):
|
||||
for line in buffer.rstrip().splitlines():
|
||||
line = line.rstrip()
|
||||
if line:
|
||||
self._log_func(line)
|
||||
|
||||
def flush(self):
|
||||
return
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bec_widgets
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
|
||||
parser.add_argument("--id", type=str, help="The id of the server")
|
||||
parser.add_argument(
|
||||
"--gui_class",
|
||||
type=str,
|
||||
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
|
||||
)
|
||||
parser.add_argument("--config", type=str, help="Config file")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.gui_class == "BECFigure":
|
||||
gui_class = BECFigure
|
||||
elif args.gui_class == "BECDockArea":
|
||||
gui_class = BECDockArea
|
||||
else:
|
||||
print(
|
||||
"Please specify a valid gui_class to run. Use -h for help."
|
||||
"\n Starting with default gui_class BECFigure."
|
||||
)
|
||||
gui_class = BECFigure
|
||||
|
||||
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.debug)):
|
||||
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("BEC Figure")
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(module_path, "assets", "bec_widgets_icon.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
win = QMainWindow()
|
||||
win.setWindowTitle("BEC Widgets")
|
||||
|
||||
service_config = ServiceConfig(args.config)
|
||||
bec_logger.configure(
|
||||
service_config.redis,
|
||||
QtRedisConnector,
|
||||
service_name="BECWidgetsCLIServer",
|
||||
service_config=service_config.service_config,
|
||||
)
|
||||
server = BECWidgetsCLIServer(gui_id=args.id, config=service_config, gui_class=gui_class)
|
||||
|
||||
gui = server.gui
|
||||
win.setCentralWidget(gui)
|
||||
win.resize(800, 600)
|
||||
win.show()
|
||||
|
||||
app.aboutToQuit.connect(server.shutdown)
|
||||
|
||||
def sigint_handler(*args):
|
||||
# display message, for people to let it terminate gracefully
|
||||
print("Caught SIGINT, exiting")
|
||||
app.quit()
|
||||
|
||||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
signal.signal(signal.SIGTERM, sigint_handler)
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,129 +0,0 @@
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets
|
||||
|
||||
|
||||
class ConfigPlotter(pg.GraphicsWidget):
|
||||
"""
|
||||
ConfigPlotter is a widget that can be used to plot data from multiple channels
|
||||
in a grid layout. The layout is specified by a list of dicts, where each dict
|
||||
specifies the position of the plot in the grid, the channels to plot, and the
|
||||
type of plot to use. The plot type is specified by the name of the pyqtgraph
|
||||
item to use. For example, to plot a single channel in a PlotItem, the config
|
||||
would look like this:
|
||||
|
||||
config = [
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 0,
|
||||
"x": 0,
|
||||
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
|
||||
}
|
||||
]
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, configs: List[dict], parent=None):
|
||||
super().__init__(parent)
|
||||
self.configs = configs
|
||||
self.plots = {}
|
||||
self._init_ui()
|
||||
self._init_plots()
|
||||
|
||||
def _init_ui(self):
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
|
||||
# pylint: disable=no-member
|
||||
self.pen = mkPen(color=(56, 76, 107), width=4, style=QtCore.Qt.SolidLine)
|
||||
|
||||
self.view = pg.GraphicsView()
|
||||
self.view.setAntialiasing(True)
|
||||
self.view.show()
|
||||
|
||||
self.layout = pg.GraphicsLayout()
|
||||
self.view.setCentralWidget(self.layout)
|
||||
|
||||
def _init_plots(self):
|
||||
for config in self.configs:
|
||||
channels = config["config"]["channels"]
|
||||
for channel in channels:
|
||||
item = pg.PlotItem()
|
||||
self.layout.addItem(
|
||||
item,
|
||||
row=config["y"],
|
||||
col=config["x"],
|
||||
rowspan=config["rows"],
|
||||
colspan=config["cols"],
|
||||
)
|
||||
|
||||
# call the corresponding init function, e.g. init_plotitem
|
||||
init_func = getattr(self, f"init_{config['config']['item']}")
|
||||
init_func(channel, config["config"], item)
|
||||
|
||||
# self.init_ImageItem(channel, config["config"], item)
|
||||
|
||||
def init_PlotItem(self, channel: str, config: dict, item: pg.GraphicsItem):
|
||||
"""
|
||||
Initialize a PlotItem
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
item(pg.GraphicsItem): PlotItem to plot the data
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
plot_data = item.plot(np.random.rand(100), pen=self.pen)
|
||||
item.setLabel("left", channel)
|
||||
self.plots[channel] = {"item": item, "plot_data": plot_data}
|
||||
|
||||
def init_ImageItem(self, channel: str, config: dict, item: pg.GraphicsItem):
|
||||
"""
|
||||
Initialize an ImageItem
|
||||
|
||||
Args:
|
||||
channel(str): channel to plot
|
||||
config(dict): config dict for the channel
|
||||
item(pg.GraphicsItem): ImageItem to plot the data
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
img = pg.ImageItem()
|
||||
item.addItem(img)
|
||||
img.setImage(np.random.rand(100, 100))
|
||||
self.plots[channel] = {"item": item, "plot_data": img}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
CONFIG = [
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 0,
|
||||
"x": 0,
|
||||
"config": {"channels": ["a"], "label_xy": ["", "a"], "item": "PlotItem"},
|
||||
},
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 1,
|
||||
"y": 1,
|
||||
"x": 0,
|
||||
"config": {"channels": ["b"], "label_xy": ["", "b"], "item": "PlotItem"},
|
||||
},
|
||||
{
|
||||
"cols": 1,
|
||||
"rows": 2,
|
||||
"y": 0,
|
||||
"x": 1,
|
||||
"config": {"channels": ["c"], "label_xy": ["", "c"], "item": "ImageItem"},
|
||||
},
|
||||
]
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
win = ConfigPlotter(CONFIG)
|
||||
pg.exec()
|
||||
@@ -1,38 +0,0 @@
|
||||
import signal
|
||||
import socket
|
||||
from PyQt5.QtNetwork import QAbstractSocket
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.signalwatchdog = SignalWatchdog() # need to store to keep socket pair alive
|
||||
signal.signal(signal.SIGINT, make_quit_handler(app))
|
||||
|
||||
|
||||
def make_quit_handler(app):
|
||||
def handler(*args):
|
||||
print() # make ^C appear on its own line
|
||||
app.quit()
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
class SignalWatchdog(QAbstractSocket):
|
||||
def __init__(self):
|
||||
"""
|
||||
Propagates system signals from Python to QEventLoop
|
||||
adapted from https://stackoverflow.com/a/65802260/655404
|
||||
"""
|
||||
super().__init__(QAbstractSocket.SctpSocket, None)
|
||||
|
||||
self.writer, self.reader = writer, reader = socket.socketpair()
|
||||
writer.setblocking(False)
|
||||
|
||||
fd_writer = writer.fileno()
|
||||
fd_reader = reader.fileno()
|
||||
|
||||
signal.set_wakeup_fd(fd_writer) # Python hook
|
||||
self.setSocketDescriptor(fd_reader) # Qt hook
|
||||
|
||||
self.readyRead.connect(
|
||||
lambda: None
|
||||
) # dummy function call that lets the Python interpreter run
|
||||
@@ -1,33 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt5 import QtWidgets, uic
|
||||
|
||||
|
||||
class UI(QtWidgets.QWidget):
|
||||
def __init__(self, uipath):
|
||||
super().__init__()
|
||||
|
||||
self.ui = uic.loadUi(uipath, self)
|
||||
|
||||
_, fname = os.path.split(uipath)
|
||||
self.setWindowTitle(fname)
|
||||
|
||||
self.show()
|
||||
|
||||
|
||||
def main():
|
||||
"""A basic script to display UI file
|
||||
|
||||
Run the script, passing UI file path as an argument, e.g.
|
||||
$ python bec_widgets/display_ui_file.py bec_widgets/line_plot.ui
|
||||
"""
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
UI(sys.argv[1])
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
bec_widgets/examples/__init__.py
Normal file
9
bec_widgets/examples/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .motor_movement import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorControlPanelRelative,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
163
bec_widgets/examples/jupyter_console/jupyter_console_window.py
Normal file
163
bec_widgets/examples/jupyter_console/jupyter_console_window.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import qdarktheme
|
||||
from qtconsole.inprocess import QtInProcessKernelManager
|
||||
from qtconsole.rich_jupyter_widget import RichJupyterWidget
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import BECDispatcher, UILoader
|
||||
from bec_widgets.widgets.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.figure import BECFigure
|
||||
from bec_widgets.widgets.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
|
||||
|
||||
class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"""A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API)."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader().load_ui(os.path.join(current_path, "jupyter_console_window.ui"), self)
|
||||
|
||||
self._init_ui()
|
||||
|
||||
self.ui.splitter.setSizes([200, 100])
|
||||
self.safe_close = False
|
||||
|
||||
# console push
|
||||
if self.console.inprocess is True:
|
||||
self.console.kernel_manager.kernel.shell.push(
|
||||
{
|
||||
"np": np,
|
||||
"pg": pg,
|
||||
"fig": self.figure,
|
||||
"dock": self.dock,
|
||||
"w1": self.w1,
|
||||
"w2": self.w2,
|
||||
"w3": self.w3,
|
||||
"w1_c": self.w1_c,
|
||||
"w2_c": self.w2_c,
|
||||
"w3_c": self.w3_c,
|
||||
"w4": self.w4,
|
||||
"d0": self.d0,
|
||||
"d1": self.d1,
|
||||
"d2": self.d2,
|
||||
"plt": self.plt,
|
||||
"bar": self.bar,
|
||||
}
|
||||
)
|
||||
|
||||
def _init_ui(self):
|
||||
# Plotting window
|
||||
self.glw_1_layout = QVBoxLayout(self.ui.glw) # Create a new QVBoxLayout
|
||||
self.figure = BECFigure(parent=self, gui_id="remote") # Create a new BECDeviceMonitor
|
||||
self.glw_1_layout.addWidget(self.figure) # Add BECDeviceMonitor to the layout
|
||||
|
||||
self.dock_layout = QVBoxLayout(self.ui.dock_placeholder)
|
||||
self.dock = BECDockArea(gui_id="remote")
|
||||
self.dock_layout.addWidget(self.dock)
|
||||
|
||||
# add stuff to figure
|
||||
self._init_figure()
|
||||
|
||||
# init dock for testing
|
||||
self._init_dock()
|
||||
|
||||
self.console_layout = QVBoxLayout(self.ui.widget_console)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
self.console_layout.addWidget(self.console)
|
||||
|
||||
def _init_figure(self):
|
||||
self.figure.plot(x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="cividis")
|
||||
self.figure.motor_map("samx", "samy")
|
||||
self.figure.image("eiger", color_map="viridis", vrange=(0, 100))
|
||||
self.figure.plot(
|
||||
x_name="samx", y_name="samy", z_name="bpm4i", color_map_z="magma", new=True
|
||||
)
|
||||
|
||||
self.figure.change_layout(2, 2)
|
||||
|
||||
self.w1 = self.figure[0, 0]
|
||||
self.w2 = self.figure[0, 1]
|
||||
self.w3 = self.figure[1, 0]
|
||||
self.w4 = self.figure[1, 1]
|
||||
|
||||
# Plot Customisation
|
||||
self.w1.set_title("Waveform 1")
|
||||
self.w1.set_x_label("Motor Position (samx)")
|
||||
self.w1.set_y_label("Intensity A.U.")
|
||||
|
||||
# Image Customisation
|
||||
self.w3.set_title("Eiger Image")
|
||||
self.w3.set_x_label("X")
|
||||
self.w3.set_y_label("Y")
|
||||
|
||||
# Configs to try to pass
|
||||
self.w1_c = self.w1._config_dict
|
||||
self.w2_c = self.w2._config_dict
|
||||
self.w3_c = self.w3._config_dict
|
||||
|
||||
# curves for w1
|
||||
self.c1 = self.w1.get_config()
|
||||
|
||||
self.fig_c = self.figure._config_dict
|
||||
|
||||
def _init_dock(self):
|
||||
|
||||
self.d0 = self.dock.add_dock(name="dock_0")
|
||||
self.fig0 = self.d0.add_widget("BECFigure")
|
||||
data = np.random.rand(10, 2)
|
||||
self.fig0.plot(data, label="2d Data")
|
||||
self.fig0.image("eiger", vrange=(0, 100))
|
||||
|
||||
self.d1 = self.dock.add_dock(name="dock_1", position="right")
|
||||
self.fig1 = self.d1.add_widget("BECFigure")
|
||||
self.fig1.plot(x_name="samx", y_name="bpm4i")
|
||||
self.fig1.plot(x_name="samx", y_name="bpm3a")
|
||||
|
||||
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
|
||||
self.fig2 = self.d2.add_widget("BECFigure", row=0, col=0)
|
||||
self.plt = self.fig2.plot(x_name="samx", y_name="bpm3a")
|
||||
self.plt.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
|
||||
self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
|
||||
self.bar.set_diameter(200)
|
||||
|
||||
self.dock.save_state()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Override to handle things when main window is closed."""
|
||||
self.dock.cleanup()
|
||||
self.figure.clear_all()
|
||||
self.figure.client.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
import bec_widgets
|
||||
|
||||
module_path = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Jupyter Console")
|
||||
app.setApplicationDisplayName("Jupyter Console")
|
||||
qdarktheme.setup_theme("auto")
|
||||
icon = QIcon()
|
||||
icon.addFile(os.path.join(module_path, "assets", "terminal_icon.png"), size=QSize(48, 48))
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
win = JupyterConsoleWindow()
|
||||
win.show()
|
||||
|
||||
app.aboutToQuit.connect(win.close)
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>2104</width>
|
||||
<height>966</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Plotting Console</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="tab_1">
|
||||
<attribute name="title">
|
||||
<string>BECDock</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="dock_placeholder" native="true"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>BECFigure</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="QWidget" name="glw" native="true"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<widget class="QWidget" name="widget_console" native="true"/>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
9
bec_widgets/examples/motor_movement/__init__.py
Normal file
9
bec_widgets/examples/motor_movement/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .motor_control_compilations import (
|
||||
MotorControlApp,
|
||||
MotorControlMap,
|
||||
MotorControlPanel,
|
||||
MotorControlPanelAbsolute,
|
||||
MotorControlPanelRelative,
|
||||
MotorCoordinateTable,
|
||||
MotorThread,
|
||||
)
|
||||
@@ -0,0 +1,250 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
|
||||
import qdarktheme
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.widgets.motor_control.motor_control import MotorThread
|
||||
from bec_widgets.widgets.motor_control.motor_table.motor_table import MotorCoordinateTable
|
||||
from bec_widgets.widgets.motor_control.movement_absolute.movement_absolute import (
|
||||
MotorControlAbsolute,
|
||||
)
|
||||
from bec_widgets.widgets.motor_control.movement_relative.movement_relative import (
|
||||
MotorControlRelative,
|
||||
)
|
||||
from bec_widgets.widgets.motor_control.selection.selection import MotorControlSelection
|
||||
|
||||
CONFIG_DEFAULT = {
|
||||
"motor_control": {
|
||||
"motor_x": "samx",
|
||||
"motor_y": "samy",
|
||||
"step_size_x": 3,
|
||||
"step_size_y": 3,
|
||||
"precision": 4,
|
||||
"step_x_y_same": False,
|
||||
"move_with_arrows": False,
|
||||
},
|
||||
"plot_settings": {
|
||||
"colormap": "Greys",
|
||||
"scatter_size": 5,
|
||||
"max_points": 1000,
|
||||
"num_dim_points": 100,
|
||||
"precision": 2,
|
||||
"num_columns": 1,
|
||||
"background_value": 25,
|
||||
},
|
||||
"motors": [
|
||||
{
|
||||
"plot_name": "Motor Map",
|
||||
"x_label": "Motor X",
|
||||
"y_label": "Motor Y",
|
||||
"signals": {
|
||||
"x": [{"name": "samx", "entry": "samx"}],
|
||||
"y": [{"name": "samy", "entry": "samy"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MotorControlApp(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
# self.motion_map = MotorMap(client=self.client, config=self.config)
|
||||
# Create MotorCoordinateTable
|
||||
self.motor_table = MotorCoordinateTable(client=self.client, config=self.config)
|
||||
|
||||
# Create the splitter and add MotorMap and MotorControlPanel
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
# splitter.addWidget(self.motion_map)
|
||||
splitter.addWidget(self.motor_control_panel)
|
||||
splitter.addWidget(self.motor_table)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connecting signals and slots
|
||||
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
|
||||
# lambda x, y: self.motion_map.change_motors(x, y, 0)
|
||||
# )
|
||||
self.motor_control_panel.absolute_widget.coordinates_signal.connect(
|
||||
self.motor_table.add_coordinate
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_table.set_precision
|
||||
)
|
||||
self.motor_control_panel.relative_widget.precision_signal.connect(
|
||||
self.motor_control_panel.absolute_widget.set_precision
|
||||
)
|
||||
|
||||
# self.motor_table.plot_coordinates_signal.connect(self.motion_map.plot_saved_coordinates)
|
||||
|
||||
|
||||
class MotorControlMap(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
# Widgets
|
||||
self.motor_control_panel = MotorControlPanel(client=self.client, config=self.config)
|
||||
# Create MotorMap
|
||||
# self.motion_map = MotorMap(client=self.client, config=self.config)
|
||||
|
||||
# Create the splitter and add MotorMap and MotorControlPanel
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
# splitter.addWidget(self.motion_map)
|
||||
splitter.addWidget(self.motor_control_panel)
|
||||
|
||||
# Set the main layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(splitter)
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connecting signals and slots
|
||||
# self.motor_control_panel.selection_widget.selected_motors_signal.connect(
|
||||
# lambda x, y: self.motion_map.change_motors(x, y, 0)
|
||||
# )
|
||||
|
||||
|
||||
class MotorControlPanel(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=self.client, config=self.config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
|
||||
|
||||
# Set the window to a fixed size based on its contents
|
||||
# self.layout().setSizeConstraint(layout.SetFixedSize)
|
||||
|
||||
|
||||
class MotorControlPanelAbsolute(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.absolute_widget = MotorControlAbsolute(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.absolute_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.absolute_widget.change_motors)
|
||||
|
||||
|
||||
class MotorControlPanelRelative(QWidget):
|
||||
def __init__(self, parent=None, client=None, config=None):
|
||||
super().__init__(parent)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
self.client = bec_dispatcher.client if client is None else client
|
||||
self.config = config
|
||||
|
||||
self.motor_thread = MotorThread(client=self.client)
|
||||
|
||||
self.selection_widget = MotorControlSelection(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
self.relative_widget = MotorControlRelative(
|
||||
client=client, config=config, motor_thread=self.motor_thread
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.selection_widget)
|
||||
layout.addWidget(self.relative_widget)
|
||||
|
||||
# Connecting signals and slots
|
||||
self.selection_widget.selected_motors_signal.connect(self.relative_widget.change_motors)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run various Motor Control Widgets compositions.")
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--variant",
|
||||
type=str,
|
||||
choices=["app", "map", "panel", "panel_abs", "panel_rel"],
|
||||
help="Select the variant of the motor control to run. "
|
||||
"'app' for the full application, "
|
||||
"'map' for MotorMap, "
|
||||
"'panel' for the MotorControlPanel, "
|
||||
"'panel_abs' for MotorControlPanel with absolute control, "
|
||||
"'panel_rel' for MotorControlPanel with relative control.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
app = QApplication([])
|
||||
qdarktheme.setup_theme("auto")
|
||||
|
||||
if args.variant == "app":
|
||||
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
|
||||
elif args.variant == "map":
|
||||
window = MotorControlMap(client=client) # , config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel":
|
||||
window = MotorControlPanel(client=client) # , config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_abs":
|
||||
window = MotorControlPanelAbsolute(client=client) # , config=CONFIG_DEFAULT)
|
||||
elif args.variant == "panel_rel":
|
||||
window = MotorControlPanelRelative(client=client) # , config=CONFIG_DEFAULT)
|
||||
else:
|
||||
print("Please specify a valid variant to run. Use -h for help.")
|
||||
print("Running the full application by default.")
|
||||
window = MotorControlApp(client=client) # , config=CONFIG_DEFAULT)
|
||||
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -6,10 +6,16 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1129</width>
|
||||
<height>550</height>
|
||||
<width>1561</width>
|
||||
<height>748</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>1409</width>
|
||||
<height>748</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Motor Controller</string>
|
||||
</property>
|
||||
@@ -29,9 +35,21 @@
|
||||
<height>471</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_6" stretch="1,1,1,0,1">
|
||||
<property name="spacing">
|
||||
<number>1</number>
|
||||
</property>
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetMinimumSize</enum>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorSelection">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>145</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Selection</string>
|
||||
</property>
|
||||
@@ -71,6 +89,9 @@
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeType">
|
||||
<enum>QSizePolicy::Minimum</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
@@ -81,6 +102,12 @@
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>339</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Relative</string>
|
||||
</property>
|
||||
@@ -93,12 +120,137 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<widget class="QCheckBox" name="checkBox_same_xy">
|
||||
<property name="text">
|
||||
<string>Step [X] = Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="step_grid">
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [Y]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Decimal</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_step_x">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>111</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Step [X]</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step_y">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>0.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>99.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>19</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>8</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>2</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="direction_grid">
|
||||
<property name="sizeConstraint">
|
||||
<enum>QLayout::SetDefaultConstraint</enum>
|
||||
</property>
|
||||
<item row="1" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_up">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
@@ -122,6 +274,12 @@
|
||||
</item>
|
||||
<item row="3" column="2" alignment="Qt::AlignHCenter|Qt::AlignVCenter">
|
||||
<widget class="QToolButton" name="toolButton_down">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
@@ -132,6 +290,12 @@
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QToolButton" name="toolButton_left">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
@@ -155,6 +319,12 @@
|
||||
</item>
|
||||
<item row="2" column="3">
|
||||
<widget class="QToolButton" name="toolButton_right">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>26</width>
|
||||
<height>26</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
@@ -189,19 +359,6 @@
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_step">
|
||||
<property name="minimum">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
<property name="value">
|
||||
<double>1.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
@@ -212,33 +369,71 @@
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeType">
|
||||
<enum>QSizePolicy::Minimum</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>13</height>
|
||||
<height>18</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorControl_absolute">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>261</width>
|
||||
<height>195</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Move Absolute</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QCheckBox" name="checkBox_save_with_go">
|
||||
<property name="text">
|
||||
<string>Save position with Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<item row="1" column="0">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_7">
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
@@ -248,39 +443,41 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_6">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_save">
|
||||
<property name="text">
|
||||
<string>Save</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_set">
|
||||
<property name="text">
|
||||
<string>Set</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_go_absolute">
|
||||
<property name="text">
|
||||
<string>Go</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_x">
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_absolute_y">
|
||||
<property name="minimum">
|
||||
<double>-500.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>500.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.100000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
@@ -309,6 +506,44 @@
|
||||
<string>Coordinates</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Entries Mode:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox_mode">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Individual</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>Start/Stop</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget_coordinates">
|
||||
<property name="selectionMode">
|
||||
@@ -319,12 +554,17 @@
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Tag</string>
|
||||
<string>Show</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Show</string>
|
||||
<string>Move</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Tag</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
@@ -337,13 +577,57 @@
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Move</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="1" column="1">
|
||||
<widget class="QPushButton" name="pushButton_resize_table">
|
||||
<property name="text">
|
||||
<string>Resize Table</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QCheckBox" name="checkBox_resize_auto">
|
||||
<property name="text">
|
||||
<string>Resize Auto</string>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QPushButton" name="pushButton_importCSV">
|
||||
<property name="text">
|
||||
<string>Import CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="pushButton_exportCSV">
|
||||
<property name="text">
|
||||
<string>Export CSV</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="pushButton_help">
|
||||
<property name="text">
|
||||
<string>Help</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="pushButton_duplicate">
|
||||
<property name="text">
|
||||
<string>Duplicate Last Entry</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_settings">
|
||||
@@ -353,26 +637,13 @@
|
||||
<layout class="QVBoxLayout" name="verticalLayout_4">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="motorLimits">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>Motor Limits</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="3" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_y_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>-1000</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>-100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="pushButton_updateLimits">
|
||||
<property name="text">
|
||||
@@ -380,22 +651,6 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_x_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>-1000</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_Y_max">
|
||||
<property name="text">
|
||||
@@ -406,38 +661,6 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_y_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>-1000</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QSpinBox" name="spinBox_x_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>-1000</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>-100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QLabel" name="label_Y_min">
|
||||
<property name="text">
|
||||
@@ -468,190 +691,68 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_y_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_y_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_x_min">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QDoubleSpinBox" name="spinBox_x_max">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<double>-1000.000000000000000</double>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>1000.000000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_2">
|
||||
<property name="title">
|
||||
<string>Motor Config</string>
|
||||
<string>Plotting Options</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_5">
|
||||
<item row="3" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_3">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_6">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QLabel" name="label_9">
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Speed</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_speed_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QSpinBox" name="spinBox_update_frequency_x">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>500</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_speed_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLabel" name="label_10">
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="label_15">
|
||||
<property name="text">
|
||||
<string>Max Points</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_num_dim_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Update Frequency</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="2">
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox_2">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Tolerance</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="QSpinBox" name="spinBox_update_frequency_y">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>500</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QPushButton" name="pushButton_update_config">
|
||||
<property name="text">
|
||||
<string>Update</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="label_4">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Decimal Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="6" column="0">
|
||||
<widget class="QLabel" name="label_16">
|
||||
<property name="text">
|
||||
<string>N dim</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="1" colspan="2">
|
||||
<item row="0" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_max_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
@@ -670,14 +771,14 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="0">
|
||||
<widget class="QLabel" name="label_11">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label_15">
|
||||
<property name="text">
|
||||
<string>Scatter Size</string>
|
||||
<string>Max Points</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="1" colspan="2">
|
||||
<item row="2" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_scatter_size">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
@@ -693,6 +794,53 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_11">
|
||||
<property name="text">
|
||||
<string>Scatter Size</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0" colspan="3">
|
||||
<widget class="QPushButton" name="pushButton_update_config">
|
||||
<property name="text">
|
||||
<string>Update Settings</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1" colspan="2">
|
||||
<widget class="QSpinBox" name="spinBox_num_dim_points">
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
<property name="minimum">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>1000</number>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<number>10</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>100</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label_16">
|
||||
<property name="text">
|
||||
<string>N dim</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0" colspan="3">
|
||||
<widget class="QPushButton" name="pushButton_enableGUI">
|
||||
<property name="text">
|
||||
<string>Enable Control GUI</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
@@ -703,6 +851,16 @@
|
||||
<string>Queue</string>
|
||||
</attribute>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="text">
|
||||
<string>Work in progress</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_5">
|
||||
<property name="enabled">
|
||||
@@ -725,7 +883,7 @@
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>scanID</string>
|
||||
<string>scan_id</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from enum import Enum
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtGui
|
||||
from PyQt5.QtCore import QThread, pyqtSlot
|
||||
from PyQt5.QtCore import pyqtSignal, Qt
|
||||
from PyQt5.QtWidgets import QApplication, QWidget
|
||||
from pyqtgraph.Qt import QtWidgets, uic
|
||||
|
||||
from PyQt5.QtGui import QKeySequence
|
||||
from PyQt5.QtWidgets import QShortcut
|
||||
|
||||
from bec_lib.core import MessageEndpoints, BECMessage
|
||||
|
||||
|
||||
# TODO - General features
|
||||
# - setting motor speed and frequency
|
||||
# - setting motor acceleration
|
||||
# - updating motor precision
|
||||
# - put motor status (moving, stopped, etc)
|
||||
# - add spinBox for motor scatter size
|
||||
# - add mouse interactions with the plot -> click to select coordinates, double click to move?
|
||||
# - adjust right click actions
|
||||
# - implement logic to check if motor actually has limits
|
||||
|
||||
|
||||
class MotorApp(QWidget):
|
||||
coordinates_updated = pyqtSignal(float, float)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "motor_controller.ui"), self)
|
||||
|
||||
# Motor Control Thread
|
||||
self.motor_thread = MotorControl()
|
||||
|
||||
self.motor_x, self.motor_y = None, None
|
||||
self.limit_x, self.limit_y = None, None
|
||||
|
||||
# Coordinates tracking
|
||||
self.motor_positions = np.array([])
|
||||
self.max_points = 5000 # Maximum number of points to keep
|
||||
self.num_dim_points = 15 # Number of points to dim gradually
|
||||
self.scatter_size = 5
|
||||
|
||||
# QThread for motor movement + signals
|
||||
self.motor_thread.motors_loaded.connect(self.get_available_motors)
|
||||
self.motor_thread.motors_selected.connect(self.get_selected_motors)
|
||||
self.motor_thread.limits_retrieved.connect(self.update_limits)
|
||||
self.motor_thread.speed_retrieved.connect(self.update_speed)
|
||||
self.motor_thread.update_frequency_retrieved.connect(self.update_update_frequency)
|
||||
|
||||
# UI
|
||||
self.init_ui()
|
||||
self.tag_N = 1 # position label for saved coordinates
|
||||
|
||||
# Get all motors available
|
||||
self.motor_thread.retrieve_all_motors()
|
||||
|
||||
def connect_motor(self, motor_x_name: str, motor_y_name: str):
|
||||
self.motor_thread.connect_motors(motor_x_name, motor_y_name)
|
||||
self.motor_thread.retrieve_motor_limits(self.motor_x, self.motor_y)
|
||||
self.motor_thread.retrieve_motor_speed(self.motor_x, self.motor_y)
|
||||
self.motor_thread.retrieve_motor_update_frequency(self.motor_x, self.motor_y)
|
||||
self.init_motor_map()
|
||||
|
||||
self.motorControl.setEnabled(True)
|
||||
self.motorControl_absolute.setEnabled(True)
|
||||
self.tabWidget_tables.setTabEnabled(1, True)
|
||||
|
||||
self.generate_table_coordinate(
|
||||
self.tableWidget_coordinates,
|
||||
self.motor_thread.retrieve_coordinates(),
|
||||
tag=f"{motor_x_name},{motor_y_name}",
|
||||
precision=0,
|
||||
)
|
||||
|
||||
@pyqtSlot(object, object)
|
||||
def get_selected_motors(self, motor_x, motor_y):
|
||||
self.motor_x, self.motor_y = motor_x, motor_y
|
||||
|
||||
@pyqtSlot(list, list)
|
||||
def get_available_motors(self, motors_x, motors_y):
|
||||
self.comboBox_motor_x.addItems(motors_x)
|
||||
self.comboBox_motor_y.addItems(motors_y)
|
||||
|
||||
@pyqtSlot(list, list)
|
||||
def update_limits(self, x_limits: list, y_limits: list) -> None:
|
||||
self.limit_x = x_limits
|
||||
self.limit_y = y_limits
|
||||
self.spinBox_x_min.setValue(self.limit_x[0])
|
||||
self.spinBox_x_max.setValue(self.limit_x[1])
|
||||
self.spinBox_y_min.setValue(self.limit_y[0])
|
||||
self.spinBox_y_max.setValue(self.limit_y[1])
|
||||
|
||||
for spinBox in (
|
||||
self.spinBox_x_min,
|
||||
self.spinBox_x_max,
|
||||
self.spinBox_y_min,
|
||||
self.spinBox_y_max,
|
||||
):
|
||||
spinBox.setStyleSheet("")
|
||||
|
||||
# TODO - names can be get from MotorController
|
||||
self.label_Y_max.setText(f"+ ({self.motor_y.name})")
|
||||
self.label_Y_min.setText(f"- ({self.motor_y.name})")
|
||||
self.label_X_max.setText(f"+ ({self.motor_x.name})")
|
||||
self.label_X_min.setText(f"- ({self.motor_x.name})")
|
||||
|
||||
self.init_motor_map() # reinitialize the map with the new limits
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
def update_speed(self, speed_x, speed_y):
|
||||
self.spinBox_speed_x.setValue(speed_x)
|
||||
self.spinBox_speed_y.setValue(speed_y)
|
||||
for spinBox in (self.spinBox_speed_x, self.spinBox_speed_y):
|
||||
spinBox.setStyleSheet("")
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
def update_update_frequency(self, update_frequency_x, update_frequency_y):
|
||||
self.spinBox_update_frequency_x.setValue(update_frequency_x)
|
||||
self.spinBox_update_frequency_y.setValue(update_frequency_y)
|
||||
for spinBox in (self.spinBox_update_frequency_x, self.spinBox_update_frequency_y):
|
||||
spinBox.setStyleSheet("")
|
||||
|
||||
@pyqtSlot()
|
||||
def enable_motor_control(self):
|
||||
self.motorControl.setEnabled(True)
|
||||
|
||||
def enable_motor_controls(self, disable: bool) -> None:
|
||||
self.motorControl.setEnabled(disable)
|
||||
self.motorSelection.setEnabled(disable)
|
||||
|
||||
# Disable or enable all controls within the motorControl_absolute group box
|
||||
for widget in self.motorControl_absolute.findChildren(QtWidgets.QWidget):
|
||||
widget.setEnabled(disable)
|
||||
|
||||
# Enable the pushButton_stop if the motor is moving
|
||||
self.pushButton_stop.setEnabled(not disable)
|
||||
|
||||
def move_motor_absolute(self, x: float, y: float) -> None:
|
||||
self.enable_motor_controls(False)
|
||||
target_coordinates = (x, y)
|
||||
self.motor_thread.move_to_coordinates(target_coordinates)
|
||||
|
||||
def move_motor_relative(self, motor, value: float) -> None:
|
||||
self.enable_motor_controls(False)
|
||||
self.motor_thread.move_relative(motor, value)
|
||||
|
||||
def update_plot_setting(self, max_points, num_dim_points, scatter_size):
|
||||
self.max_points = max_points
|
||||
self.num_dim_points = num_dim_points
|
||||
self.scatter_size = scatter_size
|
||||
|
||||
for spinBox in (
|
||||
self.spinBox_max_points,
|
||||
self.spinBox_num_dim_points,
|
||||
self.spinBox_scatter_size,
|
||||
):
|
||||
spinBox.setStyleSheet("")
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Setup all ui elements"""
|
||||
# TODO can be separated to multiple functions
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
|
||||
self.label_coorditanes = self.glw.addLabel(f"Motor position: (X, Y)", row=0, col=0)
|
||||
self.plot_map = self.glw.addPlot(row=1, col=0)
|
||||
self.limit_map = pg.ImageItem()
|
||||
self.plot_map.addItem(self.limit_map)
|
||||
self.motor_map = pg.ScatterPlotItem(
|
||||
size=self.scatter_size, pen=pg.mkPen(None), brush=pg.mkBrush(255, 255, 255, 255)
|
||||
)
|
||||
self.plot_map.addItem(self.motor_map)
|
||||
self.plot_map.showGrid(x=True, y=True)
|
||||
|
||||
##########################
|
||||
# Motor General setting
|
||||
##########################
|
||||
|
||||
# TODO make function to update precision
|
||||
self.precision = 2 # self.spinBox_precision.value() # Define the decimal precision
|
||||
|
||||
##########################
|
||||
# Motor movements signals
|
||||
##########################
|
||||
|
||||
self.toolButton_right.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_x, self.spinBox_step.value())
|
||||
)
|
||||
self.toolButton_left.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_x, -self.spinBox_step.value())
|
||||
)
|
||||
self.toolButton_up.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_y, self.spinBox_step.value())
|
||||
)
|
||||
self.toolButton_down.clicked.connect(
|
||||
lambda: self.move_motor_relative(self.motor_y, -self.spinBox_step.value())
|
||||
)
|
||||
|
||||
# Switch between key shortcuts active
|
||||
self.checkBox_enableArrows.stateChanged.connect(self.update_arrow_key_shortcuts)
|
||||
self.update_arrow_key_shortcuts()
|
||||
|
||||
# Move to absolute coordinates
|
||||
self.pushButton_go_absolute.clicked.connect(
|
||||
lambda: self.move_motor_absolute(
|
||||
self.spinBox_absolute_x.value(), self.spinBox_absolute_y.value()
|
||||
)
|
||||
)
|
||||
self.pushButton_go_absolute.clicked.connect(self.save_absolute_coordinates)
|
||||
self.pushButton_go_absolute.setShortcut("Ctrl+G")
|
||||
self.pushButton_go_absolute.setToolTip("Ctrl+G")
|
||||
|
||||
self.motor_thread.move_finished.connect(lambda: self.enable_motor_controls(True))
|
||||
|
||||
# Stop Button
|
||||
self.pushButton_stop.clicked.connect(self.motor_thread.stop_movement)
|
||||
|
||||
##########################
|
||||
# Motor Configs
|
||||
##########################
|
||||
|
||||
# SpinBoxes - Motor Limits #TODO make spinboxes own limits updated, currently is [-1000, 1000]
|
||||
|
||||
# SpinBoxes change color to yellow before updated, limits are updated with update button
|
||||
self.spinBox_x_min.valueChanged.connect(lambda: self.param_changed(self.spinBox_x_min))
|
||||
self.spinBox_x_max.valueChanged.connect(lambda: self.param_changed(self.spinBox_x_max))
|
||||
self.spinBox_y_min.valueChanged.connect(lambda: self.param_changed(self.spinBox_y_min))
|
||||
self.spinBox_y_max.valueChanged.connect(lambda: self.param_changed(self.spinBox_y_max))
|
||||
|
||||
# SpinBoxes - Motor Speed
|
||||
self.spinBox_speed_x.valueChanged.connect(lambda: self.param_changed(self.spinBox_speed_x))
|
||||
self.spinBox_speed_y.valueChanged.connect(lambda: self.param_changed(self.spinBox_speed_y))
|
||||
|
||||
# SpinBoxes - Motor Update Frequency
|
||||
self.spinBox_update_frequency_x.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_update_frequency_x)
|
||||
)
|
||||
self.spinBox_update_frequency_y.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_update_frequency_y)
|
||||
)
|
||||
|
||||
# SpinBoxes - Max Points and N Dim Points
|
||||
self.spinBox_max_points.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_max_points)
|
||||
)
|
||||
self.spinBox_num_dim_points.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_num_dim_points)
|
||||
)
|
||||
self.spinBox_scatter_size.valueChanged.connect(
|
||||
lambda: self.param_changed(self.spinBox_scatter_size)
|
||||
)
|
||||
|
||||
# Config updates
|
||||
self.pushButton_updateLimits.clicked.connect(
|
||||
lambda: self.update_all_motor_limits(
|
||||
x_limit=[self.spinBox_x_min.value(), self.spinBox_x_max.value()],
|
||||
y_limit=[self.spinBox_y_min.value(), self.spinBox_y_max.value()],
|
||||
)
|
||||
)
|
||||
|
||||
self.pushButton_update_config.clicked.connect(
|
||||
lambda: self.update_plot_setting(
|
||||
max_points=self.spinBox_max_points.value(),
|
||||
num_dim_points=self.spinBox_num_dim_points.value(),
|
||||
scatter_size=self.spinBox_scatter_size.value(),
|
||||
)
|
||||
)
|
||||
|
||||
self.pushButton_update_config.clicked.connect(
|
||||
lambda: self.update_all_config(
|
||||
speed=[self.spinBox_speed_x.value(), self.spinBox_speed_y.value()],
|
||||
update_frequency=[
|
||||
self.spinBox_update_frequency_x.value(),
|
||||
self.spinBox_update_frequency_y.value(),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# TODO map with floats as well -> or decide system for higher precision
|
||||
self.motor_thread.coordinates_updated.connect(
|
||||
lambda x, y: self.update_image_map(round(x, self.precision), round(y, self.precision))
|
||||
)
|
||||
|
||||
# Motor connections
|
||||
self.pushButton_connecMotors.clicked.connect(
|
||||
lambda: self.connect_motor(
|
||||
self.comboBox_motor_x.currentText(), self.comboBox_motor_y.currentText()
|
||||
)
|
||||
)
|
||||
|
||||
# Check if there are any motors connected
|
||||
if self.motor_x or self.motor_y is None:
|
||||
self.motorControl.setEnabled(False)
|
||||
self.motorControl_absolute.setEnabled(False)
|
||||
self.tabWidget_tables.setTabEnabled(1, False)
|
||||
|
||||
# Keyboard shortcuts
|
||||
delete_shortcut = QShortcut(QKeySequence("Delete"), self)
|
||||
backspace_shortcut = QShortcut(QKeySequence("Backspace"), self)
|
||||
delete_shortcut.activated.connect(self.delete_selected_row)
|
||||
backspace_shortcut.activated.connect(self.delete_selected_row)
|
||||
|
||||
def init_motor_map(self):
|
||||
# Get motor limits
|
||||
limit_x_min, limit_x_max = self.motor_thread.get_motor_limits(self.motor_x)
|
||||
limit_y_min, limit_y_max = self.motor_thread.get_motor_limits(self.motor_y)
|
||||
|
||||
self.offset_x = limit_x_min
|
||||
self.offset_y = limit_y_min
|
||||
|
||||
# Define the size of the image map based on the motor's limits
|
||||
map_width = limit_x_max - limit_x_min + 1
|
||||
map_height = limit_y_max - limit_y_min + 1
|
||||
|
||||
# Create an empty image map
|
||||
self.background_value = 15
|
||||
self.limit_map_data = np.full(
|
||||
(map_width, map_height), self.background_value, dtype=np.float32
|
||||
)
|
||||
self.limit_map.setImage(self.limit_map_data)
|
||||
|
||||
# Set the initial position on the map
|
||||
init_pos = self.motor_thread.retrieve_coordinates()
|
||||
self.motor_positions = np.array([init_pos])
|
||||
self.brushes = [pg.mkBrush(255, 255, 255, 255)]
|
||||
|
||||
self.motor_map.setData(pos=self.motor_positions, brush=self.brushes)
|
||||
|
||||
# Translate and scale the image item to match the motor coordinates
|
||||
self.tr = QtGui.QTransform()
|
||||
self.tr.translate(limit_x_min, limit_y_min)
|
||||
self.limit_map.setTransform(self.tr)
|
||||
|
||||
def update_image_map(self, x, y):
|
||||
# Update label
|
||||
self.label_coorditanes.setText(f"Motor position: ({x}, {y})")
|
||||
|
||||
# Add new point with full brightness
|
||||
new_pos = np.array([x, y])
|
||||
self.motor_positions = np.vstack((self.motor_positions, new_pos))
|
||||
|
||||
# If the number of points exceeds max_points, delete the oldest points
|
||||
if len(self.motor_positions) > self.max_points:
|
||||
self.motor_positions = self.motor_positions[-self.max_points :]
|
||||
|
||||
# Determine brushes based on position in the array
|
||||
self.brushes = [pg.mkBrush(50, 50, 50, 255)] * len(self.motor_positions)
|
||||
|
||||
# Calculate the decrement step based on self.num_dim_points
|
||||
decrement_step = (255 - 50) / self.num_dim_points
|
||||
|
||||
for i in range(1, min(self.num_dim_points + 1, len(self.motor_positions) + 1)):
|
||||
brightness = max(50, 255 - decrement_step * (i - 1))
|
||||
self.brushes[-i] = pg.mkBrush(brightness, brightness, brightness, 255)
|
||||
|
||||
self.brushes[-1] = pg.mkBrush(255, 255, 255, 255) # Newest point is always full brightness
|
||||
|
||||
self.motor_map.setData(pos=self.motor_positions, brush=self.brushes, size=self.scatter_size)
|
||||
|
||||
def update_all_motor_limits(self, x_limit: list = None, y_limit: list = None) -> None:
|
||||
self.motor_thread.update_all_motor_limits(x_limit=x_limit, y_limit=y_limit)
|
||||
|
||||
def update_all_config(self, speed: list = None, update_frequency: list = None) -> None:
|
||||
# TODO now only speed and update frequency
|
||||
self.motor_thread.update_all_config(speed=speed, update_frequency=update_frequency)
|
||||
|
||||
def update_arrow_key_shortcuts(self):
|
||||
if self.checkBox_enableArrows.isChecked():
|
||||
# Set the arrow key shortcuts for motor movement
|
||||
self.toolButton_right.setShortcut(Qt.Key_Right)
|
||||
self.toolButton_left.setShortcut(Qt.Key_Left)
|
||||
self.toolButton_up.setShortcut(Qt.Key_Up)
|
||||
self.toolButton_down.setShortcut(Qt.Key_Down)
|
||||
else:
|
||||
# Clear the shortcuts
|
||||
self.toolButton_right.setShortcut("")
|
||||
self.toolButton_left.setShortcut("")
|
||||
self.toolButton_up.setShortcut("")
|
||||
self.toolButton_down.setShortcut("")
|
||||
|
||||
def generate_table_coordinate(
|
||||
self, table: QtWidgets.QTableWidget, coordinates: tuple, tag: str = None, precision: int = 0
|
||||
) -> None:
|
||||
current_row_count = table.rowCount()
|
||||
|
||||
table.setRowCount(current_row_count + 1)
|
||||
|
||||
checkBox = QtWidgets.QCheckBox()
|
||||
checkBox.setChecked(True)
|
||||
button = QtWidgets.QPushButton("Go")
|
||||
|
||||
table.setItem(current_row_count, 0, QtWidgets.QTableWidgetItem(str(tag)))
|
||||
table.setCellWidget(current_row_count, 1, checkBox)
|
||||
table.setItem(
|
||||
current_row_count, 2, QtWidgets.QTableWidgetItem(str(f"{coordinates[0]:.{precision}f}"))
|
||||
)
|
||||
table.setItem(
|
||||
current_row_count, 3, QtWidgets.QTableWidgetItem(str(f"{coordinates[1]:.{precision}f}"))
|
||||
)
|
||||
table.setCellWidget(current_row_count, 4, button)
|
||||
|
||||
# hook signals of table
|
||||
button.clicked.connect(
|
||||
lambda: self.move_motor_absolute(
|
||||
float(table.item(current_row_count, 2).text()),
|
||||
float(table.item(current_row_count, 3).text()),
|
||||
)
|
||||
)
|
||||
table.resizeColumnsToContents()
|
||||
|
||||
def delete_selected_row(self):
|
||||
selected_rows = self.tableWidget_coordinates.selectionModel().selectedRows()
|
||||
|
||||
# If you allow multiple selections, you may want to loop through all selected rows
|
||||
for row in reversed(selected_rows): # Reverse to delete from the end
|
||||
self.tableWidget_coordinates.removeRow(row.row())
|
||||
|
||||
def save_absolute_coordinates(self):
|
||||
self.generate_table_coordinate(
|
||||
self.tableWidget_coordinates,
|
||||
(self.spinBox_absolute_x.value(), self.spinBox_absolute_y.value()),
|
||||
tag=f"Pos {self.tag_N}",
|
||||
precision=0,
|
||||
)
|
||||
|
||||
self.tag_N += 1
|
||||
|
||||
@staticmethod
|
||||
def param_changed(ui_element):
|
||||
ui_element.setStyleSheet("background-color: #FFA700;")
|
||||
|
||||
|
||||
class MotorActions(Enum):
|
||||
MOVE_TO_COORDINATES = "move_to_coordinates"
|
||||
MOVE_RELATIVE = "move_relative"
|
||||
|
||||
|
||||
class MotorControl(QThread):
|
||||
coordinates_updated = pyqtSignal(float, float) # Signal to emit current coordinates
|
||||
limits_retrieved = pyqtSignal(list, list) # Signal to emit current limits
|
||||
speed_retrieved = pyqtSignal(int, int) # Signal to emit current speed
|
||||
update_frequency_retrieved = pyqtSignal(int, int) # Signal to emit current update frequency
|
||||
move_finished = pyqtSignal() # Signal to emit when the move is finished
|
||||
motors_loaded = pyqtSignal(list, list) # Signal to emit when the motors are loaded
|
||||
motors_selected = pyqtSignal(object, object) # Signal to emit when the motors are selected
|
||||
# progress_updated = pyqtSignal(int) #TODO Signal to emit progress percentage
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.action = None
|
||||
self._initialize_motor()
|
||||
|
||||
def motor_by_string(self, motor_x_name: str, motor_y_name: str) -> tuple:
|
||||
motor_x_index = self.all_motors_names.index(motor_x_name)
|
||||
motor_y_index = self.all_motors_names.index(motor_y_name)
|
||||
|
||||
motor_x = self.all_motors[motor_x_index]
|
||||
motor_y = self.all_motors[motor_y_index]
|
||||
return motor_x, motor_y
|
||||
|
||||
def connect_motors(self, motor_x_name: str, motor_y_name: str) -> None:
|
||||
self.motor_x, self.motor_y = self.motor_by_string(motor_x_name, motor_y_name)
|
||||
|
||||
(self.current_x, self.current_y) = self.get_coordinates()
|
||||
|
||||
if self.motors_consumer is not None:
|
||||
self.motors_consumer.shutdown()
|
||||
|
||||
self.motors_consumer = client.connector.consumer(
|
||||
topics=[
|
||||
MessageEndpoints.device_readback(self.motor_x.name),
|
||||
MessageEndpoints.device_readback(self.motor_y.name),
|
||||
],
|
||||
cb=self._device_status_callback_motors,
|
||||
parent=self,
|
||||
)
|
||||
|
||||
self.motors_consumer.start()
|
||||
|
||||
self.motors_selected.emit(self.motor_x, self.motor_y)
|
||||
|
||||
def get_all_motors(self) -> list:
|
||||
all_motors = client.device_manager.devices.acquisition_group("motor")
|
||||
return all_motors
|
||||
|
||||
def get_all_motors_names(self) -> list:
|
||||
all_motors = client.device_manager.devices.acquisition_group("motor")
|
||||
all_motors_names = [motor.name for motor in all_motors]
|
||||
return all_motors_names
|
||||
|
||||
def retrieve_all_motors(self):
|
||||
self.all_motors = self.get_all_motors()
|
||||
self.all_motors_names = self.get_all_motors_names()
|
||||
self.motors_loaded.emit(self.all_motors_names, self.all_motors_names)
|
||||
|
||||
return self.all_motors, self.all_motors_names
|
||||
|
||||
def get_coordinates(self) -> tuple:
|
||||
"""Get current motor position"""
|
||||
x = self.motor_x.read(cached=True)["value"]
|
||||
y = self.motor_y.read(cached=True)["value"]
|
||||
return x, y
|
||||
|
||||
def retrieve_coordinates(self) -> tuple:
|
||||
"""Get current motor position for export to main app"""
|
||||
return self.current_x, self.current_y
|
||||
|
||||
def get_motor_limits(self, motor) -> list:
|
||||
"""Get the limits of a motor"""
|
||||
return motor.limits
|
||||
|
||||
def get_motor_config(self, motor) -> dict:
|
||||
"""Get the configuration of a motor""" # TODO at this moment just for speed and update_frequency
|
||||
return motor.get_device_config()
|
||||
|
||||
def update_all_config(self, speed: list = None, update_frequency: list = None) -> None:
|
||||
# TODO now only speed and update frequency
|
||||
if speed is not None:
|
||||
self.motor_x.set_device_config({"speed": speed[0]})
|
||||
self.motor_y.set_device_config({"speed": speed[1]})
|
||||
|
||||
if update_frequency is not None:
|
||||
self.motor_x.set_device_config({"update_frequency": update_frequency[0]})
|
||||
self.motor_y.set_device_config({"update_frequency": update_frequency[1]})
|
||||
|
||||
self.retrieve_motor_speed(self.motor_x, self.motor_y)
|
||||
self.retrieve_motor_update_frequency(self.motor_x, self.motor_y)
|
||||
|
||||
def retrieve_motor_speed(
|
||||
self, motor_x, motor_y
|
||||
) -> None: # TODO can be migrated to some general config function
|
||||
"""Get the speed of a motor"""
|
||||
speed_x = motor_x.get_device_config()["speed"]
|
||||
speed_y = motor_y.get_device_config()["speed"]
|
||||
self.speed_retrieved.emit(int(speed_x), int(speed_y))
|
||||
|
||||
def retrieve_motor_update_frequency(
|
||||
self, motor_x, motor_y
|
||||
) -> None: # TODO can be migrated to some general config function
|
||||
"""Get the speed of a motor"""
|
||||
update_frequency_x = motor_x.get_device_config()["update_frequency"]
|
||||
update_frequency_y = motor_y.get_device_config()["update_frequency"]
|
||||
self.update_frequency_retrieved.emit(int(update_frequency_x), int(update_frequency_y))
|
||||
|
||||
def retrieve_motor_limits(self, motor_x, motor_y):
|
||||
limit_x = self.get_motor_limits(motor_x)
|
||||
limit_y = self.get_motor_limits(motor_y)
|
||||
self.limits_retrieved.emit(limit_x, limit_y)
|
||||
|
||||
def update_motor_limits(self, motor, low_limit=None, high_limit=None) -> None:
|
||||
current_low_limit, current_high_limit = self.get_motor_limits(motor)
|
||||
|
||||
# Check if the low limit has changed and is not None
|
||||
if low_limit is not None and low_limit != current_low_limit:
|
||||
motor.low_limit = low_limit
|
||||
|
||||
# Check if the high limit has changed and is not None
|
||||
if high_limit is not None and high_limit != current_high_limit:
|
||||
motor.high_limit = high_limit
|
||||
|
||||
def update_all_motor_limits(self, x_limit: list = None, y_limit: list = None) -> None:
|
||||
current_position = self.get_coordinates()
|
||||
|
||||
if x_limit is not None:
|
||||
if current_position[0] < x_limit[0] or current_position[0] > x_limit[1]:
|
||||
raise ValueError("Current motor position is outside the new limits (X)")
|
||||
else:
|
||||
self.update_motor_limits(self.motor_x, low_limit=x_limit[0], high_limit=x_limit[1])
|
||||
|
||||
if y_limit is not None:
|
||||
if current_position[1] < y_limit[0] or current_position[1] > y_limit[1]:
|
||||
raise ValueError("Current motor position is outside the new limits (Y)")
|
||||
else:
|
||||
self.update_motor_limits(self.motor_y, low_limit=y_limit[0], high_limit=y_limit[1])
|
||||
|
||||
self.retrieve_motor_limits(self.motor_x, self.motor_y)
|
||||
|
||||
def move_to_coordinates(self, target_coordinates: tuple):
|
||||
self.action = MotorActions.MOVE_TO_COORDINATES
|
||||
self.target_coordinates = target_coordinates
|
||||
self.start()
|
||||
|
||||
def move_relative(self, motor, value: float):
|
||||
self.action = MotorActions.MOVE_RELATIVE
|
||||
self.motor = motor
|
||||
self.value = value
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
if self.action == MotorActions.MOVE_TO_COORDINATES:
|
||||
self._move_motor_coordinate()
|
||||
elif self.action == MotorActions.MOVE_RELATIVE:
|
||||
self._move_motor_relative(self.motor, self.value)
|
||||
|
||||
def set_target_coordinates(self, target_coordinates: tuple) -> None:
|
||||
self.target_coordinates = target_coordinates
|
||||
|
||||
def _initialize_motor(self) -> None:
|
||||
self.motor_x, self.motor_y = None, None
|
||||
self.current_x, self.current_y = None, None
|
||||
|
||||
self.motors_consumer = None
|
||||
|
||||
# Get all available motors in the client
|
||||
self.all_motors = self.get_all_motors()
|
||||
self.all_motors_names = self.get_all_motors_names()
|
||||
self.retrieve_all_motors() # send motor list to GUI
|
||||
|
||||
self.target_coordinates = None
|
||||
|
||||
def _move_motor_coordinate(self) -> None:
|
||||
"""Move the motor to the specified coordinates"""
|
||||
status = scans.mv(
|
||||
self.motor_x,
|
||||
self.target_coordinates[0],
|
||||
self.motor_y,
|
||||
self.target_coordinates[1],
|
||||
relative=False,
|
||||
)
|
||||
|
||||
status.wait()
|
||||
self.move_finished.emit()
|
||||
|
||||
def _move_motor_relative(self, motor, value: float) -> None:
|
||||
status = scans.mv(motor, value, relative=True)
|
||||
|
||||
status.wait()
|
||||
self.move_finished.emit()
|
||||
|
||||
def stop_movement(self):
|
||||
queue.request_scan_abortion()
|
||||
queue.request_queue_reset()
|
||||
|
||||
@staticmethod
|
||||
def _device_status_callback_motors(msg, *, parent, **_kwargs) -> None:
|
||||
deviceMSG = BECMessage.DeviceMessage.loads(msg.value)
|
||||
if parent.motor_x.name in deviceMSG.content["signals"]:
|
||||
parent.current_x = deviceMSG.content["signals"][parent.motor_x.name]["value"]
|
||||
elif parent.motor_y.name in deviceMSG.content["signals"]:
|
||||
parent.current_y = deviceMSG.content["signals"][parent.motor_y.name]["value"]
|
||||
parent.coordinates_updated.emit(parent.current_x, parent.current_y)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from bec_lib import BECClient
|
||||
|
||||
# from bec_lib.core import ServiceConfig,RedisConnector
|
||||
|
||||
client = BECClient()
|
||||
# client.initialize(config=ServiceConfig(config_path="test_config.yaml"))
|
||||
|
||||
# Client initialization - by dispatcher
|
||||
# client = bec_dispatcher.client
|
||||
client.start()
|
||||
dev = client.device_manager.devices
|
||||
scans = client.scans
|
||||
queue = client.queue
|
||||
|
||||
app = QApplication([])
|
||||
window = MotorApp()
|
||||
window.show()
|
||||
app.exec_()
|
||||
@@ -1,168 +0,0 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QHBoxLayout,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QSpinBox,
|
||||
)
|
||||
from pyqtgraph import mkPen
|
||||
from pyqtgraph.Qt import QtCore
|
||||
from bec_widgets.qt_utils import Crosshair
|
||||
|
||||
|
||||
class ExampleApp(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# Layout
|
||||
self.layout = QHBoxLayout()
|
||||
self.setLayout(self.layout)
|
||||
|
||||
##########################
|
||||
# 1D Plot
|
||||
##########################
|
||||
|
||||
# PlotWidget
|
||||
self.plot_widget_1d = pg.PlotWidget(title="1D PlotWidget with multiple curves")
|
||||
self.plot_item_1d = self.plot_widget_1d.getPlotItem()
|
||||
self.plot_item_1d.setLogMode(True, True)
|
||||
|
||||
# 1D Datasets
|
||||
self.x_data = np.linspace(0, 10, 1000)
|
||||
|
||||
def gauss(x, mu, sigma):
|
||||
return (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
|
||||
|
||||
# same convention as in line_plot.py
|
||||
self.y_value_list = [
|
||||
gauss(self.x_data, 1, 1),
|
||||
gauss(self.x_data, 1.5, 3),
|
||||
abs(np.sin(self.x_data)),
|
||||
abs(np.cos(self.x_data)),
|
||||
abs(np.sin(2 * self.x_data)),
|
||||
] # List of y-values for multiple curves
|
||||
|
||||
self.curve_names = ["Gauss(1,1)", "Gauss(1.5,3)", "Abs(Sine)", "Abs(Cosine)", "Abs(Sine2x)"]
|
||||
self.curves = []
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
self.plot_widget_2d = pg.PlotWidget(title="2D plot with crosshair and ROI square")
|
||||
self.data_2D = np.random.random((100, 200))
|
||||
self.plot_item_2d = self.plot_widget_2d.getPlotItem()
|
||||
self.image_item = pg.ImageItem(self.data_2D)
|
||||
self.plot_item_2d.addItem(self.image_item)
|
||||
|
||||
##########################
|
||||
# Table
|
||||
##########################
|
||||
self.table = QTableWidget(len(self.curve_names), 2)
|
||||
self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"])
|
||||
self.table.setVerticalHeaderLabels(self.curve_names)
|
||||
self.table.resizeColumnsToContents()
|
||||
|
||||
##########################
|
||||
# Spinbox for N curves
|
||||
##########################
|
||||
self.spin_box = QSpinBox()
|
||||
self.spin_box.setMinimum(0)
|
||||
self.spin_box.setMaximum(len(self.y_value_list))
|
||||
self.spin_box.setValue(2)
|
||||
self.spin_box.valueChanged.connect(lambda: self.update_curves(self.spin_box.value()))
|
||||
|
||||
##########################
|
||||
# Adding widgets to layout
|
||||
##########################
|
||||
|
||||
##### left side #####
|
||||
self.column1 = QVBoxLayout()
|
||||
self.layout.addLayout(self.column1)
|
||||
|
||||
# SpinBox
|
||||
self.spin_row = QHBoxLayout()
|
||||
self.column1.addLayout(self.spin_row)
|
||||
self.spin_row.addWidget(QLabel("Number of curves:"))
|
||||
self.spin_row.addWidget(self.spin_box)
|
||||
|
||||
# label
|
||||
self.clicked_label_1d = QLabel("Clicked Coordinates (1D):")
|
||||
self.column1.addWidget(self.clicked_label_1d)
|
||||
|
||||
# table
|
||||
self.column1.addWidget(self.table)
|
||||
|
||||
# 1D plot
|
||||
self.column1.addWidget(self.plot_widget_1d)
|
||||
|
||||
##### left side #####
|
||||
self.column2 = QVBoxLayout()
|
||||
self.layout.addLayout(self.column2)
|
||||
|
||||
# labels
|
||||
self.clicked_label_2d = QLabel("Clicked Coordinates (2D):")
|
||||
self.moved_label_2d = QLabel("Moved Coordinates (2D):")
|
||||
self.column2.addWidget(self.clicked_label_2d)
|
||||
self.column2.addWidget(self.moved_label_2d)
|
||||
|
||||
# 2D plot
|
||||
self.column2.addWidget(self.plot_widget_2d)
|
||||
|
||||
self.update_curves(2) # just Gaussian curves
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot_item_1d, precision=10)
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(self.table, x, y, column=0)
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.update_table(self.table, x, y, column=1)
|
||||
)
|
||||
# 2D
|
||||
self.crosshair_2d = Crosshair(self.plot_item_2d)
|
||||
self.crosshair_2d.coordinatesChanged2D.connect(
|
||||
lambda x, y: self.moved_label_2d.setText(f"Mouse Moved Coordinates (2D): x={x}, y={y}")
|
||||
)
|
||||
self.crosshair_2d.coordinatesClicked2D.connect(
|
||||
lambda x, y: self.clicked_label_2d.setText(f"Clicked Coordinates (2D): x={x}, y={y}")
|
||||
)
|
||||
|
||||
def update_table(self, table_widget, x, y_values, column):
|
||||
"""Update the table with the new coordinates"""
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, column, QTableWidgetItem(f"({x}, {y})"))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update_curves(self, num_curves):
|
||||
"""Update the number of curves"""
|
||||
|
||||
self.plot_item_1d.clear()
|
||||
|
||||
# Curves
|
||||
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
|
||||
self.plot_item_1d.addLegend()
|
||||
self.curves = []
|
||||
|
||||
y_value_list = self.y_value_list[:num_curves]
|
||||
|
||||
for ii, y_value in enumerate(y_value_list):
|
||||
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
curve = pg.PlotDataItem(
|
||||
self.x_data, y_value, pen=pen, skipFiniteCheck=True, name=self.curve_names[ii]
|
||||
)
|
||||
self.plot_item_1d.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
|
||||
self.hook_crosshair()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
window = ExampleApp()
|
||||
window.show()
|
||||
app.exec_()
|
||||
17
bec_widgets/examples/plugin_example_pyside/main.py
Normal file
17
bec_widgets/examples/plugin_example_pyside/main.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
"""PySide6 port of the Qt Designer taskmenuextension example from Qt v6.x"""
|
||||
|
||||
import sys
|
||||
|
||||
from bec_ipython_client.main import BECIPythonClient
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from tictactoe import TicTacToe
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
window = TicTacToe()
|
||||
window.state = "-X-XO----"
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
from tictactoe import TicTacToe
|
||||
from tictactoeplugin import TicTacToePlugin
|
||||
|
||||
# Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(TicTacToePlugin())
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": ["tictactoe.py", "main.py", "registertictactoe.py", "tictactoeplugin.py",
|
||||
"tictactoetaskmenu.py"]
|
||||
}
|
||||
135
bec_widgets/examples/plugin_example_pyside/tictactoe.py
Normal file
135
bec_widgets/examples/plugin_example_pyside/tictactoe.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtCore import Property, QPoint, QRect, QSize, Qt, Slot
|
||||
from qtpy.QtGui import QPainter, QPen
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
EMPTY = "-"
|
||||
CROSS = "X"
|
||||
NOUGHT = "O"
|
||||
DEFAULT_STATE = "---------"
|
||||
|
||||
|
||||
class TicTacToe(QWidget): # pragma: no cover
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._state = DEFAULT_STATE
|
||||
self._turn_number = 0
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(200, 200)
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(200, 200)
|
||||
|
||||
def setState(self, new_state):
|
||||
self._turn_number = 0
|
||||
self._state = DEFAULT_STATE
|
||||
for position in range(min(9, len(new_state))):
|
||||
mark = new_state[position]
|
||||
if mark == CROSS or mark == NOUGHT:
|
||||
self._turn_number += 1
|
||||
self._change_state_at(position, mark)
|
||||
position += 1
|
||||
self.update()
|
||||
|
||||
def state(self):
|
||||
return self._state
|
||||
|
||||
@Slot()
|
||||
def clear_board(self):
|
||||
self._state = DEFAULT_STATE
|
||||
self._turn_number = 0
|
||||
self.update()
|
||||
|
||||
def _change_state_at(self, pos, new_state):
|
||||
self._state = self._state[:pos] + new_state + self._state[pos + 1 :]
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if self._turn_number == 9:
|
||||
self.clear_board()
|
||||
return
|
||||
for position in range(9):
|
||||
cell = self._cell_rect(position)
|
||||
if cell.contains(event.position().toPoint()):
|
||||
if self._state[position] == EMPTY:
|
||||
new_state = CROSS if self._turn_number % 2 == 0 else NOUGHT
|
||||
self._change_state_at(position, new_state)
|
||||
self._turn_number += 1
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
with QPainter(self) as painter:
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
painter.setPen(QPen(Qt.darkGreen, 1))
|
||||
painter.drawLine(self._cell_width(), 0, self._cell_width(), self.height())
|
||||
painter.drawLine(2 * self._cell_width(), 0, 2 * self._cell_width(), self.height())
|
||||
painter.drawLine(0, self._cell_height(), self.width(), self._cell_height())
|
||||
painter.drawLine(0, 2 * self._cell_height(), self.width(), 2 * self._cell_height())
|
||||
|
||||
painter.setPen(QPen(Qt.darkBlue, 2))
|
||||
|
||||
for position in range(9):
|
||||
cell = self._cell_rect(position)
|
||||
if self._state[position] == CROSS:
|
||||
painter.drawLine(cell.topLeft(), cell.bottomRight())
|
||||
painter.drawLine(cell.topRight(), cell.bottomLeft())
|
||||
elif self._state[position] == NOUGHT:
|
||||
painter.drawEllipse(cell)
|
||||
|
||||
painter.setPen(QPen(Qt.yellow, 3))
|
||||
|
||||
for position in range(0, 8, 3):
|
||||
if (
|
||||
self._state[position] != EMPTY
|
||||
and self._state[position + 1] == self._state[position]
|
||||
and self._state[position + 2] == self._state[position]
|
||||
):
|
||||
y = self._cell_rect(position).center().y()
|
||||
painter.drawLine(0, y, self.width(), y)
|
||||
self._turn_number = 9
|
||||
|
||||
for position in range(3):
|
||||
if (
|
||||
self._state[position] != EMPTY
|
||||
and self._state[position + 3] == self._state[position]
|
||||
and self._state[position + 6] == self._state[position]
|
||||
):
|
||||
x = self._cell_rect(position).center().x()
|
||||
painter.drawLine(x, 0, x, self.height())
|
||||
self._turn_number = 9
|
||||
|
||||
if (
|
||||
self._state[0] != EMPTY
|
||||
and self._state[4] == self._state[0]
|
||||
and self._state[8] == self._state[0]
|
||||
):
|
||||
painter.drawLine(0, 0, self.width(), self.height())
|
||||
self._turn_number = 9
|
||||
|
||||
if (
|
||||
self._state[2] != EMPTY
|
||||
and self._state[4] == self._state[2]
|
||||
and self._state[6] == self._state[2]
|
||||
):
|
||||
painter.drawLine(0, self.height(), self.width(), 0)
|
||||
self._turn_number = 9
|
||||
|
||||
def _cell_rect(self, position):
|
||||
h_margin = self.width() / 30
|
||||
v_margin = self.height() / 30
|
||||
row = int(position / 3)
|
||||
column = position - 3 * row
|
||||
pos = QPoint(column * self._cell_width() + h_margin, row * self._cell_height() + v_margin)
|
||||
size = QSize(self._cell_width() - 2 * h_margin, self._cell_height() - 2 * v_margin)
|
||||
return QRect(pos, size)
|
||||
|
||||
def _cell_width(self):
|
||||
return self.width() / 3
|
||||
|
||||
def _cell_height(self):
|
||||
return self.height() / 3
|
||||
|
||||
state = Property(str, state, setState)
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
from tictactoe import TicTacToe
|
||||
from tictactoetaskmenu import TicTacToeTaskMenuFactory
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='TicTacToe' name='ticTacToe'>
|
||||
<property name='geometry'>
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>200</width>
|
||||
<height>200</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name='state'>
|
||||
<string>-X-XO----</string>
|
||||
</property>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class TicTacToePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = TicTacToe(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def includeFile(self):
|
||||
return "tictactoe"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
manager = form_editor.extensionManager()
|
||||
iid = TicTacToeTaskMenuFactory.task_menu_iid()
|
||||
manager.registerExtensions(TicTacToeTaskMenuFactory(manager), iid)
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "TicTacToe"
|
||||
|
||||
def toolTip(self):
|
||||
return "Tic Tac Toe Example, demonstrating class QDesignerTaskMenuExtension (Python)"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,67 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtCore import Slot
|
||||
from qtpy.QtDesigner import QExtensionFactory, QPyDesignerTaskMenuExtension
|
||||
from qtpy.QtGui import QAction
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
|
||||
from tictactoe import TicTacToe
|
||||
|
||||
|
||||
class TicTacToeDialog(QDialog): # pragma: no cover
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
self._ticTacToe = TicTacToe(self)
|
||||
layout.addWidget(self._ticTacToe)
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset
|
||||
)
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
reset_button = button_box.button(QDialogButtonBox.Reset)
|
||||
reset_button.clicked.connect(self._ticTacToe.clear_board)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def set_state(self, new_state):
|
||||
self._ticTacToe.setState(new_state)
|
||||
|
||||
def state(self):
|
||||
return self._ticTacToe.state
|
||||
|
||||
|
||||
class TicTacToeTaskMenu(QPyDesignerTaskMenuExtension):
|
||||
def __init__(self, ticTacToe, parent):
|
||||
super().__init__(parent)
|
||||
self._ticTacToe = ticTacToe
|
||||
self._edit_state_action = QAction("Edit State...", None)
|
||||
self._edit_state_action.triggered.connect(self._edit_state)
|
||||
|
||||
def taskActions(self):
|
||||
return [self._edit_state_action]
|
||||
|
||||
def preferredEditAction(self):
|
||||
return self._edit_state_action
|
||||
|
||||
@Slot()
|
||||
def _edit_state(self):
|
||||
dialog = TicTacToeDialog(self._ticTacToe)
|
||||
dialog.set_state(self._ticTacToe.state)
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
self._ticTacToe.state = dialog.state()
|
||||
|
||||
|
||||
class TicTacToeTaskMenuFactory(QExtensionFactory):
|
||||
def __init__(self, extension_manager):
|
||||
super().__init__(extension_manager)
|
||||
|
||||
@staticmethod
|
||||
def task_menu_iid():
|
||||
return "org.qt-project.Qt.Designer.TaskMenu"
|
||||
|
||||
def createExtension(self, object, iid, parent):
|
||||
if iid != TicTacToeTaskMenuFactory.task_menu_iid():
|
||||
return None
|
||||
if object.__class__.__name__ != "TicTacToe":
|
||||
return None
|
||||
return TicTacToeTaskMenu(object, parent)
|
||||
@@ -1,408 +0,0 @@
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.core import BECMessage
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtWidgets import QCheckBox, QTableWidgetItem
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets, uic
|
||||
from pyqtgraph.Qt.QtCore import pyqtSignal
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
from bec_lib.core.redis_connector import MessageObject, RedisConnector
|
||||
|
||||
from qt_utils import Crosshair
|
||||
|
||||
client = bec_dispatcher.client
|
||||
|
||||
|
||||
class BasicPlot(QtWidgets.QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
roi_signal = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
|
||||
"""
|
||||
Basic plot widget for displaying scan data.
|
||||
|
||||
Args:
|
||||
name (str, optional): Name of the plot. Defaults to "".
|
||||
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
|
||||
"""
|
||||
|
||||
super(BasicPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
|
||||
|
||||
self._idle_time = 100
|
||||
self.producer = RedisConnector(["localhost:6379"]).producer()
|
||||
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
|
||||
self.plotter_scan_id = None
|
||||
|
||||
self._current_proj = None
|
||||
self._current_metadata_ep = "px_stream/projection_{}/metadata"
|
||||
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
|
||||
self.data_retriever = threading.Thread(target=self.on_projection, daemon=True)
|
||||
self.data_retriever.start()
|
||||
|
||||
# self.comboBox.currentIndexChanged.connect(lambda : print(f'current comboText: {self.comboBox.currentText()}'))
|
||||
# self.comboBox.currentIndexChanged.connect(lambda: print(f'current comboIndex: {self.comboBox.currentIndex()}'))
|
||||
#
|
||||
# self.doubleSpinBox.valueChanged.connect(lambda : print('Spin Changed'))
|
||||
|
||||
# self.splitterH_main.setSizes([1, 1])
|
||||
|
||||
##########################
|
||||
# UI
|
||||
##########################
|
||||
self.init_ui()
|
||||
self.init_curves()
|
||||
self.hook_crosshair()
|
||||
self.pushButton_generate.clicked.connect(self.generate_data)
|
||||
|
||||
def init_ui(self):
|
||||
"""Setup all ui elements"""
|
||||
##########################
|
||||
# 1D Plot
|
||||
##########################
|
||||
|
||||
# LabelItem for ROI
|
||||
self.label_plot = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot)
|
||||
self.label_plot.setText("ROI region")
|
||||
|
||||
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
|
||||
self.roi_selector = pg.LinearRegionItem([-1, 1])
|
||||
|
||||
self.glw_plot.nextRow() # TODO update of cursor
|
||||
self.label_plot_moved = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot_moved)
|
||||
self.label_plot_moved.setText("Actual coordinates (X, Y)")
|
||||
|
||||
# Label for coordinates clicked
|
||||
self.glw_plot.nextRow()
|
||||
self.label_plot_clicked = pg.LabelItem(justify="center")
|
||||
self.glw_plot.addItem(self.label_plot_clicked)
|
||||
self.label_plot_clicked.setText("Clicked coordinates (X, Y)")
|
||||
|
||||
# 1D PlotItem
|
||||
self.glw_plot.nextRow()
|
||||
self.plot = pg.PlotItem()
|
||||
self.plot.setLogMode(True, True)
|
||||
self.glw_plot.addItem(self.plot)
|
||||
self.plot.addLegend()
|
||||
|
||||
##########################
|
||||
# 2D Plot
|
||||
##########################
|
||||
|
||||
# Label for coordinates moved
|
||||
self.label_image_moved = pg.LabelItem(justify="center")
|
||||
self.glw_image.addItem(self.label_image_moved)
|
||||
self.label_image_moved.setText("Actual coordinates (X, Y)")
|
||||
|
||||
# Label for coordinates clicked
|
||||
self.glw_image.nextRow()
|
||||
self.label_image_clicked = pg.LabelItem(justify="center")
|
||||
self.glw_image.addItem(self.label_image_clicked)
|
||||
self.label_image_clicked.setText("Clicked coordinates (X, Y)")
|
||||
|
||||
# TODO try to lock aspect ratio with view
|
||||
|
||||
# # Create a window
|
||||
# win = pg.GraphicsLayoutWidget()
|
||||
# win.show()
|
||||
#
|
||||
# # Create a ViewBox
|
||||
# view = win.addViewBox()
|
||||
#
|
||||
# # Lock the aspect ratio
|
||||
# view.setAspectLocked(True)
|
||||
|
||||
# # Create an ImageItem
|
||||
# image_item = pg.ImageItem(np.random.random((100, 100)))
|
||||
#
|
||||
# # Add the ImageItem to the ViewBox
|
||||
# view.addItem(image_item)
|
||||
|
||||
# 2D ImageItem
|
||||
self.glw_image.nextRow()
|
||||
self.plot_image = pg.PlotItem()
|
||||
self.glw_image.addItem(self.plot_image)
|
||||
|
||||
def init_curves(self):
|
||||
# init of 1D plot
|
||||
self.plot.clear()
|
||||
|
||||
self.curves = []
|
||||
self.pens = []
|
||||
self.brushs = []
|
||||
|
||||
self.color_list = BasicPlot.golden_angle_color(
|
||||
colormap="CET-R2", num=len(self.y_value_list)
|
||||
)
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
pen = mkPen(color=self.color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
brush = mkBrush(color=self.color_list[ii])
|
||||
curve = pg.PlotDataItem(symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value)
|
||||
self.plot.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self.pens.append(pen)
|
||||
self.brushs.append(brush)
|
||||
|
||||
# check if roi selector is in the plot
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
# init of 2D plot
|
||||
self.plot_image.clear()
|
||||
|
||||
self.img = pg.ImageItem()
|
||||
self.plot_image.addItem(self.img)
|
||||
|
||||
# hooking signals
|
||||
self.hook_crosshair()
|
||||
self.init_table()
|
||||
|
||||
def splitter_sizes(self):
|
||||
...
|
||||
|
||||
def hook_crosshair(self):
|
||||
self.crosshair_1d = Crosshair(self.plot, precision=4)
|
||||
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.label_plot_moved.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
self.crosshair_1d.coordinatesClicked1D.connect(
|
||||
lambda x, y: self.label_plot_clicked.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
|
||||
self.crosshair_1d.coordinatesChanged1D.connect(
|
||||
lambda x, y: self.update_table(table_widget=self.cursor_table, x=x, y_values=y)
|
||||
)
|
||||
|
||||
self.crosshair_2D = Crosshair(self.plot_image)
|
||||
|
||||
self.crosshair_2D.coordinatesChanged2D.connect(
|
||||
lambda x, y: self.label_image_moved.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
self.crosshair_2D.coordinatesClicked2D.connect(
|
||||
lambda x, y: self.label_image_clicked.setText(f"Moved : ({x}, {y})")
|
||||
)
|
||||
|
||||
# ROI
|
||||
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
|
||||
|
||||
def generate_data(self):
|
||||
def gauss(x, mu, sigma):
|
||||
return (1 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((x - mu) / sigma) ** 2)
|
||||
|
||||
self.plotter_data_x = np.linspace(0, 10, 1000)
|
||||
self.plotter_data_y = [
|
||||
gauss(self.plotter_data_x, 1, 1),
|
||||
gauss(self.plotter_data_x, 1.5, 3),
|
||||
np.sin(self.plotter_data_x),
|
||||
np.cos(self.plotter_data_x),
|
||||
np.sin(2 * self.plotter_data_x),
|
||||
] # List of y-values for multiple curves
|
||||
self.y_value_list = ["Gauss (1,1)", "Gauss (1.5,3)"] # ["Sine"]#, "Cosine", "Sine2x"]
|
||||
|
||||
# Curves
|
||||
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
|
||||
|
||||
self.init_curves()
|
||||
|
||||
for ii in range(len(self.y_value_list)):
|
||||
self.curves[ii].setData(self.plotter_data_x, self.plotter_data_y[ii])
|
||||
|
||||
self.data_2D = np.random.random((150, 30))
|
||||
self.img.setImage(self.data_2D)
|
||||
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
def get_roi_region(self):
|
||||
"""For testing purpose now, get roi region and print it to self.label as tuple"""
|
||||
region = self.roi_selector.getRegion()
|
||||
self.label_plot.setText(f"x = {(10 ** region[0]):.4f}, y ={(10 ** region[1]):.4f}")
|
||||
return_dict = {
|
||||
"horiz_roi": [
|
||||
np.where(self.plotter_data_x[0] > 10 ** region[0])[0][0],
|
||||
np.where(self.plotter_data_x[0] < 10 ** region[1])[0][-1],
|
||||
]
|
||||
}
|
||||
msg = BECMessage.DeviceMessage(signals=return_dict).dumps()
|
||||
self.producer.set_and_publish("px_stream/gui_event", msg=msg)
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
def init_table(self):
|
||||
# Init number of rows in table according to n of devices
|
||||
self.cursor_table.setRowCount(len(self.y_value_list))
|
||||
# self.table.setHorizontalHeaderLabels(["(X, Y) - Moved", "(X, Y) - Clicked"]) #TODO can be dynamic
|
||||
self.cursor_table.setVerticalHeaderLabels(self.y_value_list)
|
||||
self.cursor_table.resizeColumnsToContents()
|
||||
|
||||
def update_table(self, table_widget, x, y_values):
|
||||
for i, y in enumerate(y_values):
|
||||
table_widget.setItem(i, 1, QTableWidgetItem(str(x)))
|
||||
table_widget.setItem(i, 2, QTableWidgetItem(str(y)))
|
||||
table_widget.resizeColumnsToContents()
|
||||
|
||||
def update(self):
|
||||
"""Update the plot with the new data."""
|
||||
|
||||
# check if QTable was initialised and if list of devices was changed
|
||||
# if self.y_value_list != self.previous_y_value_list:
|
||||
# self.setup_cursor_table()
|
||||
# self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
|
||||
|
||||
self.curves[0].setData(self.plotter_data_x[0], self.plotter_data_y[0])
|
||||
|
||||
@staticmethod
|
||||
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
|
||||
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
|
||||
"""Removes a curve from the given plot by the specified name.
|
||||
|
||||
Args:
|
||||
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
|
||||
name (str): The name of the curve to remove.
|
||||
"""
|
||||
# if checkbox.isChecked():
|
||||
for item in plot.items:
|
||||
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
|
||||
plot.removeItem(item)
|
||||
return
|
||||
|
||||
# else:
|
||||
# return
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = BasicPlot.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
def on_projection(self):
|
||||
while True:
|
||||
if self._current_proj is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
endpoint = f"px_stream/projection_{self._current_proj}/data"
|
||||
msgs = client.producer.lrange(topic=endpoint, start=-1, end=-1)
|
||||
data = [BECMessage.DeviceMessage.loads(msg) for msg in msgs]
|
||||
if not data:
|
||||
continue
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
self.plotter_data_y = [
|
||||
np.sum(
|
||||
np.sum(data[-1].content["signals"]["data"] * self._current_norm, axis=1)
|
||||
/ np.sum(self._current_norm, axis=0),
|
||||
axis=0,
|
||||
).squeeze()
|
||||
]
|
||||
|
||||
self.update_signal.emit()
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_dap_update(self, data: dict, metadata: dict):
|
||||
self.img.setImage(data["z"])
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def new_proj(self, data):
|
||||
proj_nr = data["proj_nr"]
|
||||
endpoint = f"px_stream/projection_{proj_nr}/metadata"
|
||||
msg_raw = client.producer.get(topic=endpoint)
|
||||
msg = BECMessage.DeviceMessage.loads(msg_raw)
|
||||
self._current_q = msg.content["signals"]["q"]
|
||||
self._current_norm = msg.content["signals"]["norm_sum"]
|
||||
self._current_metadata = msg.content["signals"]["metadata"]
|
||||
|
||||
self.plotter_data_x = [self._current_q]
|
||||
self._current_proj = proj_nr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--signals",
|
||||
help="specify recorded signals",
|
||||
nargs="+",
|
||||
default=["gauss_bpm"],
|
||||
)
|
||||
# default = ["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
|
||||
# dispatcher = bec_dispatcher
|
||||
value = parser.parse_args()
|
||||
print(f"Plotting signals for: {', '.join(value.signals)}")
|
||||
client = bec_dispatcher.client
|
||||
# client.start()
|
||||
app = QtWidgets.QApplication([])
|
||||
ctrl_c.setup(app)
|
||||
plot = BasicPlot(y_value_list=value.signals)
|
||||
# bec_dispatcher.connect(plot)
|
||||
bec_dispatcher.connect_proj_id(plot.new_proj)
|
||||
bec_dispatcher.connect_dap_slot(plot.on_dap_update, "px_dap_worker")
|
||||
plot.roi_signal.connect(lambda x: print(f"signal from ROI {x}"))
|
||||
plot.roi_signal.connect(lambda x: bec_dispatcher.getStuff(x))
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec_()
|
||||
@@ -1,155 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>845</width>
|
||||
<height>635</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Line Plot</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<widget class="QSplitter" name="splitter_plot">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<widget class="GraphicsLayoutWidget" name="glw_plot"/>
|
||||
<widget class="GraphicsLayoutWidget" name="glw_image"/>
|
||||
</widget>
|
||||
<widget class="QWidget" name="">
|
||||
<layout class="QVBoxLayout" name="verticalLayout" stretch="1,1,1,15">
|
||||
<item>
|
||||
<widget class="QPushButton" name="pushButton_generate">
|
||||
<property name="text">
|
||||
<string>Generate 1D and 2D data without stream</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="title">
|
||||
<string>1st angle of azimutal segment (deg)</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QDoubleSpinBox" name="doubleSpinBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<double>360.000000000000000</double>
|
||||
</property>
|
||||
<property name="singleStep">
|
||||
<double>0.250000000000000</double>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="comboBox">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f1amp</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f2amp</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>f2 phase</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="spinBox_precision">
|
||||
<property name="value">
|
||||
<number>4</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="cursor_table">
|
||||
<property name="textElideMode">
|
||||
<enum>Qt::ElideMiddle</enum>
|
||||
</property>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Display</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>X</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Y</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>GraphicsLayoutWidget</class>
|
||||
<extends>QGraphicsView</extends>
|
||||
<header>pyqtgraph.h</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,360 +0,0 @@
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import pyqtSlot
|
||||
from PyQt5.QtWidgets import QTableWidgetItem, QCheckBox
|
||||
|
||||
from bec_lib import BECClient
|
||||
from pyqtgraph import mkBrush, mkColor, mkPen
|
||||
from pyqtgraph.Qt import QtCore, QtWidgets, uic
|
||||
from pyqtgraph.Qt.QtCore import pyqtSignal
|
||||
|
||||
|
||||
class BasicPlot(QtWidgets.QWidget):
|
||||
update_signal = pyqtSignal()
|
||||
roi_signal = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, name="", y_value_list=["gauss_bpm"]) -> None:
|
||||
"""
|
||||
Basic plot widget for displaying scan data.
|
||||
|
||||
Args:
|
||||
name (str, optional): Name of the plot. Defaults to "".
|
||||
y_value_list (list, optional): List of signals to be plotted. Defaults to ["gauss_bpm"].
|
||||
"""
|
||||
|
||||
super(BasicPlot, self).__init__()
|
||||
# Set style for pyqtgraph plots
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
current_path = os.path.dirname(__file__)
|
||||
uic.loadUi(os.path.join(current_path, "line_plot.ui"), self)
|
||||
|
||||
# Set splitter distribution of widgets
|
||||
self.splitter.setSizes([5, 2])
|
||||
|
||||
self._idle_time = 100
|
||||
self.title = ""
|
||||
self.label_bottom = ""
|
||||
self.label_left = ""
|
||||
|
||||
self.scan_motors = []
|
||||
self.y_value_list = y_value_list
|
||||
self.previous_y_value_list = None
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
self.curves = []
|
||||
self.pens = []
|
||||
self.brushs = []
|
||||
|
||||
self.plotter_scan_id = None
|
||||
|
||||
# TODO to be moved to utils function
|
||||
plotstyles = {
|
||||
"symbol": "o",
|
||||
"symbolSize": 10,
|
||||
}
|
||||
color_list = ["#384c6b", "#e28a2b", "#5E3023", "#e41a1c", "#984e83", "#4daf4a"]
|
||||
color_list = BasicPlot.golden_angle_color(colormap="CET-R2", num=len(self.y_value_list))
|
||||
|
||||
# setup plots - GraphicsLayoutWidget
|
||||
# LabelItem
|
||||
self.label = pg.LabelItem(justify="center")
|
||||
self.glw.addItem(self.label)
|
||||
self.label.setText("test label")
|
||||
|
||||
# PlotItem - main window
|
||||
self.glw.nextRow()
|
||||
self.plot = pg.PlotItem()
|
||||
self.glw.addItem(self.plot)
|
||||
self.plot.addLegend()
|
||||
|
||||
# PlotItem - ROI window - disabled for now #TODO add 2D plot for ROI and 1D plot for mouse click
|
||||
# self.glw.nextRow()
|
||||
# self.plot_roi = pg.PlotItem()
|
||||
# self.glw.addItem(self.plot_roi)
|
||||
|
||||
# ROI selector - so far from [-1,1] #TODO update to scale with xrange
|
||||
self.roi_selector = pg.LinearRegionItem([-1, 1])
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
pen = mkPen(color=color_list[ii], width=2, style=QtCore.Qt.DashLine)
|
||||
brush = mkBrush(color=color_list[ii])
|
||||
curve = pg.PlotDataItem(
|
||||
**plotstyles, symbolBrush=brush, pen=pen, skipFiniteCheck=True, name=y_value
|
||||
)
|
||||
self.plot.addItem(curve)
|
||||
self.curves.append(curve)
|
||||
self.pens.append(pen)
|
||||
self.brushs.append(brush)
|
||||
|
||||
self.crosshair_v = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.crosshair_h = pg.InfiniteLine(angle=0, movable=False)
|
||||
self.plot.addItem(self.crosshair_v, ignoreBounds=True)
|
||||
self.plot.addItem(self.crosshair_h, ignoreBounds=True)
|
||||
|
||||
# Add textItems
|
||||
self.add_text_items()
|
||||
|
||||
# Manage signals
|
||||
self.proxy = pg.SignalProxy(
|
||||
self.plot.scene().sigMouseMoved, rateLimit=60, slot=self.mouse_moved
|
||||
)
|
||||
self.proxy_update = pg.SignalProxy(self.update_signal, rateLimit=25, slot=self.update)
|
||||
self.roi_selector.sigRegionChangeFinished.connect(self.get_roi_region)
|
||||
self.pushButton_debug.clicked.connect(self.debug)
|
||||
|
||||
def debug(self):
|
||||
"""
|
||||
Debug button just for quick testing
|
||||
"""
|
||||
|
||||
def get_roi_region(self):
|
||||
"""For testing purpose now, get roi region and print it to self.label as tuple"""
|
||||
region = self.roi_selector.getRegion()
|
||||
self.label.setText(f"x = {region[0]:.4f}, y ={region[1]:.4f}")
|
||||
self.roi_signal.emit(region)
|
||||
|
||||
def add_text_items(self): # TODO probably can be removed
|
||||
"""Add text items to the plot"""
|
||||
|
||||
# self.mouse_box_data.setText("Mouse cursor")
|
||||
# TODO Via StyleSheet, one may set the color of the full QLabel
|
||||
# self.mouse_box_data.setStyleSheet(f"QLabel {{color : rgba{self.pens[0].color().getRgb()}}}")
|
||||
|
||||
def mouse_moved(self, event: tuple) -> None:
|
||||
"""
|
||||
Update the mouse table with the current mouse position and the corresponding data.
|
||||
|
||||
Args:
|
||||
event (tuple): Mouse event containing the position of the mouse cursor.
|
||||
The position is stored in first entry as horizontal, vertical pixel.
|
||||
"""
|
||||
pos = event[0]
|
||||
if not self.plot.sceneBoundingRect().contains(pos):
|
||||
return
|
||||
mousePoint = self.plot.vb.mapSceneToView(pos)
|
||||
self.crosshair_v.setPos(mousePoint.x())
|
||||
self.crosshair_h.setPos(mousePoint.y())
|
||||
if not self.plotter_data_x:
|
||||
return
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
closest_point = self.closest_x_y_value(
|
||||
mousePoint.x(), self.plotter_data_x, self.plotter_data_y[ii]
|
||||
)
|
||||
# TODO fix text wobble in plot, see plot when it crosses 0
|
||||
x_data = f"{closest_point[0]:.{self.precision}f}"
|
||||
y_data = f"{closest_point[1]:.{self.precision}f}"
|
||||
|
||||
# Write coordinate to QTable
|
||||
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
|
||||
self.mouse_table.setItem(ii, 2, QTableWidgetItem(str(x_data)))
|
||||
self.mouse_table.setItem(ii, 3, QTableWidgetItem(str(y_data)))
|
||||
|
||||
self.mouse_table.resizeColumnsToContents()
|
||||
|
||||
def closest_x_y_value(self, input_value, list_x, list_y) -> tuple:
|
||||
"""
|
||||
Find the closest x and y value to the input value.
|
||||
|
||||
Args:
|
||||
input_value (float): Input value
|
||||
list_x (list): List of x values
|
||||
list_y (list): List of y values
|
||||
|
||||
Returns:
|
||||
tuple: Closest x and y value
|
||||
"""
|
||||
arr = np.asarray(list_x)
|
||||
i = (np.abs(arr - input_value)).argmin()
|
||||
return list_x[i], list_y[i]
|
||||
|
||||
def update(self):
|
||||
"""Update the plot with the new data."""
|
||||
# check if roi selector is in the plot
|
||||
if self.roi_selector not in self.plot.items:
|
||||
self.plot.addItem(self.roi_selector)
|
||||
|
||||
# check if QTable was initialised and if list of devices was changed
|
||||
if self.y_value_list != self.previous_y_value_list:
|
||||
self.setup_cursor_table()
|
||||
self.previous_y_value_list = self.y_value_list.copy() if self.y_value_list else None
|
||||
|
||||
if len(self.plotter_data_x) <= 1:
|
||||
return
|
||||
self.plot.setLabel("bottom", self.label_bottom)
|
||||
self.plot.setLabel("left", self.label_left)
|
||||
for ii in range(len(self.y_value_list)):
|
||||
self.curves[ii].setData(self.plotter_data_x, self.plotter_data_y[ii])
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, data: dict, metadata: dict) -> None:
|
||||
"""Update function that is called during the scan callback. To avoid
|
||||
too many renderings, the GUI is only processing events every <_idle_time> ms.
|
||||
|
||||
Args:
|
||||
data (dict): Dictionary containing a new scan segment
|
||||
metadata (dict): Scan metadata
|
||||
|
||||
"""
|
||||
if metadata["scanID"] != self.plotter_scan_id:
|
||||
self.plotter_scan_id = metadata["scanID"]
|
||||
self._reset_plot_data()
|
||||
|
||||
self.title = f"Scan {metadata['scan_number']}"
|
||||
|
||||
self.scan_motors = scan_motors = metadata.get("scan_report_devices")
|
||||
# client = BECClient()
|
||||
remove_y_value_index = [
|
||||
index
|
||||
for index, y_value in enumerate(self.y_value_list)
|
||||
if y_value not in client.device_manager.devices
|
||||
]
|
||||
if remove_y_value_index:
|
||||
for ii in sorted(remove_y_value_index, reverse=True):
|
||||
# TODO Use bec warning message??? to be discussed with Klaus
|
||||
warnings.warn(
|
||||
f"Warning: no matching signal for {self.y_value_list[ii]} found in list of devices. Removing from plot."
|
||||
)
|
||||
self.remove_curve_by_name(self.plot, self.y_value_list[ii])
|
||||
self.y_value_list.pop(ii)
|
||||
|
||||
self.precision = client.device_manager.devices[scan_motors[0]]._info["describe"][
|
||||
scan_motors[0]
|
||||
]["precision"]
|
||||
# TODO after update of bec_lib, this will be new way to access data
|
||||
# self.precision = client.device_manager.devices[scan_motors[0]].precision
|
||||
|
||||
x = data["data"][scan_motors[0]][scan_motors[0]]["value"]
|
||||
self.plotter_data_x.append(x)
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
y = data["data"][y_value][y_value]["value"]
|
||||
self.plotter_data_y[ii].append(y)
|
||||
self.label_bottom = scan_motors[0]
|
||||
self.label_left = f"{', '.join(self.y_value_list)}"
|
||||
|
||||
# print(f'metadata scan N{metadata["scan_number"]}') #TODO put as label on top of plot
|
||||
# print(f'Data point = {data["point_id"]}') #TODO can be used for progress bar
|
||||
|
||||
if len(self.plotter_data_x) <= 1:
|
||||
return
|
||||
self.update_signal.emit()
|
||||
|
||||
def _reset_plot_data(self):
|
||||
"""Reset the plot data."""
|
||||
self.plotter_data_x = []
|
||||
self.plotter_data_y = []
|
||||
for ii in range(len(self.y_value_list)):
|
||||
self.curves[ii].setData([], [])
|
||||
self.plotter_data_y.append([])
|
||||
|
||||
def setup_cursor_table(self):
|
||||
"""QTable formatting according to N of devices displayed in plot."""
|
||||
|
||||
# Init number of rows in table according to n of devices
|
||||
self.mouse_table.setRowCount(len(self.y_value_list))
|
||||
|
||||
for ii, y_value in enumerate(self.y_value_list):
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setChecked(True)
|
||||
# TODO just for testing, will be replaced by removing/adding curve
|
||||
checkbox.stateChanged.connect(lambda: print("status Changed"))
|
||||
# checkbox.stateChanged.connect(lambda: self.remove_curve_by_name(plot=self.plot, checkbox=checkbox, name=y_value))
|
||||
self.mouse_table.setCellWidget(ii, 0, checkbox)
|
||||
self.mouse_table.setItem(ii, 1, QTableWidgetItem(str(y_value)))
|
||||
|
||||
self.mouse_table.resizeColumnsToContents()
|
||||
|
||||
@staticmethod
|
||||
def remove_curve_by_name(plot: pyqtgraph.PlotItem, name: str) -> None:
|
||||
# def remove_curve_by_name(plot: pyqtgraph.PlotItem, checkbox: QtWidgets.QCheckBox, name: str) -> None:
|
||||
"""Removes a curve from the given plot by the specified name.
|
||||
|
||||
Args:
|
||||
plot (pyqtgraph.PlotItem): The plot from which to remove the curve.
|
||||
name (str): The name of the curve to remove.
|
||||
"""
|
||||
# if checkbox.isChecked():
|
||||
for item in plot.items:
|
||||
if isinstance(item, pg.PlotDataItem) and getattr(item, "opts", {}).get("name") == name:
|
||||
plot.removeItem(item)
|
||||
return
|
||||
|
||||
# else:
|
||||
# return
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(colormap: str, num: int) -> list:
|
||||
"""
|
||||
Extract num colors for from the specified colormap following golden angle distribution.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap
|
||||
num (int): Number of requested colors
|
||||
|
||||
Returns:
|
||||
list: List of colors with length <num>
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.color
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = BasicPlot.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = [
|
||||
mkColor(tuple((cmap_colors[int(ii)] * 255).astype(int))) for ii in color_selection[:num]
|
||||
]
|
||||
return colors
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
from bec_widgets import ctrl_c
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--signals",
|
||||
help="specify recorded signals",
|
||||
nargs="+",
|
||||
default=["gauss_bpm", "bpm4i", "bpm5i", "bpm6i", "xert"],
|
||||
)
|
||||
value = parser.parse_args()
|
||||
print(f"Plotting signals for: {', '.join(value.signals)}")
|
||||
client = bec_dispatcher.client
|
||||
# client.start()
|
||||
app = QtWidgets.QApplication([])
|
||||
ctrl_c.setup(app)
|
||||
plot = BasicPlot(y_value_list=value.signals)
|
||||
bec_dispatcher.connect(plot)
|
||||
plot.show()
|
||||
# client.callbacks.register("scan_segment", plot, sync=False)
|
||||
app.exec_()
|
||||
@@ -1 +0,0 @@
|
||||
from .crosshair import Crosshair
|
||||
@@ -1,56 +0,0 @@
|
||||
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from PyQt5.QtGui import QIcon
|
||||
|
||||
from bec_widgets.scan2d_plot import BECScanPlot2D
|
||||
|
||||
|
||||
class BECScanPlot2DPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, formEditor):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
def createWidget(self, parent):
|
||||
return BECScanPlot2D(parent)
|
||||
|
||||
def name(self):
|
||||
return "BECScanPlot2D"
|
||||
|
||||
def group(self):
|
||||
return "BEC widgets"
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def toolTip(self):
|
||||
return "BEC plot for 2D scans"
|
||||
|
||||
def whatsThis(self):
|
||||
return "BEC plot for 2D scans"
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def domXml(self):
|
||||
return (
|
||||
'<widget class="BECScanPlot2D" name="BECScanPlot2D">\n'
|
||||
' <property name="toolTip" >\n'
|
||||
" <string>BEC plot for 2D scans</string>\n"
|
||||
" </property>\n"
|
||||
' <property name="whatsThis" >\n'
|
||||
" <string>BEC plot for 2D scans in Python using PyQt.</string>\n"
|
||||
" </property>\n"
|
||||
"</widget>\n"
|
||||
)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan2d_plot"
|
||||
@@ -1,56 +0,0 @@
|
||||
from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
|
||||
from PyQt5.QtGui import QIcon
|
||||
|
||||
from bec_widgets.scan_plot import BECScanPlot
|
||||
|
||||
|
||||
class BECScanPlotPlugin(QPyDesignerCustomWidgetPlugin):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, formEditor):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
def createWidget(self, parent):
|
||||
return BECScanPlot(parent)
|
||||
|
||||
def name(self):
|
||||
return "BECScanPlot"
|
||||
|
||||
def group(self):
|
||||
return "BEC widgets"
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def toolTip(self):
|
||||
return "BEC plot for scans"
|
||||
|
||||
def whatsThis(self):
|
||||
return "BEC plot for scans"
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def domXml(self):
|
||||
return (
|
||||
'<widget class="BECScanPlot" name="BECScanPlot">\n'
|
||||
' <property name="toolTip" >\n'
|
||||
" <string>BEC plot for scans</string>\n"
|
||||
" </property>\n"
|
||||
' <property name="whatsThis" >\n'
|
||||
" <string>BEC plot for scans in Python using PyQt.</string>\n"
|
||||
" </property>\n"
|
||||
"</widget>\n"
|
||||
)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan_plot"
|
||||
@@ -1,12 +0,0 @@
|
||||
Add/modify the path in the following variable to make the plugin avaiable in Qt Designer:
|
||||
```
|
||||
$ export PYQTDESIGNERPATH=/<path to repo>/bec_widgets/qtdesigner_plugins
|
||||
```
|
||||
|
||||
It can be done when activating a conda environment (run with the corresponding env already activated):
|
||||
```
|
||||
$ conda env config vars set PYQTDESIGNERPATH=/<path to repo>/bec_widgets/qtdesigner_plugins
|
||||
```
|
||||
|
||||
All the available conda-forge `pyqt >=5.15` packages don't seem to support loading Qt Designer
|
||||
python plugins at the time of writing. Use `pyqt =5.12` to solve the issue for now.
|
||||
@@ -1,140 +0,0 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.core.logger import bec_logger
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
pg.setConfigOptions(background="w", foreground="k", antialias=True)
|
||||
|
||||
|
||||
class BECScanPlot2D(pg.GraphicsView):
|
||||
def __init__(self, parent=None, background="default"):
|
||||
super().__init__(parent, background)
|
||||
bec_dispatcher.connect(self)
|
||||
|
||||
self._x_channel = ""
|
||||
self._y_channel = ""
|
||||
self._z_channel = ""
|
||||
|
||||
self._xpos = []
|
||||
self._ypos = []
|
||||
|
||||
self._x_ind = None
|
||||
self._y_ind = None
|
||||
|
||||
self.plot_item = pg.PlotItem()
|
||||
self.setCentralItem(self.plot_item)
|
||||
self.plot_item.setAspectLocked(True)
|
||||
|
||||
self.imageItem = pg.ImageItem()
|
||||
self.plot_item.addItem(self.imageItem)
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_new_scan(self, _scan_segment, metadata):
|
||||
# TODO: Do we reset in case of a scan type change?
|
||||
self.imageItem.clear()
|
||||
|
||||
# TODO: better to check the number of coordinates in metadata["positions"]?
|
||||
if metadata["scan_name"] != "grid_scan":
|
||||
return
|
||||
|
||||
positions = [sorted(set(pos)) for pos in zip(*metadata["positions"])]
|
||||
|
||||
motors = metadata["scan_motors"]
|
||||
if self.x_channel and self.y_channel:
|
||||
self._x_ind = motors.index(self.x_channel) if self.x_channel in motors else None
|
||||
self._y_ind = motors.index(self.y_channel) if self.y_channel in motors else None
|
||||
elif not self.x_channel and not self.y_channel:
|
||||
# Plot the first and second motors along x and y axes respectively
|
||||
self._x_ind = 0
|
||||
self._y_ind = 1
|
||||
else:
|
||||
logger.warning(
|
||||
f"X and Y channels should be either both empty or both set in {self.objectName()}"
|
||||
)
|
||||
|
||||
if self._x_ind is None or self._y_ind is None:
|
||||
return
|
||||
|
||||
xpos = positions[self._x_ind]
|
||||
ypos = positions[self._y_ind]
|
||||
|
||||
self._xpos = xpos
|
||||
self._ypos = ypos
|
||||
|
||||
self.imageItem.setImage(np.zeros(shape=(len(xpos), len(ypos))))
|
||||
|
||||
w = max(xpos) - min(xpos)
|
||||
h = max(ypos) - min(ypos)
|
||||
w_pix = w / (len(xpos) - 1)
|
||||
h_pix = h / (len(ypos) - 1)
|
||||
self.imageItem.setRect(min(xpos) - w_pix / 2, min(ypos) - h_pix / 2, w + w_pix, h + h_pix)
|
||||
|
||||
self.plot_item.setLabel("bottom", motors[self._x_ind])
|
||||
self.plot_item.setLabel("left", motors[self._y_ind])
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, scan_segment, metadata):
|
||||
if not self.z_channel or metadata["scan_name"] != "grid_scan":
|
||||
return
|
||||
|
||||
if self._x_ind is None or self._y_ind is None:
|
||||
return
|
||||
|
||||
point_coord = metadata["positions"][scan_segment["point_id"]]
|
||||
|
||||
x_coord_ind = self._xpos.index(point_coord[self._x_ind])
|
||||
y_coord_ind = self._ypos.index(point_coord[self._y_ind])
|
||||
|
||||
data = scan_segment["data"]
|
||||
z_new = data[self.z_channel][self.z_channel]["value"]
|
||||
|
||||
image = self.imageItem.image
|
||||
image[x_coord_ind, y_coord_ind] = z_new
|
||||
self.imageItem.setImage()
|
||||
|
||||
@pyqtProperty(str)
|
||||
def x_channel(self):
|
||||
return self._x_channel
|
||||
|
||||
@x_channel.setter
|
||||
def x_channel(self, new_val):
|
||||
self._x_channel = new_val
|
||||
self.plot_item.setLabel("bottom", new_val)
|
||||
|
||||
@pyqtProperty(str)
|
||||
def y_channel(self):
|
||||
return self._y_channel
|
||||
|
||||
@y_channel.setter
|
||||
def y_channel(self, new_val):
|
||||
self._y_channel = new_val
|
||||
self.plot_item.setLabel("left", new_val)
|
||||
|
||||
@pyqtProperty(str)
|
||||
def z_channel(self):
|
||||
return self._z_channel
|
||||
|
||||
@z_channel.setter
|
||||
def z_channel(self, new_val):
|
||||
self._z_channel = new_val
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
plot = BECScanPlot2D()
|
||||
# If x_channel and y_channel are both omitted, they will be inferred from each running grid scan
|
||||
plot.z_channel = "bpm3y"
|
||||
|
||||
plot.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,137 +0,0 @@
|
||||
import itertools
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.core.logger import bec_logger
|
||||
from PyQt5.QtCore import pyqtProperty, pyqtSlot
|
||||
|
||||
from bec_widgets.bec_dispatcher import bec_dispatcher
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
pg.setConfigOptions(background="w", foreground="k", antialias=True)
|
||||
COLORS = ["#fd7f6f", "#7eb0d5", "#b2e061", "#bd7ebe", "#ffb55a"]
|
||||
|
||||
|
||||
class BECScanPlot(pg.GraphicsView):
|
||||
def __init__(self, parent=None, background="default"):
|
||||
super().__init__(parent, background)
|
||||
bec_dispatcher.connect(self)
|
||||
|
||||
self.view = pg.PlotItem()
|
||||
self.setCentralItem(self.view)
|
||||
|
||||
self._x_channel = ""
|
||||
self._y_channel_list = []
|
||||
|
||||
self.scan_curves = {}
|
||||
self.dap_curves = {}
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_new_scan(self, _scan_segment, _metadata):
|
||||
for plot_curve in {**self.scan_curves, **self.dap_curves}.values():
|
||||
plot_curve.setData(x=[], y=[])
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def on_scan_segment(self, scan_segment, _metadata):
|
||||
if not self.x_channel:
|
||||
return
|
||||
|
||||
data = scan_segment["data"]
|
||||
|
||||
if self.x_channel not in data:
|
||||
logger.warning(f"Unknown channel `{self.x_channel}` for X data in {self.objectName()}")
|
||||
return
|
||||
|
||||
x_new = data[self.x_channel][self.x_channel]["value"]
|
||||
for chan, plot_curve in self.scan_curves.items():
|
||||
if not chan:
|
||||
continue
|
||||
|
||||
if chan not in data:
|
||||
logger.warning(f"Unknown channel `{chan}` for Y data in {self.objectName()}")
|
||||
continue
|
||||
|
||||
y_new = data[chan][chan]["value"]
|
||||
x, y = plot_curve.getData() # TODO: is it a good approach?
|
||||
if x is None:
|
||||
x = []
|
||||
if y is None:
|
||||
y = []
|
||||
|
||||
plot_curve.setData(x=[*x, x_new], y=[*y, y_new])
|
||||
|
||||
@pyqtSlot(dict, dict)
|
||||
def redraw_dap(self, data, _metadata):
|
||||
for chan, plot_curve in self.dap_curves.items():
|
||||
if not chan:
|
||||
continue
|
||||
|
||||
if chan not in data:
|
||||
logger.warning(f"Unknown channel `{chan}` for DAP data in {self.objectName()}")
|
||||
continue
|
||||
|
||||
x_new = data[chan]["x"]
|
||||
y_new = data[chan]["y"]
|
||||
|
||||
plot_curve.setData(x=x_new, y=y_new)
|
||||
|
||||
@pyqtProperty("QStringList")
|
||||
def y_channel_list(self):
|
||||
return self._y_channel_list
|
||||
|
||||
@y_channel_list.setter
|
||||
def y_channel_list(self, new_list):
|
||||
# TODO: do we want to care about dap/not dap here?
|
||||
chan_removed = [chan for chan in self._y_channel_list if chan not in new_list]
|
||||
if chan_removed and chan_removed[0].startswith("dap."):
|
||||
chan_removed = chan_removed[0].partition("dap.")[-1]
|
||||
bec_dispatcher.disconnect_dap_slot(self.redraw_dap, chan_removed)
|
||||
|
||||
self._y_channel_list = new_list
|
||||
|
||||
# Prepare plot for a potentially different list of y channels
|
||||
self.view.clear()
|
||||
|
||||
self.view.addLegend()
|
||||
colors = itertools.cycle(COLORS)
|
||||
|
||||
for y_chan in new_list:
|
||||
if y_chan.startswith("dap."):
|
||||
y_chan = y_chan.partition("dap.")[-1]
|
||||
curves = self.dap_curves
|
||||
bec_dispatcher.connect_dap_slot(self.redraw_dap, y_chan)
|
||||
else:
|
||||
curves = self.scan_curves
|
||||
|
||||
curves[y_chan] = self.view.plot(
|
||||
x=[], y=[], pen=pg.mkPen(color=next(colors), width=2), name=y_chan
|
||||
)
|
||||
|
||||
if len(new_list) == 1:
|
||||
self.view.setLabel("left", new_list[0])
|
||||
|
||||
@pyqtProperty(str)
|
||||
def x_channel(self):
|
||||
return self._x_channel
|
||||
|
||||
@x_channel.setter
|
||||
def x_channel(self, new_val):
|
||||
self._x_channel = new_val
|
||||
self.view.setLabel("bottom", new_val)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
plot = BECScanPlot()
|
||||
plot.x_channel = "samx"
|
||||
plot.y_channel_list = ["bpm3y", "bpm6y"]
|
||||
|
||||
plot.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
13
bec_widgets/utils/__init__.py
Normal file
13
bec_widgets/utils/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from qtpy.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from .bec_connector import BECConnector, ConnectionConfig
|
||||
from .bec_dispatcher import BECDispatcher
|
||||
from .bec_table import BECTable
|
||||
from .colors import Colors
|
||||
from .container_utils import WidgetContainerUtils
|
||||
from .crosshair import Crosshair
|
||||
from .entry_validator import EntryValidator
|
||||
from .layout_manager import GridLayoutManager
|
||||
from .rpc_decorator import register_rpc_methods, rpc_public
|
||||
from .ui_loader import UILoader
|
||||
from .validator_delegate import DoubleValidationDelegate
|
||||
295
bec_widgets/utils/bec_connector.py
Normal file
295
bec_widgets/utils/bec_connector.py
Normal file
@@ -0,0 +1,295 @@
|
||||
# pylint: disable = no-name-in-module,missing-module-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
|
||||
from bec_widgets.cli.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
|
||||
|
||||
|
||||
class ConnectionConfig(BaseModel):
|
||||
"""Configuration for BECConnector mixin class"""
|
||||
|
||||
widget_class: str = Field(default="NonSpecifiedWidget", description="The class of the widget.")
|
||||
gui_id: Optional[str] = Field(
|
||||
default=None, validate_default=True, description="The GUI ID of the widget."
|
||||
)
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
|
||||
@field_validator("gui_id")
|
||||
@classmethod
|
||||
def generate_gui_id(cls, v, values):
|
||||
"""Generate a GUI ID if none is provided."""
|
||||
if v is None:
|
||||
widget_class = values.data["widget_class"]
|
||||
v = f"{widget_class}_{str(time.time())}"
|
||||
return v
|
||||
return v
|
||||
|
||||
|
||||
class WorkerSignals(QObject):
|
||||
progress = Signal(dict)
|
||||
completed = Signal()
|
||||
|
||||
|
||||
class Worker(QRunnable):
|
||||
"""
|
||||
Worker class to run a function in a separate thread.
|
||||
"""
|
||||
|
||||
def __init__(self, func, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.signals = WorkerSignals()
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Run the specified function in the thread.
|
||||
"""
|
||||
self.func(*self.args, **self.kwargs)
|
||||
self.signals.completed.emit()
|
||||
|
||||
|
||||
class BECConnector(BECWidget):
|
||||
"""Connection mixin class for all BEC widgets, to handle BEC client and device manager"""
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc"]
|
||||
|
||||
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
|
||||
if config:
|
||||
self.config = config
|
||||
self.config.widget_class = self.__class__.__name__
|
||||
else:
|
||||
print(
|
||||
f"No initial config found for {self.__class__.__name__}.\n"
|
||||
f"Initializing with default config."
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
if gui_id:
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id = gui_id
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
# register widget to rpc register
|
||||
self.rpc_register = RPCRegister()
|
||||
self.rpc_register.add_rpc(self)
|
||||
|
||||
self._thread_pool = QThreadPool.globalInstance()
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
Submit a task to run in a separate thread. The task will run the specified
|
||||
function with the provided arguments and emit the completed signal when done.
|
||||
|
||||
Use this method if you want to wait for a task to complete without blocking the
|
||||
main thread.
|
||||
|
||||
Args:
|
||||
fn: Function to run in a separate thread.
|
||||
*args: Arguments for the function.
|
||||
on_complete: Slot to run when the task is complete.
|
||||
**kwargs: Keyword arguments for the function.
|
||||
|
||||
Returns:
|
||||
worker: The worker object that will run the task.
|
||||
|
||||
Examples:
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> self.submit_task(my_function, 1, 2)
|
||||
|
||||
>>> def my_function(a, b):
|
||||
>>> print(a + b)
|
||||
>>> def on_complete():
|
||||
>>> print("Task complete")
|
||||
>>> self.submit_task(my_function, 1, 2, on_complete=on_complete)
|
||||
|
||||
"""
|
||||
worker = Worker(fn, *args, **kwargs)
|
||||
if on_complete:
|
||||
worker.signals.completed.connect(on_complete)
|
||||
self._thread_pool.start(worker)
|
||||
return worker
|
||||
|
||||
def _get_all_rpc(self) -> dict:
|
||||
"""Get all registered RPC objects."""
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
return dict(all_connections)
|
||||
|
||||
@property
|
||||
def _rpc_id(self) -> str:
|
||||
"""Get the RPC ID of the widget."""
|
||||
return self.gui_id
|
||||
|
||||
@_rpc_id.setter
|
||||
def _rpc_id(self, rpc_id: str) -> None:
|
||||
"""Set the RPC ID of the widget."""
|
||||
self.gui_id = rpc_id
|
||||
|
||||
@property
|
||||
def _config_dict(self) -> dict:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
return self.config.model_dump()
|
||||
|
||||
@_config_dict.setter
|
||||
def _config_dict(self, config: BaseModel) -> None:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the widget.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
|
||||
"""
|
||||
Apply the configuration to the widget.
|
||||
|
||||
Args:
|
||||
config(dict): Configuration settings.
|
||||
generate_new_id(bool): If True, generate a new GUI ID for the widget.
|
||||
"""
|
||||
self.config = ConnectionConfig(**config)
|
||||
if generate_new_id is True:
|
||||
gui_id = str(uuid.uuid4())
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.set_gui_id(gui_id)
|
||||
self.rpc_register.add_rpc(self)
|
||||
else:
|
||||
self.gui_id = self.config.gui_id
|
||||
|
||||
def load_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Load the configuration of the widget from YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to load the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
config = load_yaml_gui(self)
|
||||
else:
|
||||
config = load_yaml(path)
|
||||
|
||||
if config is not None:
|
||||
if config.get("widget_class") != self.__class__.__name__:
|
||||
raise ValueError(
|
||||
f"Configuration file is not for {self.__class__.__name__}. Got configuration for {config.get('widget_class')}."
|
||||
)
|
||||
self.apply_config(config)
|
||||
|
||||
def save_config(self, path: str | None = None, gui: bool = False):
|
||||
"""
|
||||
Save the configuration of the widget to YAML.
|
||||
|
||||
Args:
|
||||
path(str): Path to save the configuration file for non-GUI dialog mode.
|
||||
gui(bool): If True, use the GUI dialog to save the configuration file.
|
||||
"""
|
||||
if gui is True:
|
||||
save_yaml_gui(self, self._config_dict)
|
||||
else:
|
||||
if path is None:
|
||||
path = os.getcwd()
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
|
||||
Args:
|
||||
gui_id(str): GUI ID
|
||||
"""
|
||||
self.config.gui_id = gui_id
|
||||
self.gui_id = gui_id
|
||||
|
||||
def get_obj_by_id(self, obj_id: str):
|
||||
if obj_id == self.gui_id:
|
||||
return self
|
||||
|
||||
def get_bec_shortcuts(self):
|
||||
"""Get BEC shortcuts for the widget."""
|
||||
self.dev = self.client.device_manager.devices
|
||||
self.scans = self.client.scans
|
||||
self.queue = self.client.queue
|
||||
self.scan_storage = self.queue.scan_storage
|
||||
self.dap = self.client.dap
|
||||
|
||||
def update_client(self, client) -> None:
|
||||
"""Update the client and device manager from BEC and create object for BEC shortcuts.
|
||||
|
||||
Args:
|
||||
client: BEC client
|
||||
"""
|
||||
self.client = client
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@pyqtSlot(ConnectionConfig) # TODO can be also dict
|
||||
def on_config_update(self, config: ConnectionConfig | dict) -> None:
|
||||
"""
|
||||
Update the configuration for the widget.
|
||||
|
||||
Args:
|
||||
config(ConnectionConfig): Configuration settings.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
config = ConnectionConfig(**config)
|
||||
# TODO add error handler
|
||||
|
||||
self.config = config
|
||||
|
||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||
"""
|
||||
Get the configuration of the widget.
|
||||
|
||||
Args:
|
||||
dict_output(bool): If True, return the configuration as a dictionary. If False, return the configuration as a pydantic model.
|
||||
|
||||
Returns:
|
||||
dict: The configuration of the plot widget.
|
||||
"""
|
||||
if dict_output:
|
||||
return self.config.model_dump()
|
||||
else:
|
||||
return self.config
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
all_connections = self.rpc_register.list_all_connections()
|
||||
if len(all_connections) == 0:
|
||||
print("No more connections. Shutting down GUI BEC client.")
|
||||
self.bec_dispatcher.disconnect_all()
|
||||
self.client.shutdown()
|
||||
|
||||
# def closeEvent(self, event):
|
||||
# self.cleanup()
|
||||
# super().closeEvent(event)
|
||||
138
bec_widgets/utils/bec_designer.py
Normal file
138
bec_widgets/utils/bec_designer.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import site
|
||||
import sys
|
||||
import sysconfig
|
||||
from pathlib import Path
|
||||
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
|
||||
|
||||
def list_editable_packages() -> set[str]:
|
||||
"""
|
||||
List all editable packages in the environment.
|
||||
|
||||
Returns:
|
||||
set: A set of paths to editable packages.
|
||||
"""
|
||||
|
||||
editable_packages = set()
|
||||
|
||||
# Get site-packages directories
|
||||
site_packages = site.getsitepackages()
|
||||
if hasattr(site, "getusersitepackages"):
|
||||
site_packages.append(site.getusersitepackages())
|
||||
|
||||
for dist in importlib.metadata.distributions():
|
||||
location = dist.locate_file("").resolve()
|
||||
is_editable = all(not str(location).startswith(site_pkg) for site_pkg in site_packages)
|
||||
|
||||
if is_editable:
|
||||
editable_packages.add(str(location))
|
||||
|
||||
for packages in site_packages:
|
||||
# all dist-info directories in site-packages that contain a direct_url.json file
|
||||
dist_info_dirs = Path(packages).rglob("*.dist-info")
|
||||
for dist_info_dir in dist_info_dirs:
|
||||
direct_url = dist_info_dir / "direct_url.json"
|
||||
if not direct_url.exists():
|
||||
continue
|
||||
# load the json file and get the path to the package
|
||||
with open(direct_url, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
path = data.get("url", "")
|
||||
if path.startswith("file://"):
|
||||
path = path[7:]
|
||||
editable_packages.add(path)
|
||||
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
|
||||
init_virtual_env()
|
||||
|
||||
major_version = sys.version_info[0]
|
||||
minor_version = sys.version_info[1]
|
||||
os.environ["PY_MAJOR_VERSION"] = str(major_version)
|
||||
os.environ["PY_MINOR_VERSION"] = str(minor_version)
|
||||
|
||||
if sys.platform == "win32":
|
||||
if is_virtual_env():
|
||||
_extend_path_var("PATH", os.fspath(Path(sys._base_executable).parent), True)
|
||||
else:
|
||||
if sys.platform == "linux":
|
||||
suffix = f"{sys.abiflags}.so"
|
||||
env_var = "LD_PRELOAD"
|
||||
elif sys.platform == "darwin":
|
||||
suffix = ".dylib"
|
||||
env_var = "DYLD_INSERT_LIBRARIES"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {sys.platform}")
|
||||
version = f"{major_version}.{minor_version}"
|
||||
library_name = f"libpython{version}{suffix}"
|
||||
lib_path = str(Path(sysconfig.get_config_var("LIBDIR")) / library_name)
|
||||
os.environ[env_var] = lib_path
|
||||
|
||||
if is_pyenv_python() or is_virtual_env():
|
||||
# append all editable packages to the PYTHONPATH
|
||||
editable_packages = list_editable_packages()
|
||||
for pckg in editable_packages:
|
||||
_extend_path_var("PYTHONPATH", pckg, True)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path):
|
||||
"""
|
||||
Recursively find all directories containing a .pyproject file.
|
||||
"""
|
||||
plugin_paths = []
|
||||
for path in base_path.rglob("*.pyproject"):
|
||||
plugin_paths.append(str(path.parent))
|
||||
return plugin_paths
|
||||
|
||||
|
||||
def set_plugin_environment_variable(plugin_paths):
|
||||
"""
|
||||
Set the PYSIDE_DESIGNER_PLUGINS environment variable with the given plugin paths.
|
||||
"""
|
||||
current_paths = os.environ.get("PYSIDE_DESIGNER_PLUGINS", "")
|
||||
if current_paths:
|
||||
current_paths = current_paths.split(os.pathsep)
|
||||
else:
|
||||
current_paths = []
|
||||
|
||||
current_paths.extend(plugin_paths)
|
||||
os.environ["PYSIDE_DESIGNER_PLUGINS"] = os.pathsep.join(current_paths)
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def main(): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Exiting...")
|
||||
return
|
||||
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
|
||||
plugin_paths = find_plugin_paths(base_dir)
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
176
bec_widgets/utils/bec_dispatcher.py
Normal file
176
bec_widgets/utils/bec_dispatcher.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.redis_connector import MessageObject, RedisConnector
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import PYQT6, PYSIDE6, QCoreApplication, QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
cb_signal = pyqtSignal(dict, dict)
|
||||
|
||||
def __init__(self, cb):
|
||||
super().__init__()
|
||||
|
||||
self.cb = cb
|
||||
self.cb_signal.connect(self.cb)
|
||||
|
||||
def __hash__(self):
|
||||
# make 2 differents QtThreadSafeCallback to look
|
||||
# identical when used as dictionary keys, if the
|
||||
# callback is the same
|
||||
return id(self.cb)
|
||||
|
||||
def __call__(self, msg_content, metadata):
|
||||
self.cb_signal.emit(msg_content, metadata)
|
||||
|
||||
|
||||
class QtRedisConnector(RedisConnector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _execute_callback(self, cb, msg, kwargs):
|
||||
if not isinstance(cb, QtThreadSafeCallback):
|
||||
return super()._execute_callback(cb, msg, kwargs)
|
||||
# if msg.msg_type == "bundle_message":
|
||||
# # big warning: how to handle bundle messages?
|
||||
# # message with messages inside ; which slot to call?
|
||||
# # bundle_msg = msg
|
||||
# # for msg in bundle_msg:
|
||||
# # ...
|
||||
# # for now, only consider the 1st message
|
||||
# msg = msg[0]
|
||||
# raise RuntimeError(f"
|
||||
if isinstance(msg, MessageObject):
|
||||
if isinstance(msg.value, list):
|
||||
msg = msg.value[0]
|
||||
else:
|
||||
msg = msg.value
|
||||
|
||||
# we can notice kwargs are lost when passed to Qt slot
|
||||
metadata = msg.metadata
|
||||
cb(msg.content, metadata)
|
||||
else:
|
||||
# from stream
|
||||
msg = msg["data"]
|
||||
cb(msg.content, msg.metadata)
|
||||
|
||||
|
||||
class BECClientWithoutLoggerInit(BECClient):
|
||||
def _initialize_logger(self):
|
||||
return
|
||||
|
||||
|
||||
class BECDispatcher:
|
||||
"""Utility class to keep track of slots connected to a particular redis connector"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
qapp = None
|
||||
|
||||
def __new__(cls, client=None, config: str = None, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(BECDispatcher, cls).__new__(cls)
|
||||
cls._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, client=None, config: str | ServiceConfig = None):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not QCoreApplication.instance():
|
||||
BECDispatcher.qapp = QCoreApplication([])
|
||||
|
||||
self._slots = collections.defaultdict(set)
|
||||
self.client = client
|
||||
|
||||
if self.client is None:
|
||||
if config is not None:
|
||||
if not isinstance(config, ServiceConfig):
|
||||
# config is supposed to be a path
|
||||
config = ServiceConfig(config)
|
||||
self.client = BECClientWithoutLoggerInit(
|
||||
config=config, connector_cls=QtRedisConnector
|
||||
) # , forced=True)
|
||||
else:
|
||||
self.client = BECClientWithoutLoggerInit(
|
||||
connector_cls=QtRedisConnector
|
||||
) # , forced=True)
|
||||
else:
|
||||
if self.client.started:
|
||||
# have to reinitialize client to use proper connector
|
||||
self.client.shutdown()
|
||||
self.client._BECClient__init_params["connector_cls"] = QtRedisConnector
|
||||
|
||||
try:
|
||||
self.client.start()
|
||||
except redis.exceptions.ConnectionError:
|
||||
print("Could not connect to Redis, skipping start of BECClient.")
|
||||
|
||||
self._initialized = True
|
||||
|
||||
@classmethod
|
||||
def reset_singleton(cls):
|
||||
cls._instance = None
|
||||
cls._initialized = False
|
||||
|
||||
if not cls.qapp:
|
||||
return
|
||||
|
||||
# shutdown QCoreApp if it exists
|
||||
if PYQT6:
|
||||
cls.qapp.exit()
|
||||
elif PYSIDE6:
|
||||
cls.qapp.shutdown()
|
||||
cls.qapp = None
|
||||
|
||||
def connect_slot(
|
||||
self, slot: Callable, topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]]
|
||||
) -> None:
|
||||
"""Connect widget's pyqt slot, so that it is called on new pub/sub topic message.
|
||||
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
"""
|
||||
slot = QtThreadSafeCallback(slot)
|
||||
self.client.connector.register(topics, cb=slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].update(set(topics_str))
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
# find the right slot to disconnect from ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
# but the slot we receive here is the original callable
|
||||
for connected_slot in self._slots:
|
||||
if connected_slot.cb == slot:
|
||||
break
|
||||
else:
|
||||
return
|
||||
self.client.connector.unregister(topics, cb=connected_slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].difference_update(set(topics_str))
|
||||
if not self._slots[slot]:
|
||||
del self._slots[slot]
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
for slot in list(self._slots.keys()):
|
||||
slot_topics = self._slots[slot]
|
||||
slot_topics.difference_update(set(topics_str))
|
||||
if not slot_topics:
|
||||
del self._slots[slot]
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
self.disconnect_topics(self.client.connector._topics_cb)
|
||||
21
bec_widgets/utils/bec_table.py
Normal file
21
bec_widgets/utils/bec_table.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QTableWidget
|
||||
|
||||
|
||||
class BECTable(QTableWidget):
|
||||
"""Table widget with custom keyPressEvent to delete rows with backspace or delete key"""
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
"""
|
||||
Delete selected rows with backspace or delete key
|
||||
|
||||
Args:
|
||||
event: keyPressEvent
|
||||
"""
|
||||
if event.key() in (Qt.Key_Backspace, Qt.Key_Delete):
|
||||
selected_ranges = self.selectedRanges()
|
||||
for selected_range in selected_ranges:
|
||||
for row in range(selected_range.topRow(), selected_range.bottomRow() + 1):
|
||||
self.removeRow(row)
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
2
bec_widgets/utils/bec_widget.py
Normal file
2
bec_widgets/utils/bec_widget.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class BECWidget:
|
||||
"""Base class for all BEC widgets."""
|
||||
314
bec_widgets/utils/colors.py
Normal file
314
bec_widgets/utils/colors.py
Normal file
@@ -0,0 +1,314 @@
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
|
||||
class Colors:
|
||||
|
||||
@staticmethod
|
||||
def golden_ratio(num: int) -> list:
|
||||
"""Calculate the golden ratio for a given number of angles.
|
||||
|
||||
Args:
|
||||
num (int): Number of angles
|
||||
|
||||
Returns:
|
||||
list: List of angles calculated using the golden ratio.
|
||||
"""
|
||||
phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2)
|
||||
angles = []
|
||||
for ii in range(num):
|
||||
x = np.cos(ii * phi)
|
||||
y = np.sin(ii * phi)
|
||||
angle = np.arctan2(y, x)
|
||||
angles.append(angle)
|
||||
return angles
|
||||
|
||||
@staticmethod
|
||||
def golden_angle_color(
|
||||
colormap: str, num: int, format: Literal["QColor", "HEX", "RGB"] = "QColor"
|
||||
) -> list:
|
||||
"""
|
||||
Extract num colors from the specified colormap following golden angle distribution and return them in the specified format.
|
||||
|
||||
Args:
|
||||
colormap (str): Name of the colormap.
|
||||
num (int): Number of requested colors.
|
||||
format (Literal["QColor","HEX","RGB"]): The format of the returned colors ('RGB', 'HEX', 'QColor').
|
||||
|
||||
Returns:
|
||||
list: List of colors in the specified format.
|
||||
|
||||
Raises:
|
||||
ValueError: If the number of requested colors is greater than the number of colors in the colormap.
|
||||
"""
|
||||
cmap = pg.colormap.get(colormap)
|
||||
cmap_colors = cmap.getColors(mode="float")
|
||||
if num > len(cmap_colors):
|
||||
raise ValueError(
|
||||
f"Number of colors requested ({num}) is greater than the number of colors in the colormap ({len(cmap_colors)})"
|
||||
)
|
||||
angles = Colors.golden_ratio(len(cmap_colors))
|
||||
color_selection = np.round(np.interp(angles, (-np.pi, np.pi), (0, len(cmap_colors))))
|
||||
colors = []
|
||||
for ii in color_selection[:num]:
|
||||
color = cmap_colors[int(ii)]
|
||||
if format.upper() == "HEX":
|
||||
colors.append(QColor.fromRgbF(*color).name())
|
||||
elif format.upper() == "RGB":
|
||||
colors.append(tuple((np.array(color) * 255).astype(int)))
|
||||
elif format.upper() == "QCOLOR":
|
||||
colors.append(QColor.fromRgbF(*color))
|
||||
else:
|
||||
raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.")
|
||||
return colors
|
||||
|
||||
@staticmethod
|
||||
def hex_to_rgba(hex_color: str, alpha=255) -> tuple:
|
||||
"""
|
||||
Convert HEX color to RGBA.
|
||||
|
||||
Args:
|
||||
hex_color(str): HEX color string.
|
||||
alpha(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
tuple: RGBA color tuple (r, g, b, a).
|
||||
"""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) == 6:
|
||||
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
elif len(hex_color) == 8:
|
||||
r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6))
|
||||
return (r, g, b, a)
|
||||
else:
|
||||
raise ValueError("HEX color must be 6 or 8 characters long.")
|
||||
return (r, g, b, alpha)
|
||||
|
||||
@staticmethod
|
||||
def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str:
|
||||
"""
|
||||
Convert RGBA color to HEX.
|
||||
|
||||
Args:
|
||||
r(int): Red value (0-255).
|
||||
g(int): Green value (0-255).
|
||||
b(int): Blue value (0-255).
|
||||
a(int): Alpha value (0-255). Default is 255 (opaque).
|
||||
|
||||
Returns:
|
||||
hec_color(str): HEX color string.
|
||||
"""
|
||||
return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a)
|
||||
|
||||
@staticmethod
|
||||
def validate_color(color: tuple | str) -> tuple | str:
|
||||
"""
|
||||
Validate the color input if it is HEX or RGBA compatible. Can be used in any pydantic model as a field validator.
|
||||
|
||||
Args:
|
||||
color(tuple|str): The color to be validated. Can be a tuple of RGBA values or a HEX string.
|
||||
|
||||
Returns:
|
||||
tuple|str: The validated color.
|
||||
"""
|
||||
CSS_COLOR_NAMES = {
|
||||
"aliceblue",
|
||||
"antiquewhite",
|
||||
"aqua",
|
||||
"aquamarine",
|
||||
"azure",
|
||||
"beige",
|
||||
"bisque",
|
||||
"black",
|
||||
"blanchedalmond",
|
||||
"blue",
|
||||
"blueviolet",
|
||||
"brown",
|
||||
"burlywood",
|
||||
"cadetblue",
|
||||
"chartreuse",
|
||||
"chocolate",
|
||||
"coral",
|
||||
"cornflowerblue",
|
||||
"cornsilk",
|
||||
"crimson",
|
||||
"cyan",
|
||||
"darkblue",
|
||||
"darkcyan",
|
||||
"darkgoldenrod",
|
||||
"darkgray",
|
||||
"darkgreen",
|
||||
"darkgrey",
|
||||
"darkkhaki",
|
||||
"darkmagenta",
|
||||
"darkolivegreen",
|
||||
"darkorange",
|
||||
"darkorchid",
|
||||
"darkred",
|
||||
"darksalmon",
|
||||
"darkseagreen",
|
||||
"darkslateblue",
|
||||
"darkslategray",
|
||||
"darkslategrey",
|
||||
"darkturquoise",
|
||||
"darkviolet",
|
||||
"deeppink",
|
||||
"deepskyblue",
|
||||
"dimgray",
|
||||
"dimgrey",
|
||||
"dodgerblue",
|
||||
"firebrick",
|
||||
"floralwhite",
|
||||
"forestgreen",
|
||||
"fuchsia",
|
||||
"gainsboro",
|
||||
"ghostwhite",
|
||||
"gold",
|
||||
"goldenrod",
|
||||
"gray",
|
||||
"green",
|
||||
"greenyellow",
|
||||
"grey",
|
||||
"honeydew",
|
||||
"hotpink",
|
||||
"indianred",
|
||||
"indigo",
|
||||
"ivory",
|
||||
"khaki",
|
||||
"lavender",
|
||||
"lavenderblush",
|
||||
"lawngreen",
|
||||
"lemonchiffon",
|
||||
"lightblue",
|
||||
"lightcoral",
|
||||
"lightcyan",
|
||||
"lightgoldenrodyellow",
|
||||
"lightgray",
|
||||
"lightgreen",
|
||||
"lightgrey",
|
||||
"lightpink",
|
||||
"lightsalmon",
|
||||
"lightseagreen",
|
||||
"lightskyblue",
|
||||
"lightslategray",
|
||||
"lightslategrey",
|
||||
"lightsteelblue",
|
||||
"lightyellow",
|
||||
"lime",
|
||||
"limegreen",
|
||||
"linen",
|
||||
"magenta",
|
||||
"maroon",
|
||||
"mediumaquamarine",
|
||||
"mediumblue",
|
||||
"mediumorchid",
|
||||
"mediumpurple",
|
||||
"mediumseagreen",
|
||||
"mediumslateblue",
|
||||
"mediumspringgreen",
|
||||
"mediumturquoise",
|
||||
"mediumvioletred",
|
||||
"midnightblue",
|
||||
"mintcream",
|
||||
"mistyrose",
|
||||
"moccasin",
|
||||
"navajowhite",
|
||||
"navy",
|
||||
"oldlace",
|
||||
"olive",
|
||||
"olivedrab",
|
||||
"orange",
|
||||
"orangered",
|
||||
"orchid",
|
||||
"palegoldenrod",
|
||||
"palegreen",
|
||||
"paleturquoise",
|
||||
"palevioletred",
|
||||
"papayawhip",
|
||||
"peachpuff",
|
||||
"peru",
|
||||
"pink",
|
||||
"plum",
|
||||
"powderblue",
|
||||
"purple",
|
||||
"red",
|
||||
"rosybrown",
|
||||
"royalblue",
|
||||
"saddlebrown",
|
||||
"salmon",
|
||||
"sandybrown",
|
||||
"seagreen",
|
||||
"seashell",
|
||||
"sienna",
|
||||
"silver",
|
||||
"skyblue",
|
||||
"slateblue",
|
||||
"slategray",
|
||||
"slategrey",
|
||||
"snow",
|
||||
"springgreen",
|
||||
"steelblue",
|
||||
"tan",
|
||||
"teal",
|
||||
"thistle",
|
||||
"tomato",
|
||||
"turquoise",
|
||||
"violet",
|
||||
"wheat",
|
||||
"white",
|
||||
"whitesmoke",
|
||||
"yellow",
|
||||
"yellowgreen",
|
||||
}
|
||||
if isinstance(color, str):
|
||||
hex_pattern = re.compile(r"^#(?:[0-9a-fA-F]{3}){1,2}$")
|
||||
if hex_pattern.match(color):
|
||||
return color
|
||||
elif color.lower() in CSS_COLOR_NAMES:
|
||||
return color
|
||||
else:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a valid HEX string or CSS Color.",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
elif isinstance(color, tuple):
|
||||
if len(color) != 4:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
"The color must be a tuple of 4 elements (R, G, B, A).",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
for value in color:
|
||||
if not 0 <= value <= 255:
|
||||
raise PydanticCustomError(
|
||||
"unsupported color",
|
||||
f"The color values must be between 0 and 255 in RGBA format (R,G,B,A)",
|
||||
{"wrong_value": color},
|
||||
)
|
||||
return color
|
||||
|
||||
@staticmethod
|
||||
def validate_color_map(color_map: str) -> str:
|
||||
"""
|
||||
Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance.
|
||||
|
||||
Args:
|
||||
color_map(str): The colormap to be validated.
|
||||
|
||||
Returns:
|
||||
str: The validated colormap.
|
||||
"""
|
||||
available_colormaps = pg.colormap.listMaps()
|
||||
if color_map not in available_colormaps:
|
||||
raise PydanticCustomError(
|
||||
"unsupported colormap",
|
||||
f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.",
|
||||
{"wrong_value": color_map},
|
||||
)
|
||||
return color_map
|
||||
48
bec_widgets/utils/container_utils.py
Normal file
48
bec_widgets/utils/container_utils.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import itertools
|
||||
from typing import Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
|
||||
class WidgetContainerUtils:
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
|
||||
"""
|
||||
Generate a unique widget ID.
|
||||
|
||||
Args:
|
||||
container(dict): The container of widgets.
|
||||
prefix(str): The prefix of the widget ID.
|
||||
|
||||
Returns:
|
||||
widget_id(str): The unique widget ID.
|
||||
"""
|
||||
existing_ids = set(container.keys())
|
||||
for i in itertools.count(1):
|
||||
widget_id = f"{prefix}_{i}"
|
||||
if widget_id not in existing_ids:
|
||||
return widget_id
|
||||
|
||||
@staticmethod
|
||||
def find_first_widget_by_class(
|
||||
container: dict, widget_class: Type[QWidget], can_fail: bool = True
|
||||
) -> QWidget | None:
|
||||
"""
|
||||
Find the first widget of a given class in the figure.
|
||||
|
||||
Args:
|
||||
container(dict): The container of widgets.
|
||||
widget_class(Type): The class of the widget to find.
|
||||
can_fail(bool): If True, the method will return None if no widget is found. If False, it will raise an error.
|
||||
|
||||
Returns:
|
||||
widget: The widget of the given class.
|
||||
"""
|
||||
for widget_id, widget in container.items():
|
||||
if isinstance(widget, widget_class):
|
||||
return widget
|
||||
if can_fail:
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f"No widget of class {widget_class} found.")
|
||||
@@ -1,19 +1,23 @@
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
|
||||
# from qtpy.QtCore import QObject, pyqtSignal
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
|
||||
class Crosshair(QObject):
|
||||
# Signal for 1D plot
|
||||
coordinatesChanged1D = pyqtSignal(float, list)
|
||||
coordinatesClicked1D = pyqtSignal(float, list)
|
||||
coordinatesChanged1D = pyqtSignal(tuple)
|
||||
coordinatesClicked1D = pyqtSignal(tuple)
|
||||
# Signal for 2D plot
|
||||
coordinatesChanged2D = pyqtSignal(float, float)
|
||||
coordinatesClicked2D = pyqtSignal(float, float)
|
||||
coordinatesChanged2D = pyqtSignal(tuple)
|
||||
coordinatesClicked2D = pyqtSignal(tuple)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = None, parent=None):
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
|
||||
"""
|
||||
Crosshair for 1D and 2D plots.
|
||||
|
||||
Args:
|
||||
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
|
||||
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
|
||||
@@ -63,9 +67,19 @@ class Crosshair(QObject):
|
||||
size=10, pen=pg.mkPen(None), brush=pg.mkBrush(color)
|
||||
)
|
||||
self.marker_moved_1d.append(marker_moved)
|
||||
self.marker_clicked_1d.append(marker_clicked)
|
||||
self.plot_item.addItem(marker_moved)
|
||||
self.plot_item.addItem(marker_clicked)
|
||||
# Create glowing effect markers for clicked events
|
||||
marker_clicked_list = []
|
||||
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
|
||||
marker_clicked = pg.ScatterPlotItem(
|
||||
size=size,
|
||||
pen=pg.mkPen(None),
|
||||
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
|
||||
)
|
||||
marker_clicked_list.append(marker_clicked)
|
||||
self.plot_item.addItem(marker_clicked)
|
||||
|
||||
self.marker_clicked_1d.append(marker_clicked_list)
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
self.marker_2d = pg.ROI(
|
||||
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
@@ -109,7 +123,8 @@ class Crosshair(QObject):
|
||||
if y_values_1d:
|
||||
if all(v is None for v in x_values_1d) or all(v is None for v in y_values_1d):
|
||||
return None, None
|
||||
return x, y_values_1d
|
||||
closest_x = min(x_values_1d, key=lambda xi: abs(xi - x)) # Snap x to closest data point
|
||||
return closest_x, y_values_1d
|
||||
|
||||
# Handle 2D plot
|
||||
if image_2d is not None:
|
||||
@@ -159,10 +174,11 @@ class Crosshair(QObject):
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
self.coordinatesChanged1D.emit(
|
||||
coordinate_to_emit = (
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
for i, y_val in enumerate(y_values):
|
||||
self.marker_moved_1d[i].setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
@@ -171,7 +187,8 @@ class Crosshair(QObject):
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
self.coordinatesChanged2D.emit(x, y_values)
|
||||
coordinate_to_emit = (x, y_values)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
@@ -194,19 +211,22 @@ class Crosshair(QObject):
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
if x is None or all(v is None for v in y_values):
|
||||
return
|
||||
self.coordinatesClicked1D.emit(
|
||||
coordinate_to_emit = (
|
||||
round(x, self.precision),
|
||||
[round(y_val, self.precision) for y_val in y_values],
|
||||
)
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
for i, y_val in enumerate(y_values):
|
||||
self.marker_clicked_1d[i].setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
[y_val if not self.is_log_y else np.log10(y_val)],
|
||||
)
|
||||
for marker in self.marker_clicked_1d[i]:
|
||||
marker.setData(
|
||||
[x if not self.is_log_x else np.log10(x)],
|
||||
[y_val if not self.is_log_y else np.log10(y_val)],
|
||||
)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
if x is None or y_values is None:
|
||||
return
|
||||
self.coordinatesClicked2D.emit(x, y_values)
|
||||
coordinate_to_emit = (x, y_values)
|
||||
self.coordinatesClicked2D.emit(coordinate_to_emit)
|
||||
self.marker_2d.setPos([x, y_values])
|
||||
|
||||
def check_log(self):
|
||||
42
bec_widgets/utils/entry_validator.py
Normal file
42
bec_widgets/utils/entry_validator.py
Normal file
@@ -0,0 +1,42 @@
|
||||
class EntryValidator:
|
||||
def __init__(self, devices):
|
||||
self.devices = devices
|
||||
|
||||
def validate_signal(self, name: str, entry: str = None) -> str:
|
||||
"""
|
||||
Validate a signal entry for a given device. If the entry is not provided, the first signal entry will be used from the device hints.
|
||||
|
||||
Args:
|
||||
name(str): Device name
|
||||
entry(str): Signal entry
|
||||
|
||||
Returns:
|
||||
str: Signal entry
|
||||
"""
|
||||
if name not in self.devices:
|
||||
raise ValueError(f"Device '{name}' not found in current BEC session")
|
||||
|
||||
device = self.devices[name]
|
||||
description = device.describe()
|
||||
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
|
||||
|
||||
return entry
|
||||
|
||||
def validate_monitor(self, monitor: str) -> str:
|
||||
"""
|
||||
Validate a monitor entry for a given device.
|
||||
|
||||
Args:
|
||||
monitor(str): Monitor entry
|
||||
|
||||
Returns:
|
||||
str: Monitor entry
|
||||
"""
|
||||
if monitor not in self.devices:
|
||||
raise ValueError(f"Device '{monitor}' not found in current BEC session")
|
||||
|
||||
return monitor
|
||||
148
bec_widgets/utils/generate_designer_plugin.py
Normal file
148
bec_widgets/utils/generate_designer_plugin.py
Normal file
@@ -0,0 +1,148 @@
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock"]
|
||||
|
||||
|
||||
class DesignerPluginInfo:
|
||||
def __init__(self, plugin_class):
|
||||
self.plugin_class = plugin_class
|
||||
self.plugin_name_pascal = plugin_class.__name__
|
||||
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
|
||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||
plugin_module = (
|
||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||
)
|
||||
self.plugin_import = f"from {plugin_module} import {self.plugin_name_pascal}Plugin"
|
||||
|
||||
# first sentence / line of the docstring is used as tooltip
|
||||
self.plugin_tooltip = (
|
||||
plugin_class.__doc__.split("\n")[0].strip().replace('"', "'")
|
||||
if plugin_class.__doc__
|
||||
else self.plugin_name_pascal
|
||||
)
|
||||
|
||||
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
|
||||
|
||||
@staticmethod
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
class DesignerPluginGenerator:
|
||||
def __init__(self, widget: type):
|
||||
self._excluded = False
|
||||
self.widget = widget
|
||||
self.info = DesignerPluginInfo(widget)
|
||||
if widget.__name__ in EXCLUDED_PLUGINS:
|
||||
|
||||
self._excluded = True
|
||||
return
|
||||
|
||||
self.templates = {}
|
||||
self.template_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "plugin_templates"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
if self._excluded:
|
||||
print(f"Plugin {self.widget.__name__} is excluded from generation.")
|
||||
return
|
||||
self._check_class_validity()
|
||||
self._load_templates()
|
||||
self._write_templates()
|
||||
|
||||
def _check_class_validity(self):
|
||||
|
||||
# Check if the widget is a QWidget subclass
|
||||
if not issubclass(self.widget, QObject):
|
||||
return
|
||||
|
||||
# Check if the widget class has parent as the first argument. This is a strict requirement of Qt!
|
||||
signature = list(inspect.signature(self.widget.__init__).parameters.values())
|
||||
if len(signature) == 1 or signature[1].name != "parent":
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must have parent as the first argument."
|
||||
)
|
||||
|
||||
base_cls = [val for val in self.widget.__bases__ if issubclass(val, QObject)]
|
||||
if not base_cls:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must inherit from a QObject subclass."
|
||||
)
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
cls_init_found = (
|
||||
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
|
||||
)
|
||||
super_init_found = (
|
||||
bool(
|
||||
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
|
||||
)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
|
||||
)
|
||||
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
|
||||
super_init_found = (
|
||||
bool(init_source.find("super().__init__(parent=parent") > 0)
|
||||
or bool(init_source.find("super().__init__(parent,") > 0)
|
||||
or bool(init_source.find("super().__init__(parent)") > 0)
|
||||
)
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
f"Widget class {self.widget.__name__} must call the super constructor with parent."
|
||||
)
|
||||
|
||||
def _write_templates(self):
|
||||
self._write_register()
|
||||
self._write_plugin()
|
||||
self._write_pyproject()
|
||||
|
||||
def _write_register(self):
|
||||
file_path = os.path.join(self.info.base_path, f"register_{self.info.plugin_name_snake}.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["register"].format(**self.info.__dict__))
|
||||
|
||||
def _write_plugin(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}_plugin.py")
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.templates["plugin"].format(**self.info.__dict__))
|
||||
|
||||
def _write_pyproject(self):
|
||||
file_path = os.path.join(self.info.base_path, f"{self.info.plugin_name_snake}.pyproject")
|
||||
out = {"files": [f"{self.info.plugin_class.__module__.split('.')[-1]}.py"]}
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(str(out))
|
||||
|
||||
def _load_templates(self):
|
||||
for file in os.listdir(self.template_path):
|
||||
if not file.endswith(".template"):
|
||||
continue
|
||||
with open(os.path.join(self.template_path, file), "r", encoding="utf-8") as f:
|
||||
self.templates[file.split(".")[0]] = f.read()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
|
||||
from bec_widgets.widgets.dock import BECDockArea
|
||||
|
||||
generator = DesignerPluginGenerator(BECDockArea)
|
||||
generator.run()
|
||||
121
bec_widgets/utils/layout_manager.py
Normal file
121
bec_widgets/utils/layout_manager.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QGridLayout, QWidget
|
||||
|
||||
|
||||
class GridLayoutManager:
|
||||
"""
|
||||
GridLayoutManager class is used to manage widgets in a QGridLayout and extend its functionality.
|
||||
|
||||
The GridLayoutManager class provides methods to add, move, and check the position of widgets in a QGridLayout.
|
||||
It also provides a method to get the positions of all widgets in the layout.
|
||||
|
||||
Args:
|
||||
layout(QGridLayout): The layout to manage.
|
||||
"""
|
||||
|
||||
def __init__(self, layout: QGridLayout):
|
||||
self.layout = layout
|
||||
|
||||
def is_position_occupied(self, row: int, col: int) -> bool:
|
||||
"""
|
||||
Check if the position in the layout is occupied by a widget.
|
||||
|
||||
Args:
|
||||
row(int): The row to check.
|
||||
col(int): The column to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the position is occupied, False otherwise.
|
||||
"""
|
||||
for i in range(self.layout.count()):
|
||||
widget_row, widget_col, _, _ = self.layout.getItemPosition(i)
|
||||
if widget_row == row and widget_col == col:
|
||||
return True
|
||||
return False
|
||||
|
||||
def shift_widgets(
|
||||
self,
|
||||
direction: Literal["down", "up", "left", "right"] = "down",
|
||||
start_row: int = 0,
|
||||
start_col: int = 0,
|
||||
):
|
||||
"""
|
||||
Shift widgets in the layout in the specified direction starting from the specified position.
|
||||
|
||||
Args:
|
||||
direction(str): The direction to shift the widgets. Can be "down", "up", "left", or "right".
|
||||
start_row(int): The row to start shifting from. Default is 0.
|
||||
start_col(int): The column to start shifting from. Default is 0.
|
||||
"""
|
||||
for i in reversed(range(self.layout.count())):
|
||||
widget_item = self.layout.itemAt(i)
|
||||
widget = widget_item.widget()
|
||||
row, col, rowspan, colspan = self.layout.getItemPosition(i)
|
||||
if direction == "down" and row >= start_row:
|
||||
self.layout.addWidget(widget, row + 1, col, rowspan, colspan)
|
||||
elif direction == "up" and row > start_row:
|
||||
self.layout.addWidget(widget, row - 1, col, rowspan, colspan)
|
||||
elif direction == "right" and col >= start_col:
|
||||
self.layout.addWidget(widget, row, col + 1, rowspan, colspan)
|
||||
elif direction == "left" and col > start_col:
|
||||
self.layout.addWidget(widget, row, col - 1, rowspan, colspan)
|
||||
|
||||
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
|
||||
"""
|
||||
Move a widget to a new position in the layout.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to move.
|
||||
new_row(int): The new row to move the widget to.
|
||||
new_col(int): The new column to move the widget to.
|
||||
"""
|
||||
self.layout.removeWidget(widget)
|
||||
self.layout.addWidget(widget, new_row, new_col)
|
||||
|
||||
def add_widget(
|
||||
self,
|
||||
widget: QWidget,
|
||||
row=None,
|
||||
col=0,
|
||||
rowspan=1,
|
||||
colspan=1,
|
||||
shift: Literal["down", "up", "left", "right"] = "down",
|
||||
):
|
||||
"""
|
||||
Add a widget to the layout at the specified position.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to add.
|
||||
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
|
||||
col(int): The column to add the widget to. Default is 0.
|
||||
rowspan(int): The number of rows the widget will span. Default is 1.
|
||||
colspan(int): The number of columns the widget will span. Default is 1.
|
||||
shift(str): The direction to shift the widgets if the position is occupied. Can be "down", "up", "left", or "right".
|
||||
"""
|
||||
if row is None:
|
||||
row = self.layout.rowCount()
|
||||
if self.is_position_occupied(row, col):
|
||||
self.shift_widgets(shift, start_row=row)
|
||||
self.layout.addWidget(widget, row, col, rowspan, colspan)
|
||||
|
||||
def get_widgets_positions(self) -> dict:
|
||||
"""
|
||||
Get the positions of all widgets in the layout.
|
||||
Returns:
|
||||
dict: A dictionary with the positions of the widgets in the layout.
|
||||
|
||||
"""
|
||||
positions = []
|
||||
for i in range(self.layout.count()):
|
||||
widget_item = self.layout.itemAt(i)
|
||||
widget = widget_item.widget()
|
||||
if widget:
|
||||
position = self.layout.getItemPosition(i)
|
||||
positions.append((position, widget))
|
||||
positions.sort(key=lambda x: (x[0][0], x[0][1], x[0][2], x[0][3]))
|
||||
ordered_positions = OrderedDict()
|
||||
for pos, widget in positions:
|
||||
ordered_positions[pos] = widget
|
||||
return ordered_positions
|
||||
54
bec_widgets/utils/plugin_templates/plugin.template
Normal file
54
bec_widgets/utils/plugin_templates/plugin.template
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
{widget_import}
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='{plugin_name_pascal}' name='{plugin_name_snake}'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = {plugin_name_pascal}(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def includeFile(self):
|
||||
return "{plugin_name_snake}"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "{plugin_name_pascal}"
|
||||
|
||||
def toolTip(self):
|
||||
return "{plugin_tooltip}"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/utils/plugin_templates/register.template
Normal file
15
bec_widgets/utils/plugin_templates/register.template
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
{plugin_import}
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget({plugin_name_pascal}Plugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
158
bec_widgets/utils/plugin_utils.py
Normal file
158
bec_widgets/utils/plugin_utils.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
|
||||
def get_plugin_widgets() -> dict[str, BECConnector]:
|
||||
"""
|
||||
Get all available widgets from the plugin directory. Widgets are classes that inherit from BECConnector.
|
||||
The plugins are provided through python plugins and specified in the respective pyproject.toml file using
|
||||
the following key:
|
||||
|
||||
[project.entry-points."bec.widgets.user_widgets"]
|
||||
plugin_widgets = "path.to.plugin.module"
|
||||
|
||||
e.g.
|
||||
[project.entry-points."bec.widgets.user_widgets"]
|
||||
plugin_widgets = "pxiii_bec.bec_widgets.widgets"
|
||||
|
||||
assuming that the widgets module for the package pxiii_bec is located at pxiii_bec/bec_widgets/widgets and
|
||||
contains the widgets to be loaded within the pxiii_bec/bec_widgets/widgets/__init__.py file.
|
||||
|
||||
Returns:
|
||||
dict[str, BECConnector]: A dictionary of widget names and their respective classes.
|
||||
"""
|
||||
modules = _get_available_plugins("bec.widgets.user_widgets")
|
||||
loaded_plugins = {}
|
||||
print(modules)
|
||||
for module in modules:
|
||||
mods = inspect.getmembers(module, predicate=_filter_plugins)
|
||||
for name, mod_cls in mods:
|
||||
if name in loaded_plugins:
|
||||
print(f"Duplicated widgets plugin {name}.")
|
||||
loaded_plugins[name] = mod_cls
|
||||
return loaded_plugins
|
||||
|
||||
|
||||
def _filter_plugins(obj):
|
||||
return inspect.isclass(obj) and issubclass(obj, BECConnector)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BECClassInfo:
|
||||
name: str
|
||||
module: str
|
||||
file: str
|
||||
obj: type
|
||||
is_connector: bool = False
|
||||
is_widget: bool = False
|
||||
is_top_level: bool = False
|
||||
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self):
|
||||
self._collection = []
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
Add a class to the collection.
|
||||
|
||||
Args:
|
||||
class_info(BECClassInfo): The class information
|
||||
"""
|
||||
self.collection.append(class_info)
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
"""
|
||||
Get the collection of classes.
|
||||
"""
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def connector_classes(self):
|
||||
"""
|
||||
Get all connector classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_connector]
|
||||
|
||||
@property
|
||||
def top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_top_level]
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""
|
||||
Get all plugins. These are all classes that are on the top level and are widgets.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget and info.is_top_level]
|
||||
|
||||
@property
|
||||
def widgets(self):
|
||||
"""
|
||||
Get all widgets. These are all classes inheriting from BECWidget.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_widget]
|
||||
|
||||
@property
|
||||
def rpc_top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
|
||||
"""
|
||||
return [info.obj for info in self.collection if info.is_top_level and info.is_connector]
|
||||
|
||||
|
||||
def get_rpc_classes(repo_name: str) -> BECClassContainer:
|
||||
"""
|
||||
Get all RPC-enabled classes in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_name(str): The name of the repository.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with keys "connector_classes" and "top_level_classes" and values as lists of classes.
|
||||
"""
|
||||
collection = BECClassContainer()
|
||||
anchor_module = importlib.import_module(f"{repo_name}.widgets")
|
||||
directory = os.path.dirname(anchor_module.__file__)
|
||||
for root, _, files in sorted(os.walk(directory)):
|
||||
for file in files:
|
||||
if not file.endswith(".py") or file.startswith("__"):
|
||||
continue
|
||||
|
||||
path = os.path.join(root, file)
|
||||
subs = os.path.dirname(os.path.relpath(path, directory)).split("/")
|
||||
if len(subs) == 1 and not subs[0]:
|
||||
module_name = file.split(".")[0]
|
||||
else:
|
||||
module_name = ".".join(subs + [file.split(".")[0]])
|
||||
|
||||
module = importlib.import_module(f"{repo_name}.widgets.{module_name}")
|
||||
|
||||
for name in dir(module):
|
||||
obj = getattr(module, name)
|
||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||
continue
|
||||
if isinstance(obj, type):
|
||||
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
|
||||
if issubclass(obj, BECConnector):
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
):
|
||||
class_info.is_top_level = True
|
||||
collection.add_class(class_info)
|
||||
|
||||
return collection
|
||||
15
bec_widgets/utils/rpc_decorator.py
Normal file
15
bec_widgets/utils/rpc_decorator.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def rpc_public(func):
|
||||
func.rpc_public = True # Mark the function for later processing by the class decorator
|
||||
return func
|
||||
|
||||
|
||||
def register_rpc_methods(cls):
|
||||
"""
|
||||
Class decorator to scan for rpc_public methods and add them to USER_ACCESS.
|
||||
"""
|
||||
if not hasattr(cls, "USER_ACCESS"):
|
||||
cls.USER_ACCESS = set()
|
||||
for name, method in cls.__dict__.items():
|
||||
if getattr(method, "rpc_public", False):
|
||||
cls.USER_ACCESS.add(name)
|
||||
return cls
|
||||
37
bec_widgets/utils/thread_checker.py
Normal file
37
bec_widgets/utils/thread_checker.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import threading
|
||||
|
||||
|
||||
class ThreadTracker:
|
||||
def __init__(self, exclude_names=None):
|
||||
self.exclude_names = exclude_names if exclude_names else []
|
||||
self.initial_threads = self._capture_threads()
|
||||
|
||||
def _capture_threads(self):
|
||||
return set(
|
||||
th
|
||||
for th in threading.enumerate()
|
||||
if not any(ex_name in th.name for ex_name in self.exclude_names)
|
||||
and th is not threading.main_thread()
|
||||
)
|
||||
|
||||
def _thread_info(self, threads):
|
||||
return ", \n".join(f"{th.name}(ID: {th.ident})" for th in threads)
|
||||
|
||||
def check_unfinished_threads(self):
|
||||
current_threads = self._capture_threads()
|
||||
additional_threads = current_threads - self.initial_threads
|
||||
closed_threads = self.initial_threads - current_threads
|
||||
if additional_threads:
|
||||
raise Exception(
|
||||
f"###### Initial threads ######:\n {self._thread_info(self.initial_threads)}\n"
|
||||
f"###### Current threads ######:\n {self._thread_info(current_threads)}\n"
|
||||
f"###### Closed threads ######:\n {self._thread_info(closed_threads)}\n"
|
||||
f"###### Unfinished threads ######:\n {self._thread_info(additional_threads)}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"All threads properly closed.\n"
|
||||
f"###### Initial threads ######:\n {self._thread_info(self.initial_threads)}\n"
|
||||
f"###### Current threads ######:\n {self._thread_info(current_threads)}\n"
|
||||
f"###### Closed threads ######:\n {self._thread_info(closed_threads)}"
|
||||
)
|
||||
140
bec_widgets/utils/ui_loader.py
Normal file
140
bec_widgets/utils/ui_loader.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import os
|
||||
|
||||
from qtpy import PYQT6, PYSIDE6, QT_VERSION
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_rpc_classes
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict = None):
|
||||
super().__init__(baseinstance)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
self.baseinstance = baseinstance
|
||||
|
||||
def createWidget(self, class_name, parent=None, name=""):
|
||||
if class_name in self.custom_widgets:
|
||||
widget = self.custom_widgets[class_name](parent)
|
||||
widget.setObjectName(name)
|
||||
return widget
|
||||
return super().createWidget(class_name, parent, name)
|
||||
|
||||
|
||||
class UILoader:
|
||||
"""Universal UI loader for PyQt6 and PySide6."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
|
||||
widgets = get_rpc_classes("bec_widgets").top_level_classes
|
||||
|
||||
self.custom_widgets = {widget.__name__: widget for widget in widgets}
|
||||
|
||||
if PYSIDE6:
|
||||
self.loader = self.load_ui_pyside6
|
||||
elif PYQT6:
|
||||
self.loader = self.load_ui_pyqt6
|
||||
else:
|
||||
raise ImportError("No compatible Qt bindings found.")
|
||||
|
||||
def load_ui_pyside6(self, ui_file, parent=None):
|
||||
"""
|
||||
Specific loader for PySide6 using QUiLoader.
|
||||
Args:
|
||||
ui_file(str): Path to the .ui file.
|
||||
parent(QWidget): Parent widget.
|
||||
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
|
||||
loader = CustomUiLoader(parent, self.custom_widgets)
|
||||
file = QFile(ui_file)
|
||||
if not file.open(QIODevice.ReadOnly):
|
||||
raise IOError(f"Cannot open file: {ui_file}")
|
||||
widget = loader.load(file, parent)
|
||||
file.close()
|
||||
return widget
|
||||
|
||||
def load_ui_pyqt6(self, ui_file, parent=None):
|
||||
"""
|
||||
Specific loader for PyQt6 using loadUi.
|
||||
Args:
|
||||
ui_file(str): Path to the .ui file.
|
||||
parent(QWidget): Parent widget.
|
||||
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
from PyQt6.uic.Loader.loader import DynamicUILoader
|
||||
|
||||
class CustomDynamicUILoader(DynamicUILoader):
|
||||
def __init__(self, package, custom_widgets: dict = None):
|
||||
super().__init__(package)
|
||||
self.custom_widgets = custom_widgets or {}
|
||||
|
||||
def _handle_custom_widgets(self, el):
|
||||
"""Handle the <customwidgets> element."""
|
||||
|
||||
def header2module(header):
|
||||
"""header2module(header) -> string
|
||||
|
||||
Convert paths to C++ header files to according Python modules
|
||||
>>> header2module("foo/bar/baz.h")
|
||||
'foo.bar.baz'
|
||||
"""
|
||||
|
||||
if header.endswith(".h"):
|
||||
header = header[:-2]
|
||||
|
||||
mpath = []
|
||||
for part in header.split("/"):
|
||||
# Ignore any empty parts or those that refer to the current
|
||||
# directory.
|
||||
if part not in ("", "."):
|
||||
if part == "..":
|
||||
# We should allow this for Python3.
|
||||
raise SyntaxError(
|
||||
"custom widget header file name may not contain '..'."
|
||||
)
|
||||
|
||||
mpath.append(part)
|
||||
|
||||
return ".".join(mpath)
|
||||
|
||||
for custom_widget in el:
|
||||
classname = custom_widget.findtext("class")
|
||||
header = custom_widget.findtext("header")
|
||||
if header:
|
||||
header = self._translate_bec_widgets_header(header)
|
||||
self.factory.addCustomWidget(
|
||||
classname,
|
||||
custom_widget.findtext("extends") or "QWidget",
|
||||
header2module(header),
|
||||
)
|
||||
|
||||
def _translate_bec_widgets_header(self, header):
|
||||
for name, value in self.custom_widgets.items():
|
||||
if header == DesignerPluginInfo.pascal_to_snake(name):
|
||||
return value.__module__
|
||||
return header
|
||||
|
||||
return CustomDynamicUILoader("", self.custom_widgets).loadUi(ui_file, parent)
|
||||
|
||||
def load_ui(self, ui_file, parent=None):
|
||||
"""
|
||||
Universal UI loader method.
|
||||
Args:
|
||||
ui_file(str): Path to the .ui file.
|
||||
parent(QWidget): Parent widget.
|
||||
|
||||
Returns:
|
||||
QWidget: The loaded widget.
|
||||
"""
|
||||
if parent is None:
|
||||
parent = self.parent
|
||||
return self.loader(ui_file, parent)
|
||||
13
bec_widgets/utils/validator_delegate.py
Normal file
13
bec_widgets/utils/validator_delegate.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# from qtpy.QtGui import QDoubleValidator
|
||||
# from qtpy.QtWidgets import QStyledItemDelegate, QLineEdit
|
||||
|
||||
from qtpy.QtGui import QDoubleValidator
|
||||
from qtpy.QtWidgets import QLineEdit, QStyledItemDelegate
|
||||
|
||||
|
||||
class DoubleValidationDelegate(QStyledItemDelegate):
|
||||
def createEditor(self, parent, option, index):
|
||||
editor = QLineEdit(parent)
|
||||
validator = QDoubleValidator()
|
||||
editor.setValidator(validator)
|
||||
return editor
|
||||
364
bec_widgets/utils/widget_io.py
Normal file
364
bec_widgets/utils/widget_io.py
Normal file
@@ -0,0 +1,364 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class WidgetHandler(ABC):
|
||||
"""Abstract base class for all widget handlers."""
|
||||
|
||||
@abstractmethod
|
||||
def get_value(self, widget: QWidget):
|
||||
"""Retrieve value from the widget instance."""
|
||||
|
||||
@abstractmethod
|
||||
def set_value(self, widget: QWidget, value):
|
||||
"""Set a value on the widget instance."""
|
||||
|
||||
|
||||
class LineEditHandler(WidgetHandler):
|
||||
"""Handler for QLineEdit widgets."""
|
||||
|
||||
def get_value(self, widget: QLineEdit) -> str:
|
||||
return widget.text()
|
||||
|
||||
def set_value(self, widget: QLineEdit, value: str) -> None:
|
||||
widget.setText(value)
|
||||
|
||||
|
||||
class ComboBoxHandler(WidgetHandler):
|
||||
"""Handler for QComboBox widgets."""
|
||||
|
||||
def get_value(self, widget: QComboBox) -> int:
|
||||
return widget.currentIndex()
|
||||
|
||||
def set_value(self, widget: QComboBox, value: int | str) -> None:
|
||||
if isinstance(value, str):
|
||||
value = widget.findText(value)
|
||||
if isinstance(value, int):
|
||||
widget.setCurrentIndex(value)
|
||||
|
||||
|
||||
class TableWidgetHandler(WidgetHandler):
|
||||
"""Handler for QTableWidget widgets."""
|
||||
|
||||
def get_value(self, widget: QTableWidget) -> list:
|
||||
return [
|
||||
[
|
||||
widget.item(row, col).text() if widget.item(row, col) else ""
|
||||
for col in range(widget.columnCount())
|
||||
]
|
||||
for row in range(widget.rowCount())
|
||||
]
|
||||
|
||||
def set_value(self, widget: QTableWidget, value) -> None:
|
||||
for row, row_values in enumerate(value):
|
||||
for col, cell_value in enumerate(row_values):
|
||||
item = QTableWidgetItem(str(cell_value))
|
||||
widget.setItem(row, col, item)
|
||||
|
||||
|
||||
class SpinBoxHandler(WidgetHandler):
|
||||
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
|
||||
|
||||
def get_value(self, widget):
|
||||
return widget.value()
|
||||
|
||||
def set_value(self, widget, value):
|
||||
widget.setValue(value)
|
||||
|
||||
|
||||
class CheckBoxHandler(WidgetHandler):
|
||||
"""Handler for QCheckBox widgets."""
|
||||
|
||||
def get_value(self, widget):
|
||||
return widget.isChecked()
|
||||
|
||||
def set_value(self, widget, value):
|
||||
widget.setChecked(value)
|
||||
|
||||
|
||||
class LabelHandler(WidgetHandler):
|
||||
"""Handler for QLabel widgets."""
|
||||
|
||||
def get_value(self, widget):
|
||||
return widget.text()
|
||||
|
||||
def set_value(self, widget, value):
|
||||
widget.setText(value)
|
||||
|
||||
|
||||
class WidgetIO:
|
||||
"""Public interface for getting and setting values using handler mapping"""
|
||||
|
||||
_handlers = {
|
||||
QLineEdit: LineEditHandler,
|
||||
QComboBox: ComboBoxHandler,
|
||||
QTableWidget: TableWidgetHandler,
|
||||
QSpinBox: SpinBoxHandler,
|
||||
QDoubleSpinBox: SpinBoxHandler,
|
||||
QCheckBox: CheckBoxHandler,
|
||||
QLabel: LabelHandler,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_value(widget, ignore_errors=False):
|
||||
"""
|
||||
Retrieve value from the widget instance.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = WidgetIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().get_value(widget) # Instantiate the handler
|
||||
if not ignore_errors:
|
||||
raise ValueError(f"No handler for widget type: {type(widget)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def set_value(widget, value, ignore_errors=False):
|
||||
"""
|
||||
Set a value on the widget instance.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
value: Value to set.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = WidgetIO._find_handler(widget)
|
||||
if handler_class:
|
||||
handler_class().set_value(widget, value) # Instantiate the handler
|
||||
elif not ignore_errors:
|
||||
raise ValueError(f"No handler for widget type: {type(widget)}")
|
||||
|
||||
@staticmethod
|
||||
def check_and_adjust_limits(spin_box: QDoubleSpinBox, number: float):
|
||||
"""
|
||||
Check if the new limits are within the current limits, if not adjust the limits.
|
||||
|
||||
Args:
|
||||
number(float): The new value to check against the limits.
|
||||
"""
|
||||
|
||||
min_value = spin_box.minimum()
|
||||
max_value = spin_box.maximum()
|
||||
|
||||
# Calculate the new limits
|
||||
new_limit = number + 5 * number
|
||||
|
||||
if number < min_value:
|
||||
spin_box.setMinimum(new_limit)
|
||||
elif number > max_value:
|
||||
spin_box.setMaximum(new_limit)
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
Find the appropriate handler for the widget by checking its base classes.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
|
||||
Returns:
|
||||
handler_class: The handler class if found, otherwise None.
|
||||
"""
|
||||
for base in type(widget).__mro__:
|
||||
if base in WidgetIO._handlers:
|
||||
return WidgetIO._handlers[base]
|
||||
return None
|
||||
|
||||
|
||||
################## for exporting and importing widget hierarchies ##################
|
||||
|
||||
|
||||
class WidgetHierarchy:
|
||||
@staticmethod
|
||||
def print_widget_hierarchy(
|
||||
widget,
|
||||
indent: int = 0,
|
||||
grab_values: bool = False,
|
||||
prefix: str = "",
|
||||
exclude_internal_widgets: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Print the widget hierarchy to the console.
|
||||
|
||||
Args:
|
||||
widget: Widget to print the hierarchy of
|
||||
indent(int, optional): Level of indentation.
|
||||
grab_values(bool,optional): Whether to grab the values of the widgets.
|
||||
prefix(stc,optional): Custom string prefix for indentation.
|
||||
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
|
||||
"""
|
||||
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
|
||||
if grab_values:
|
||||
value = WidgetIO.get_value(widget, ignore_errors=True)
|
||||
value_str = f" [value: {value}]" if value is not None else ""
|
||||
widget_info += value_str
|
||||
|
||||
print(prefix + widget_info)
|
||||
|
||||
children = widget.children()
|
||||
for child in children:
|
||||
if (
|
||||
exclude_internal_widgets
|
||||
and isinstance(widget, QComboBox)
|
||||
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
|
||||
):
|
||||
continue
|
||||
child_prefix = prefix + " "
|
||||
arrow = "├─ " if child != children[-1] else "└─ "
|
||||
WidgetHierarchy.print_widget_hierarchy(
|
||||
child, indent + 1, grab_values, prefix=child_prefix + arrow
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def export_config_to_dict(
|
||||
widget: QWidget,
|
||||
config: dict = None,
|
||||
indent: int = 0,
|
||||
grab_values: bool = False,
|
||||
print_hierarchy: bool = False,
|
||||
save_all: bool = True,
|
||||
exclude_internal_widgets: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Export the widget hierarchy to a dictionary.
|
||||
|
||||
Args:
|
||||
widget: Widget to print the hierarchy of.
|
||||
config(dict,optional): Dictionary to export the hierarchy to.
|
||||
indent(int,optional): Level of indentation.
|
||||
grab_values(bool,optional): Whether to grab the values of the widgets.
|
||||
print_hierarchy(bool,optional): Whether to print the hierarchy to the console.
|
||||
save_all(bool,optional): Whether to save all widgets or only those with values.
|
||||
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
|
||||
Returns:
|
||||
config(dict): Dictionary containing the widget hierarchy.
|
||||
"""
|
||||
if config is None:
|
||||
config = {}
|
||||
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
|
||||
|
||||
# if grab_values and type(widget) in WidgetIO._handlers:
|
||||
if grab_values:
|
||||
value = WidgetIO.get_value(widget, ignore_errors=True)
|
||||
if value is not None or save_all:
|
||||
if widget_info not in config:
|
||||
config[widget_info] = {}
|
||||
if value is not None:
|
||||
config[widget_info]["value"] = value
|
||||
|
||||
if print_hierarchy:
|
||||
WidgetHierarchy.print_widget_hierarchy(widget, indent, grab_values)
|
||||
|
||||
for child in widget.children():
|
||||
# Skip internal widgets of QComboBox in PyQt6
|
||||
if (
|
||||
exclude_internal_widgets
|
||||
and isinstance(widget, QComboBox)
|
||||
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
|
||||
):
|
||||
continue
|
||||
child_config = WidgetHierarchy.export_config_to_dict(
|
||||
child, None, indent + 1, grab_values, print_hierarchy, save_all
|
||||
)
|
||||
if child_config or save_all:
|
||||
if widget_info not in config:
|
||||
config[widget_info] = {}
|
||||
config[widget_info].update(child_config)
|
||||
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def import_config_from_dict(widget, config: dict, set_values: bool = False) -> None:
|
||||
"""
|
||||
Import the widget hierarchy from a dictionary.
|
||||
|
||||
Args:
|
||||
widget: Widget to import the hierarchy to.
|
||||
config:
|
||||
set_values:
|
||||
"""
|
||||
widget_name = f"{widget.__class__.__name__} ({widget.objectName()})"
|
||||
widget_config = config.get(widget_name, {})
|
||||
for child in widget.children():
|
||||
child_name = f"{child.__class__.__name__} ({child.objectName()})"
|
||||
child_config = widget_config.get(child_name)
|
||||
if child_config is not None:
|
||||
value = child_config.get("value")
|
||||
if set_values and value is not None:
|
||||
WidgetIO.set_value(child, value)
|
||||
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
||||
|
||||
|
||||
# Example application to demonstrate the usage of the functions
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([])
|
||||
|
||||
# Create instance of WidgetHierarchy
|
||||
widget_hierarchy = WidgetHierarchy()
|
||||
|
||||
# Create a simple widget hierarchy for demonstration purposes
|
||||
main_widget = QWidget()
|
||||
layout = QVBoxLayout(main_widget)
|
||||
line_edit = QLineEdit(main_widget)
|
||||
combo_box = QComboBox(main_widget)
|
||||
table_widget = QTableWidget(2, 2, main_widget)
|
||||
spin_box = QSpinBox(main_widget)
|
||||
layout.addWidget(line_edit)
|
||||
layout.addWidget(combo_box)
|
||||
layout.addWidget(table_widget)
|
||||
layout.addWidget(spin_box)
|
||||
|
||||
# Add text items to the combo box
|
||||
combo_box.addItems(["Option 1", "Option 2", "Option 3"])
|
||||
|
||||
main_widget.show()
|
||||
|
||||
# Hierarchy of original widget
|
||||
print(30 * "#")
|
||||
print(f"Widget hierarchy for {main_widget.objectName()}:")
|
||||
print(30 * "#")
|
||||
config_dict = widget_hierarchy.export_config_to_dict(
|
||||
main_widget, grab_values=True, print_hierarchy=True
|
||||
)
|
||||
print(30 * "#")
|
||||
print(f"Config dict: {config_dict}")
|
||||
|
||||
# Hierarchy of new widget and set values
|
||||
new_config_dict = {
|
||||
"QWidget ()": {
|
||||
"QLineEdit ()": {"value": "New Text"},
|
||||
"QComboBox ()": {"value": 1},
|
||||
"QTableWidget ()": {"value": [["a", "b"], ["c", "d"]]},
|
||||
"QSpinBox ()": {"value": 10},
|
||||
}
|
||||
}
|
||||
widget_hierarchy.import_config_from_dict(main_widget, new_config_dict, set_values=True)
|
||||
print(30 * "#")
|
||||
config_dict_new = widget_hierarchy.export_config_to_dict(
|
||||
main_widget, grab_values=True, print_hierarchy=True
|
||||
)
|
||||
config_dict_new_reduced = widget_hierarchy.export_config_to_dict(
|
||||
main_widget, grab_values=True, print_hierarchy=True, save_all=False
|
||||
)
|
||||
print(30 * "#")
|
||||
print(f"Config dict new FULL: {config_dict_new}")
|
||||
print(f"Config dict new REDUCED: {config_dict_new_reduced}")
|
||||
|
||||
app.exec()
|
||||
88
bec_widgets/utils/yaml_dialog.py
Normal file
88
bec_widgets/utils/yaml_dialog.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
|
||||
from typing import Union
|
||||
|
||||
import yaml
|
||||
from qtpy.QtWidgets import QFileDialog
|
||||
|
||||
|
||||
def load_yaml_gui(instance) -> Union[dict, None]:
|
||||
"""
|
||||
Load YAML file from disk.
|
||||
|
||||
Args:
|
||||
instance: Instance of the calling widget.
|
||||
|
||||
Returns:
|
||||
dict: Configuration data loaded from the YAML file.
|
||||
"""
|
||||
options = QFileDialog.Options()
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
instance, "Load Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
|
||||
)
|
||||
config = load_yaml(file_path)
|
||||
return config
|
||||
|
||||
|
||||
def load_yaml(file_path: str) -> Union[dict, None]:
|
||||
"""
|
||||
Load YAML file from disk.
|
||||
|
||||
Args:
|
||||
file_path(str): Path to the YAML file.
|
||||
|
||||
Returns:
|
||||
dict: Configuration data loaded from the YAML file.
|
||||
"""
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
with open(file_path, "r") as file:
|
||||
config = yaml.load(file, Loader=yaml.FullLoader)
|
||||
return config
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"The file {file_path} was not found.")
|
||||
except PermissionError:
|
||||
print(f"Permission denied for file {file_path}.")
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Error parsing YAML file {file_path}: {e}")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while loading the settings from {file_path}: {e}")
|
||||
|
||||
|
||||
def save_yaml_gui(instance, config: dict) -> None:
|
||||
"""
|
||||
Save YAML file to disk.
|
||||
|
||||
Args:
|
||||
instance: Instance of the calling widget.
|
||||
config: Configuration data to be saved.
|
||||
"""
|
||||
options = QFileDialog.Options()
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
instance, "Save Settings", "", "YAML Files (*.yaml *.yml);;All Files (*)", options=options
|
||||
)
|
||||
|
||||
save_yaml(file_path, config)
|
||||
|
||||
|
||||
def save_yaml(file_path: str, config: dict) -> None:
|
||||
"""
|
||||
Save YAML file to disk.
|
||||
|
||||
Args:
|
||||
file_path(str): Path to the YAML file.
|
||||
config(dict): Configuration data to be saved.
|
||||
"""
|
||||
if not file_path:
|
||||
return
|
||||
try:
|
||||
if not (file_path.endswith(".yaml") or file_path.endswith(".yml")):
|
||||
file_path += ".yaml"
|
||||
|
||||
with open(file_path, "w") as file:
|
||||
yaml.dump(config, file)
|
||||
print(f"Settings saved to {file_path}")
|
||||
except Exception as e:
|
||||
print(f"An error occurred while saving the settings to {file_path}: {e}")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
0
bec_widgets/widgets/bec_queue/__init__.py
Normal file
0
bec_widgets/widgets/bec_queue/__init__.py
Normal file
111
bec_widgets/widgets/bec_queue/bec_queue.py
Normal file
111
bec_widgets/widgets/bec_queue/bec_queue.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from qtpy.QtCore import Qt, Slot
|
||||
from qtpy.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
|
||||
|
||||
class BECQueue(BECConnector, QTableWidget):
|
||||
"""
|
||||
Widget to display the BEC queue.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
client=None,
|
||||
config: ConnectionConfig = None,
|
||||
gui_id: str = None,
|
||||
):
|
||||
super().__init__(client, config, gui_id)
|
||||
QTableWidget.__init__(self, parent=parent)
|
||||
self.setColumnCount(3)
|
||||
self.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"])
|
||||
header = self.horizontalHeader()
|
||||
header.setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
|
||||
self.reset_content()
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_queue(self, content, _metadata):
|
||||
"""
|
||||
Update the queue table with the latest queue information.
|
||||
|
||||
Args:
|
||||
content (dict): The queue content.
|
||||
_metadata (dict): The metadata.
|
||||
"""
|
||||
# only show the primary queue for now
|
||||
queue_info = content.get("queue", {}).get("primary", {}).get("info", [])
|
||||
self.setRowCount(len(queue_info))
|
||||
self.clearContents()
|
||||
|
||||
if not queue_info:
|
||||
self.reset_content()
|
||||
return
|
||||
|
||||
for index, item in enumerate(queue_info):
|
||||
blocks = item.get("request_blocks", [])
|
||||
scan_types = []
|
||||
scan_numbers = []
|
||||
status = item.get("status", "")
|
||||
for request_block in blocks:
|
||||
scan_type = request_block.get("content", {}).get("scan_type", "")
|
||||
if scan_type:
|
||||
scan_types.append(scan_type)
|
||||
scan_number = request_block.get("scan_number", "")
|
||||
if scan_number:
|
||||
scan_numbers.append(str(scan_number))
|
||||
if scan_types:
|
||||
scan_types = ", ".join(scan_types)
|
||||
if scan_numbers:
|
||||
scan_numbers = ", ".join(scan_numbers)
|
||||
self.set_row(index, scan_numbers, scan_types, status)
|
||||
|
||||
def format_item(self, content: str) -> QTableWidgetItem:
|
||||
"""
|
||||
Format the content of the table item.
|
||||
|
||||
Args:
|
||||
content (str): The content to be formatted.
|
||||
|
||||
Returns:
|
||||
QTableWidgetItem: The formatted item.
|
||||
"""
|
||||
item = QTableWidgetItem(content)
|
||||
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
return item
|
||||
|
||||
def set_row(self, index: int, scan_number: str, scan_type: str, status: str):
|
||||
"""
|
||||
Set the row of the table.
|
||||
|
||||
Args:
|
||||
index (int): The index of the row.
|
||||
scan_number (str): The scan number.
|
||||
scan_type (str): The scan type.
|
||||
status (str): The status.
|
||||
"""
|
||||
|
||||
self.setItem(index, 0, self.format_item(scan_number))
|
||||
self.setItem(index, 1, self.format_item(scan_type))
|
||||
self.setItem(index, 2, self.format_item(status))
|
||||
|
||||
def reset_content(self):
|
||||
"""
|
||||
Reset the content of the table.
|
||||
"""
|
||||
|
||||
self.setRowCount(1)
|
||||
self.set_row(0, "", "", "")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = BECQueue()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
1
bec_widgets/widgets/bec_queue/bec_queue.pyproject
Normal file
1
bec_widgets/widgets/bec_queue/bec_queue.pyproject
Normal file
@@ -0,0 +1 @@
|
||||
{'files': ['bec_queue.py']}
|
||||
54
bec_widgets/widgets/bec_queue/bec_queue_plugin.py
Normal file
54
bec_widgets/widgets/bec_queue/bec_queue_plugin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.bec_queue.bec_queue import BECQueue
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECQueue' name='bec_queue'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class BECQueuePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECQueue(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_queue"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECQueue"
|
||||
|
||||
def toolTip(self):
|
||||
return "Widget to display the BEC queue."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
15
bec_widgets/widgets/bec_queue/register_bec_queue.py
Normal file
15
bec_widgets/widgets/bec_queue/register_bec_queue.py
Normal file
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.bec_queue.bec_queue_plugin import BECQueuePlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECQueuePlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
0
bec_widgets/widgets/bec_status_box/__init__.py
Normal file
0
bec_widgets/widgets/bec_status_box/__init__.py
Normal file
316
bec_widgets/widgets/bec_status_box/bec_status_box.py
Normal file
316
bec_widgets/widgets/bec_status_box/bec_status_box.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""This module contains the BECStatusBox widget, which displays the status of different BEC services in a collapsible tree widget.
|
||||
The widget automatically updates the status of all running BEC services, and displays their status.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import qdarktheme
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from qtpy.QtCore import QObject, QTimer, Signal, Slot
|
||||
from qtpy.QtWidgets import QHBoxLayout, QTreeWidget, QTreeWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.widgets.bec_status_box.status_item import StatusItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
# TODO : Put normal imports back when Pydantic gets faster
|
||||
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
|
||||
|
||||
|
||||
@dataclass
|
||||
class BECServiceInfoContainer:
|
||||
"""Container to store information about the BEC services."""
|
||||
|
||||
service_name: str
|
||||
status: str
|
||||
info: dict
|
||||
metrics: dict | None
|
||||
|
||||
|
||||
class BECServiceStatusMixin(QObject):
|
||||
"""Mixin to receive the latest service status from the BEC server and emit it via services_update signal.
|
||||
|
||||
Args:
|
||||
client (BECClient): The client object to connect to the BEC server.
|
||||
"""
|
||||
|
||||
services_update = Signal(dict, dict)
|
||||
|
||||
def __init__(self, parent, client: BECClient):
|
||||
super().__init__(parent)
|
||||
self.client = client
|
||||
self._service_update_timer = QTimer()
|
||||
self._service_update_timer.timeout.connect(self._get_service_status)
|
||||
self._service_update_timer.start(1000)
|
||||
|
||||
def _get_service_status(self):
|
||||
"""Get the latest service status from the BEC server."""
|
||||
# pylint: disable=protected-access
|
||||
self.client._update_existing_services()
|
||||
self.services_update.emit(self.client._services_info, self.client._services_metric)
|
||||
|
||||
|
||||
class BECStatusBox(BECConnector, QWidget):
|
||||
"""An autonomous widget to display the status of BEC services.
|
||||
|
||||
Args:
|
||||
parent Optional : The parent widget for the BECStatusBox. Defaults to None.
|
||||
box_name Optional(str): The name of the top service label. Defaults to "BEC Server".
|
||||
client Optional(BECClient): The client object to connect to the BEC server. Defaults to None
|
||||
config Optional(BECStatusBoxConfig | dict): The configuration for the status box. Defaults to None.
|
||||
gui_id Optional(str): The unique id for the widget. Defaults to None.
|
||||
"""
|
||||
|
||||
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
|
||||
|
||||
service_update = Signal(BECServiceInfoContainer)
|
||||
bec_core_state = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
box_name: str = "BEC Server",
|
||||
client: BECClient = None,
|
||||
bec_service_status_mixin: BECServiceStatusMixin = None,
|
||||
gui_id: str = None,
|
||||
):
|
||||
super().__init__(client=client, gui_id=gui_id)
|
||||
QWidget.__init__(self, parent=parent)
|
||||
self.tree = QTreeWidget(self)
|
||||
self.layout = QHBoxLayout(self)
|
||||
|
||||
self.box_name = box_name
|
||||
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})
|
||||
|
||||
if not bec_service_status_mixin:
|
||||
bec_service_status_mixin = BECServiceStatusMixin(self, client=self.client)
|
||||
self.bec_service_status = bec_service_status_mixin
|
||||
|
||||
self.init_ui()
|
||||
self.bec_service_status.services_update.connect(self.update_service_status)
|
||||
self.bec_core_state.connect(self.update_top_item_status)
|
||||
self.tree.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Init the UI for the BECStatusBox widget, should only take place once."""
|
||||
self.init_ui_tree_widget()
|
||||
top_label = self._create_status_widget(self.box_name, status=BECStatus.IDLE)
|
||||
tree_item = QTreeWidgetItem()
|
||||
tree_item.setExpanded(True)
|
||||
tree_item.setDisabled(True)
|
||||
self.status_container[self.box_name].update({"item": tree_item, "widget": top_label})
|
||||
self.tree.addTopLevelItem(tree_item)
|
||||
self.tree.setItemWidget(tree_item, 0, top_label)
|
||||
self.service_update.connect(top_label.update_config)
|
||||
self._initialized = True
|
||||
|
||||
def init_ui_tree_widget(self) -> None:
|
||||
"""Initialise the tree widget for the status box."""
|
||||
self.tree.setHeaderHidden(True)
|
||||
# TODO probably here is a problem still with setting the stylesheet
|
||||
self.tree.setStyleSheet(
|
||||
"QTreeWidget::item:!selected "
|
||||
"{ "
|
||||
"border: 1px solid gainsboro; "
|
||||
"border-left: none; "
|
||||
"border-top: none; "
|
||||
"}"
|
||||
"QTreeWidget::item:selected {}"
|
||||
)
|
||||
|
||||
def _create_status_widget(
|
||||
self, service_name: str, status=BECStatus, info: dict = None, metrics: dict = None
|
||||
) -> StatusItem:
|
||||
"""Creates a StatusItem (QWidget) for the given service, and stores all relevant
|
||||
information about the service in the status_container.
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
status (BECStatus): The status of the service.
|
||||
info Optional(dict): The information about the service. Default is {}
|
||||
metric Optional(dict): Metrics for the respective service. Default is None
|
||||
|
||||
Returns:
|
||||
StatusItem: The status item widget.
|
||||
"""
|
||||
if info is None:
|
||||
info = {}
|
||||
self._update_status_container(service_name, status, info, metrics)
|
||||
item = StatusItem(parent=self, config=self.status_container[service_name]["info"])
|
||||
return item
|
||||
|
||||
@Slot(str)
|
||||
def update_top_item_status(self, status: BECStatus) -> None:
|
||||
"""Method to update the status of the top item in the tree widget.
|
||||
Gets the status from the Signal 'bec_core_state' and updates the StatusItem via the signal 'service_update'.
|
||||
|
||||
Args:
|
||||
status (BECStatus): The state of the core services.
|
||||
"""
|
||||
self.status_container[self.box_name]["info"].status = status
|
||||
self.service_update.emit(self.status_container[self.box_name]["info"])
|
||||
|
||||
def _update_status_container(
|
||||
self, service_name: str, status: BECStatus, info: dict, metrics: dict = None
|
||||
) -> None:
|
||||
"""Update the status_container with the newest status and metrics for the BEC service.
|
||||
If information about the service already exists, it will create a new entry.
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
status (BECStatus): The status of the service.
|
||||
info (dict): The information about the service.
|
||||
metrics (dict): The metrics of the service.
|
||||
"""
|
||||
container = self.status_container[service_name].get("info", None)
|
||||
|
||||
if container:
|
||||
container.status = status.name
|
||||
container.info = info
|
||||
container.metrics = metrics
|
||||
return
|
||||
service_info_item = BECServiceInfoContainer(
|
||||
service_name=service_name,
|
||||
status=status.name if isinstance(status, BECStatus) else status,
|
||||
info=info,
|
||||
metrics=metrics,
|
||||
)
|
||||
self.status_container[service_name].update({"info": service_info_item})
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_service_status(self, services_info: dict, services_metric: dict) -> None:
|
||||
"""Callback function services_metric from BECServiceStatusMixin.
|
||||
It updates the status of all services.
|
||||
|
||||
Args:
|
||||
services_info (dict): A dictionary containing the service status for all running BEC services.
|
||||
services_metric (dict): A dictionary containing the service metrics for all running BEC services.
|
||||
"""
|
||||
checked = [self.box_name]
|
||||
services_info = self.update_core_services(services_info, services_metric)
|
||||
checked.extend(self.CORE_SERVICES)
|
||||
|
||||
for service_name, msg in sorted(services_info.items()):
|
||||
checked.append(service_name)
|
||||
metric_msg = services_metric.get(service_name, None)
|
||||
metrics = metric_msg.metrics if metric_msg else None
|
||||
if service_name in self.status_container:
|
||||
if not msg:
|
||||
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
|
||||
continue
|
||||
self._update_status_container(service_name, msg.status, msg.info, metrics)
|
||||
self.service_update.emit(self.status_container[service_name]["info"])
|
||||
continue
|
||||
|
||||
self.add_tree_item(service_name, msg.status, msg.info, metrics)
|
||||
self.check_redundant_tree_items(checked)
|
||||
|
||||
def update_core_services(self, services_info: dict, services_metric: dict) -> dict:
|
||||
"""Update the core services of BEC, and emit the updated status to the BECStatusBox.
|
||||
|
||||
Args:
|
||||
services_info (dict): A dictionary containing the service status of different services.
|
||||
services_metric (dict): A dictionary containing the service metrics of different services.
|
||||
|
||||
Returns:
|
||||
dict: The services_info dictionary after removing the info updates related to the CORE_SERVICES
|
||||
"""
|
||||
core_state = BECStatus.RUNNING
|
||||
for service_name in sorted(self.CORE_SERVICES):
|
||||
metric_msg = services_metric.get(service_name, None)
|
||||
metrics = metric_msg.metrics if metric_msg else None
|
||||
msg = services_info.pop(service_name, None)
|
||||
if service_name not in self.status_container:
|
||||
if not msg:
|
||||
self.add_tree_item(service_name, "NOTCONNECTED", {}, metrics)
|
||||
continue
|
||||
self.add_tree_item(service_name, msg.status, msg.info, metrics)
|
||||
continue
|
||||
if not msg:
|
||||
self.status_container[service_name]["info"].status = "NOTCONNECTED"
|
||||
core_state = None
|
||||
else:
|
||||
self._update_status_container(service_name, msg.status, msg.info, metrics)
|
||||
if core_state:
|
||||
core_state = msg.status if msg.status.value < core_state.value else core_state
|
||||
|
||||
self.service_update.emit(self.status_container[service_name]["info"])
|
||||
|
||||
self.bec_core_state.emit(core_state.name if core_state else "NOTCONNECTED")
|
||||
return services_info
|
||||
|
||||
def check_redundant_tree_items(self, checked: list) -> None:
|
||||
"""Utility method to check and remove redundant objects from the BECStatusBox.
|
||||
|
||||
Args:
|
||||
checked (list): A list of services that are currently running.
|
||||
"""
|
||||
to_be_deleted = [key for key in self.status_container if key not in checked]
|
||||
|
||||
for key in to_be_deleted:
|
||||
obj = self.status_container.pop(key)
|
||||
item = obj["item"]
|
||||
self.status_container[self.box_name]["item"].removeChild(item)
|
||||
|
||||
def add_tree_item(
|
||||
self, service_name: str, status: BECStatus, info: dict = None, metrics: dict = None
|
||||
) -> None:
|
||||
"""Method to add a new QTreeWidgetItem together with a StatusItem to the tree widget.
|
||||
|
||||
Args:
|
||||
service_name (str): The name of the service.
|
||||
status (BECStatus): The status of the service.
|
||||
info (dict): The information about the service.
|
||||
metrics (dict): The metrics of the service.
|
||||
"""
|
||||
item_widget = self._create_status_widget(service_name, status, info, metrics)
|
||||
item = QTreeWidgetItem()
|
||||
self.service_update.connect(item_widget.update_config)
|
||||
self.status_container[self.box_name]["item"].addChild(item)
|
||||
self.tree.setItemWidget(item, 0, item_widget)
|
||||
self.status_container[service_name].update({"item": item, "widget": item_widget})
|
||||
|
||||
@Slot(QTreeWidgetItem, int)
|
||||
def on_tree_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
|
||||
"""Callback function for double clicks on individual QTreeWidgetItems in the collapsed section.
|
||||
|
||||
Args:
|
||||
item (QTreeWidgetItem): The item that was double clicked.
|
||||
column (int): The column that was double clicked.
|
||||
"""
|
||||
for _, objects in self.status_container.items():
|
||||
if objects["item"] == item:
|
||||
objects["widget"].show_popup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Upon closing the widget, clean up the BECStatusBox and the QWidget.
|
||||
|
||||
Args:
|
||||
event: The close event.
|
||||
"""
|
||||
super().cleanup()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main method to run the BECStatusBox widget."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
qdarktheme.setup_theme("auto")
|
||||
main_window = BECStatusBox()
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['bec_status_box.py']}
|
||||
54
bec_widgets/widgets/bec_status_box/bec_status_box_plugin.py
Normal file
54
bec_widgets/widgets/bec_status_box/bec_status_box_plugin.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.bec_status_box.bec_status_box import BECStatusBox
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECStatusBox' name='bec_status_box'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class BECStatusBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECStatusBox(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_status_box"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECStatusBox"
|
||||
|
||||
def toolTip(self):
|
||||
return "An autonomous widget to display the status of BEC services."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.bec_status_box.bec_status_box_plugin import BECStatusBoxPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECStatusBoxPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
138
bec_widgets/widgets/bec_status_box/status_item.py
Normal file
138
bec_widgets/widgets/bec_status_box/status_item.py
Normal file
@@ -0,0 +1,138 @@
|
||||
""" Module for a StatusItem widget to display status and metrics for a BEC service.
|
||||
The widget is bound to be used with the BECStatusBox widget."""
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from qtpy.QtCore import Qt, Slot
|
||||
from qtpy.QtWidgets import QDialog, QHBoxLayout, QLabel, QStyle, QVBoxLayout, QWidget
|
||||
|
||||
# TODO : Put normal imports back when Pydantic gets faster
|
||||
BECStatus = lazy_import_from("bec_lib.messages", ("BECStatus",))
|
||||
|
||||
|
||||
class IconsEnum(enum.Enum):
|
||||
"""Enum class for icons in the status item widget."""
|
||||
|
||||
RUNNING = "SP_DialogApplyButton"
|
||||
BUSY = "SP_BrowserReload"
|
||||
IDLE = "SP_MessageBoxWarning"
|
||||
ERROR = "SP_DialogCancelButton"
|
||||
NOTCONNECTED = "SP_TitleBarContextHelpButton"
|
||||
|
||||
|
||||
class StatusItem(QWidget):
|
||||
"""A widget to display the status of a service.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
config (dict): The configuration for the service, must be a BECServiceInfoContainer.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget = None, config=None):
|
||||
QWidget.__init__(self, parent=parent)
|
||||
if config is None:
|
||||
# needed because we need parent to be the first argument for QT Designer
|
||||
raise ValueError(
|
||||
"Please initialize the StatusItem with a BECServiceInfoContainer for config, received None."
|
||||
)
|
||||
self.config = config
|
||||
self.parent = parent
|
||||
self.layout = None
|
||||
self._label = None
|
||||
self._icon = None
|
||||
self.icon_size = (24, 24)
|
||||
|
||||
self._popup_label_ref = {}
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self) -> None:
|
||||
"""Init the UI for the status item widget."""
|
||||
self.layout = QHBoxLayout()
|
||||
self.layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.setLayout(self.layout)
|
||||
self._label = QLabel()
|
||||
self._icon = QLabel()
|
||||
self.layout.addWidget(self._label)
|
||||
self.layout.addWidget(self._icon)
|
||||
self.update_ui()
|
||||
|
||||
@Slot(dict)
|
||||
def update_config(self, config) -> None:
|
||||
"""Update the config of the status item widget.
|
||||
|
||||
Args:
|
||||
config (dict): Config updates from parent widget, must be a BECServiceInfoContainer.
|
||||
"""
|
||||
if self.config is None or config.service_name != self.config.service_name:
|
||||
return
|
||||
self.config = config
|
||||
self.update_ui()
|
||||
|
||||
def update_ui(self) -> None:
|
||||
"""Update the UI of the labels, and popup dialog."""
|
||||
if self.config is None:
|
||||
return
|
||||
self.set_text()
|
||||
self.set_status()
|
||||
self._set_popup_text()
|
||||
|
||||
def set_text(self) -> None:
|
||||
"""Set the text of the QLabel basae on the config."""
|
||||
service = self.config.service_name
|
||||
status = self.config.status
|
||||
if len(service.split("/")) > 1 and service.split("/")[0].startswith("BEC"):
|
||||
service = service.split("/", maxsplit=1)[0] + "/..." + service.split("/")[1][-4:]
|
||||
if status == "NOTCONNECTED":
|
||||
status = "NOT CONNECTED"
|
||||
text = f"{service} is {status}"
|
||||
self._label.setText(text)
|
||||
|
||||
def set_status(self) -> None:
|
||||
"""Set the status icon for the status item widget."""
|
||||
icon_name = IconsEnum[self.config.status].value
|
||||
icon = self.style().standardIcon(getattr(QStyle.StandardPixmap, icon_name))
|
||||
self._icon.setPixmap(icon.pixmap(*self.icon_size))
|
||||
self._icon.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
def show_popup(self) -> None:
|
||||
"""Method that is invoked when the user double clicks on the StatusItem widget."""
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle(f"{self.config.service_name} Details")
|
||||
layout = QVBoxLayout()
|
||||
popup_label = self._make_popup_label()
|
||||
self._set_popup_text()
|
||||
layout.addWidget(popup_label)
|
||||
dialog.setLayout(layout)
|
||||
dialog.finished.connect(self._cleanup_popup_label)
|
||||
dialog.exec()
|
||||
|
||||
def _make_popup_label(self) -> QLabel:
|
||||
"""Create a QLabel for the popup dialog.
|
||||
|
||||
Returns:
|
||||
QLabel: The label for the popup dialog.
|
||||
"""
|
||||
label = QLabel()
|
||||
label.setWordWrap(True)
|
||||
self._popup_label_ref.update({"label": label})
|
||||
return label
|
||||
|
||||
def _set_popup_text(self) -> None:
|
||||
"""Compile the metrics text for the status item widget."""
|
||||
if self._popup_label_ref.get("label") is None:
|
||||
return
|
||||
metrics_text = (
|
||||
f"<b>SERVICE:</b> {self.config.service_name}<br><b>STATUS:</b> {self.config.status}<br>"
|
||||
)
|
||||
if self.config.metrics:
|
||||
for key, value in self.config.metrics.items():
|
||||
if key == "create_time":
|
||||
value = datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
|
||||
metrics_text += f"<b>{key.upper()}:</b> {value}<br>"
|
||||
self._popup_label_ref["label"].setText(metrics_text)
|
||||
|
||||
def _cleanup_popup_label(self) -> None:
|
||||
"""Cleanup the popup label."""
|
||||
self._popup_label_ref.clear()
|
||||
1
bec_widgets/widgets/buttons/__init__.py
Normal file
1
bec_widgets/widgets/buttons/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .stop_button.stop_button import StopButton
|
||||
BIN
bec_widgets/widgets/buttons/color_button/assets/color_button.png
Normal file
BIN
bec_widgets/widgets/buttons/color_button/assets/color_button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
17
bec_widgets/widgets/buttons/color_button/color_button.py
Normal file
17
bec_widgets/widgets/buttons/color_button/color_button.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import pyqtgraph as pg
|
||||
|
||||
|
||||
class ColorButton(pg.ColorButton):
|
||||
"""
|
||||
A ColorButton that opens a dialog to select a color. Inherits from pyqtgraph.ColorButton.
|
||||
Patches event loop of the ColorDialog, if opened in another QDialog.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def selectColor(self):
|
||||
self.origColor = self.color()
|
||||
self.colorDialog.setCurrentColor(self.color())
|
||||
self.colorDialog.open()
|
||||
self.colorDialog.exec()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['color_button.py']}
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.buttons.color_button.color_button import ColorButton
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ColorButton' name='color_button'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class ColorButtonPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = ColorButton(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Buttons"
|
||||
|
||||
def icon(self):
|
||||
current_path = os.path.dirname(__file__)
|
||||
icon_path = os.path.join(current_path, "assets", "color_button.png")
|
||||
return QIcon(icon_path)
|
||||
|
||||
def includeFile(self):
|
||||
return "color_button"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "ColorButton"
|
||||
|
||||
def toolTip(self):
|
||||
return "ColorButton which opens a color dialog."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.buttons.color_button.color_button_plugin import ColorButtonPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ColorButtonPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
0
bec_widgets/widgets/buttons/stop_button/__init__.py
Normal file
0
bec_widgets/widgets/buttons/stop_button/__init__.py
Normal file
32
bec_widgets/widgets/buttons/stop_button/stop_button.py
Normal file
32
bec_widgets/widgets/buttons/stop_button/stop_button.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
|
||||
class StopButton(BECConnector, QPushButton):
|
||||
"""A button that stops the current scan."""
|
||||
|
||||
def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QPushButton.__init__(self, parent=parent)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
self.setText("Stop")
|
||||
self.setStyleSheet("background-color: #cc181e; color: white")
|
||||
self.clicked.connect(self.stop_scan)
|
||||
|
||||
def stop_scan(self):
|
||||
"""Stop the scan."""
|
||||
self.queue.request_scan_abortion()
|
||||
self.queue.request_queue_reset()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = StopButton()
|
||||
widget.show()
|
||||
sys.exit(app.exec_())
|
||||
496
bec_widgets/widgets/console/console.py
Normal file
496
bec_widgets/widgets/console/console.py
Normal file
@@ -0,0 +1,496 @@
|
||||
"""
|
||||
BECConsole is a Qt widget that runs a Bash shell. The widget can be used and
|
||||
embedded like any other Qt widget.
|
||||
|
||||
BECConsole is powered by Pyte, a Python based terminal emulator
|
||||
(https://github.com/selectel/pyte).
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import html
|
||||
import os
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import pyte
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import QSize, QSocketNotifier, Qt
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
from qtpy.QtCore import Slot as pyqtSlot
|
||||
from qtpy.QtGui import QClipboard, QTextCursor
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
|
||||
|
||||
ansi_colors = {
|
||||
"black": "#000000",
|
||||
"red": "#CD0000",
|
||||
"green": "#00CD00",
|
||||
"brown": "#996633", # Brown, replacing the yellow
|
||||
"blue": "#0000EE",
|
||||
"magenta": "#CD00CD",
|
||||
"cyan": "#00CDCD",
|
||||
"white": "#E5E5E5",
|
||||
"brightblack": "#7F7F7F",
|
||||
"brightred": "#FF0000",
|
||||
"brightgreen": "#00FF00",
|
||||
"brightyellow": "#FFFF00",
|
||||
"brightblue": "#5C5CFF",
|
||||
"brightmagenta": "#FF00FF",
|
||||
"brightcyan": "#00FFFF",
|
||||
"brightwhite": "#FFFFFF",
|
||||
}
|
||||
|
||||
control_keys_mapping = {
|
||||
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
|
||||
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
|
||||
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
|
||||
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
|
||||
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
|
||||
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
|
||||
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
|
||||
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
|
||||
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
|
||||
QtCore.Qt.Key_J: b"\x0A", # Ctrl-J (Line Feed)
|
||||
QtCore.Qt.Key_K: b"\x0B", # Ctrl-K (Vertical Tab)
|
||||
QtCore.Qt.Key_L: b"\x0C", # Ctrl-L (Form Feed)
|
||||
QtCore.Qt.Key_M: b"\x0D", # Ctrl-M (Carriage Return)
|
||||
QtCore.Qt.Key_N: b"\x0E", # Ctrl-N
|
||||
QtCore.Qt.Key_O: b"\x0F", # Ctrl-O
|
||||
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
|
||||
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
|
||||
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
|
||||
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
|
||||
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
|
||||
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
|
||||
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
|
||||
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
|
||||
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
|
||||
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
|
||||
QtCore.Qt.Key_Z: b"\x1A", # Ctrl-Z
|
||||
QtCore.Qt.Key_Escape: b"\x1B", # Ctrl-Escape
|
||||
QtCore.Qt.Key_Backslash: b"\x1C", # Ctrl-\
|
||||
QtCore.Qt.Key_Underscore: b"\x1F", # Ctrl-_
|
||||
}
|
||||
|
||||
normal_keys_mapping = {
|
||||
QtCore.Qt.Key_Return: b"\n",
|
||||
QtCore.Qt.Key_Space: b" ",
|
||||
QtCore.Qt.Key_Enter: b"\n",
|
||||
QtCore.Qt.Key_Tab: b"\t",
|
||||
QtCore.Qt.Key_Backspace: b"\x08",
|
||||
QtCore.Qt.Key_Home: b"\x47",
|
||||
QtCore.Qt.Key_End: b"\x4f",
|
||||
QtCore.Qt.Key_Left: b"\x02",
|
||||
QtCore.Qt.Key_Up: b"\x10",
|
||||
QtCore.Qt.Key_Right: b"\x06",
|
||||
QtCore.Qt.Key_Down: b"\x0E",
|
||||
QtCore.Qt.Key_PageUp: b"\x49",
|
||||
QtCore.Qt.Key_PageDown: b"\x51",
|
||||
QtCore.Qt.Key_F1: b"\x1b\x31",
|
||||
QtCore.Qt.Key_F2: b"\x1b\x32",
|
||||
QtCore.Qt.Key_F3: b"\x1b\x33",
|
||||
QtCore.Qt.Key_F4: b"\x1b\x34",
|
||||
QtCore.Qt.Key_F5: b"\x1b\x35",
|
||||
QtCore.Qt.Key_F6: b"\x1b\x36",
|
||||
QtCore.Qt.Key_F7: b"\x1b\x37",
|
||||
QtCore.Qt.Key_F8: b"\x1b\x38",
|
||||
QtCore.Qt.Key_F9: b"\x1b\x39",
|
||||
QtCore.Qt.Key_F10: b"\x1b\x30",
|
||||
QtCore.Qt.Key_F11: b"\x45",
|
||||
QtCore.Qt.Key_F12: b"\x46",
|
||||
}
|
||||
|
||||
|
||||
def QtKeyToAscii(event):
|
||||
"""
|
||||
Convert the Qt key event to the corresponding ASCII sequence for
|
||||
the terminal. This works fine for standard alphanumerical characters, but
|
||||
most other characters require terminal specific control sequences.
|
||||
|
||||
The conversion below works for TERM="linux" terminals.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
# special case for MacOS
|
||||
# /!\ Qt maps ControlModifier to CMD
|
||||
# CMD-C, CMD-V for copy/paste
|
||||
# CTRL-C and other modifiers -> key mapping
|
||||
if event.modifiers() == QtCore.Qt.MetaModifier:
|
||||
if event.key() == Qt.Key_Backspace:
|
||||
return control_keys_mapping.get(Qt.Key_W)
|
||||
return control_keys_mapping.get(event.key())
|
||||
elif event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
if event.key() == Qt.Key_C:
|
||||
# copy
|
||||
return "copy"
|
||||
elif event.key() == Qt.Key_V:
|
||||
# paste
|
||||
return "paste"
|
||||
return None
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
if event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
return control_keys_mapping.get(event.key())
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
|
||||
|
||||
class Screen(pyte.HistoryScreen):
|
||||
def __init__(self, stdin_fd, numColumns, numLines, historyLength):
|
||||
super().__init__(numColumns, numLines, historyLength, ratio=1 / numLines)
|
||||
self._fd = stdin_fd
|
||||
|
||||
def write_process_input(self, data):
|
||||
"""Response to CPR request for example"""
|
||||
os.write(self._fd, data.encode("utf-8"))
|
||||
|
||||
|
||||
class Backend(QtCore.QObject):
|
||||
"""
|
||||
Poll Bash.
|
||||
|
||||
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
|
||||
file descriptor of the Bash terminal.
|
||||
"""
|
||||
|
||||
# Signals to communicate with ``_TerminalWidget``.
|
||||
startWork = pyqtSignal()
|
||||
dataReady = pyqtSignal(object)
|
||||
|
||||
def __init__(self, fd, numColumns, numLines):
|
||||
super().__init__()
|
||||
|
||||
# File descriptor that connects to Bash process.
|
||||
self.fd = fd
|
||||
|
||||
# Setup Pyte (hard coded display size for now).
|
||||
self.screen = Screen(self.fd, numColumns, numLines, 10000)
|
||||
self.stream = pyte.ByteStream()
|
||||
self.stream.attach(self.screen)
|
||||
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._fd_readable)
|
||||
|
||||
def _fd_readable(self):
|
||||
"""
|
||||
Poll the Bash output, run it through Pyte, and notify the main applet.
|
||||
"""
|
||||
# Read the shell output until the file descriptor is closed.
|
||||
try:
|
||||
out = os.read(self.fd, 2**16)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
# Feed output into Pyte's state machine and send the new screen
|
||||
# output to the GUI
|
||||
self.stream.feed(out)
|
||||
self.dataReady.emit(self.screen)
|
||||
|
||||
|
||||
class BECConsole(QtWidgets.QScrollArea):
|
||||
"""Container widget for the terminal text area"""
|
||||
|
||||
def __init__(self, parent=None, numLines=50, numColumns=125):
|
||||
super().__init__(parent)
|
||||
|
||||
self.innerWidget = QtWidgets.QWidget(self)
|
||||
QHBoxLayout(self.innerWidget)
|
||||
self.innerWidget.layout().setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.term = _TerminalWidget(self.innerWidget, numLines, numColumns)
|
||||
self.term.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.innerWidget.layout().addWidget(self.term)
|
||||
|
||||
self.scroll_bar = QScrollBar(Qt.Vertical, self.term)
|
||||
self.innerWidget.layout().addWidget(self.scroll_bar)
|
||||
|
||||
self.term.set_scroll(self.scroll_bar)
|
||||
|
||||
self.setWidget(self.innerWidget)
|
||||
|
||||
def start(self, cmd=["bec", "--nogui"], deactivate_ctrl_d=True):
|
||||
self.term._cmd = cmd
|
||||
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
|
||||
|
||||
def push(self, text):
|
||||
"""Push some text to the terminal"""
|
||||
return self.term.push(text)
|
||||
|
||||
|
||||
class _TerminalWidget(QtWidgets.QPlainTextEdit):
|
||||
"""
|
||||
Start ``Backend`` process and render Pyte output as text.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, numColumns, numLines, **kwargs):
|
||||
super().__init__(parent)
|
||||
|
||||
# file descriptor to communicate with the subprocess
|
||||
self.fd = None
|
||||
self.backend = None
|
||||
self.lock = threading.Lock()
|
||||
# command to execute
|
||||
self._cmd = None
|
||||
# should ctrl-d be deactivated ? (prevent Python exit)
|
||||
self._deactivate_ctrl_d = False
|
||||
|
||||
# Specify the terminal size in terms of lines and columns.
|
||||
self.numLines = numLines
|
||||
self.numColumns = numColumns
|
||||
self.output = [""] * numLines
|
||||
|
||||
# Use Monospace fonts and disable line wrapping.
|
||||
self.setFont(QtGui.QFont("Courier", 9))
|
||||
self.setFont(QtGui.QFont("Monospace"))
|
||||
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
||||
|
||||
# Disable vertical scrollbar (we use our own, to be set via .set_scroll())
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
self._char_width = fmt.width("w")
|
||||
self._char_height = fmt.height()
|
||||
self.setCursorWidth(self._char_width)
|
||||
# self.setStyleSheet("QPlainTextEdit { color: #ffff00; background-color: #303030; } ");
|
||||
|
||||
def start(self, deactivate_ctrl_d=False):
|
||||
self._deactivate_ctrl_d = deactivate_ctrl_d
|
||||
|
||||
# Start the Bash process
|
||||
self.fd = self.forkShell()
|
||||
|
||||
# Create the ``Backend`` object
|
||||
self.backend = Backend(self.fd, self.numColumns, self.numLines)
|
||||
self.backend.dataReady.connect(self.dataReady)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
width = self._char_width * self.numColumns
|
||||
height = self._char_height * self.numLines
|
||||
return QSize(width, height + 20)
|
||||
|
||||
def set_scroll(self, scroll):
|
||||
self.scroll = scroll
|
||||
self.scroll.setMinimum(0)
|
||||
self.scroll.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
def scroll_value_change(self, value, old={"value": 0}):
|
||||
if value <= old["value"]:
|
||||
# scroll up
|
||||
# value is number of lines from the start
|
||||
nlines = old["value"] - value
|
||||
# history ratio gives prev_page == 1 line
|
||||
for i in range(nlines):
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
# scroll down
|
||||
nlines = value - old["value"]
|
||||
for i in range(nlines):
|
||||
self.backend.screen.next_page()
|
||||
old["value"] = value
|
||||
self.dataReady(self.backend.screen, reset_scroll=False)
|
||||
|
||||
@pyqtSlot(object)
|
||||
def keyPressEvent(self, event):
|
||||
"""
|
||||
Redirect all keystrokes to the terminal process.
|
||||
"""
|
||||
# Convert the Qt key to the correct ASCII code.
|
||||
if (
|
||||
self._deactivate_ctrl_d
|
||||
and event.modifiers() == QtCore.Qt.ControlModifier
|
||||
and event.key() == QtCore.Qt.Key_D
|
||||
):
|
||||
return None
|
||||
|
||||
code = QtKeyToAscii(event)
|
||||
if code == "copy":
|
||||
# MacOS only: CMD-C handling
|
||||
self.copy()
|
||||
elif code == "paste":
|
||||
# MacOS only: CMD-V handling
|
||||
self._push_clipboard()
|
||||
elif code is not None:
|
||||
os.write(self.fd, code)
|
||||
|
||||
def push(self, text):
|
||||
"""
|
||||
Write 'text' to terminal
|
||||
"""
|
||||
os.write(self.fd, text.encode("utf-8"))
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
menu = self.createStandardContextMenu()
|
||||
for action in menu.actions():
|
||||
# remove all actions except copy and paste
|
||||
if "opy" in action.text():
|
||||
# redefine text without shortcut
|
||||
# since it probably clashes with control codes (like CTRL-C etc)
|
||||
action.setText("Copy")
|
||||
continue
|
||||
if "aste" in action.text():
|
||||
# redefine text without shortcut
|
||||
action.setText("Paste")
|
||||
# paste -> have to insert with self.push
|
||||
action.triggered.connect(self._push_clipboard)
|
||||
continue
|
||||
menu.removeAction(action)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def _push_clipboard(self):
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
self.push(clipboard.text())
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MiddleButton:
|
||||
# push primary selection buffer ("mouse clipboard") to terminal
|
||||
clipboard = QApplication.instance().clipboard()
|
||||
if clipboard.supportsSelection():
|
||||
self.push(clipboard.text(QClipboard.Selection))
|
||||
return None
|
||||
elif event.button() == Qt.LeftButton:
|
||||
# left button click
|
||||
textCursor = self.textCursor()
|
||||
if textCursor.selectedText():
|
||||
# mouse was used to select text -> nothing to do
|
||||
pass
|
||||
else:
|
||||
# a simple 'click', make cursor going to end
|
||||
textCursor.setPosition(0)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
|
||||
)
|
||||
textCursor.movePosition(
|
||||
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
|
||||
)
|
||||
self.setTextCursor(textCursor)
|
||||
self.ensureCursorVisible()
|
||||
return None
|
||||
return super().mouseReleaseEvent(event)
|
||||
|
||||
def dataReady(self, screenData, reset_scroll=True):
|
||||
"""
|
||||
Render the new screen as text into the widget.
|
||||
|
||||
This method is triggered via a signal from ``Backend``.
|
||||
"""
|
||||
with self.lock:
|
||||
# Clear the widget
|
||||
self.clear()
|
||||
|
||||
# Prepare the HTML output
|
||||
for line_no in screenData.dirty:
|
||||
line = text = ""
|
||||
style = old_style = ""
|
||||
for ch in screenData.buffer[line_no].values():
|
||||
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
|
||||
if style != old_style:
|
||||
if old_style:
|
||||
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
text = ""
|
||||
old_style = style
|
||||
text += ch.data
|
||||
if style:
|
||||
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
self.output[line_no] = line
|
||||
# fill the text area with HTML contents in one go
|
||||
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
|
||||
# done updates, all clean
|
||||
screenData.dirty.clear()
|
||||
|
||||
# Activate cursor
|
||||
textCursor = self.textCursor()
|
||||
textCursor.setPosition(0)
|
||||
textCursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, screenData.cursor.y)
|
||||
textCursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, screenData.cursor.x)
|
||||
self.setTextCursor(textCursor)
|
||||
self.ensureCursorVisible()
|
||||
|
||||
# manage scroll
|
||||
if reset_scroll:
|
||||
self.scroll.valueChanged.disconnect(self.scroll_value_change)
|
||||
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
|
||||
self.scroll.setMaximum(tmp if tmp > 0 else 0)
|
||||
self.scroll.setSliderPosition(len(self.backend.screen.history.top))
|
||||
self.scroll.valueChanged.connect(self.scroll_value_change)
|
||||
|
||||
# def resizeEvent(self, event):
|
||||
# with self.lock:
|
||||
# self.numColumns = int(self.width() / self._char_width)
|
||||
# self.numLines = int(self.height() / self._char_height)
|
||||
# self.output = [""] * self.numLines
|
||||
# print("RESIZING TO", self.numColumns, "x", self.numLines)
|
||||
# self.backend.screen.resize(self.numLines, self.numColumns)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
y = event.angleDelta().y()
|
||||
if y > 0:
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
self.backend.screen.next_page()
|
||||
self.dataReady(self.backend.screen, reset_scroll=False)
|
||||
|
||||
def forkShell(self):
|
||||
"""
|
||||
Fork the current process and execute bec in shell.
|
||||
"""
|
||||
try:
|
||||
pid, fd = pty.fork()
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
if pid == 0:
|
||||
# Safe way to make it work under BSD and Linux
|
||||
try:
|
||||
ls = os.environ["LANG"].split(".")
|
||||
except KeyError:
|
||||
ls = []
|
||||
if len(ls) < 2:
|
||||
ls = ["en_US", "UTF-8"]
|
||||
try:
|
||||
os.putenv("COLUMNS", str(self.numColumns))
|
||||
os.putenv("LINES", str(self.numLines))
|
||||
os.putenv("TERM", "linux")
|
||||
os.putenv("LANG", ls[0] + ".UTF-8")
|
||||
if isinstance(self._cmd, str):
|
||||
os.execvp(self._cmd, self._cmd)
|
||||
else:
|
||||
os.execvp(self._cmd[0], self._cmd)
|
||||
# print "child_pid", child_pid, sts
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
# self.proc_finish(sid)
|
||||
os._exit(0)
|
||||
else:
|
||||
# We are in the parent process.
|
||||
# Set file control
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
||||
print("Spawned Bash shell (PID {})".format(pid))
|
||||
return fd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
|
||||
# Terminal size in characters.
|
||||
numLines = 25
|
||||
numColumns = 100
|
||||
|
||||
# Create the Qt application and QBash instance.
|
||||
app = QtWidgets.QApplication([])
|
||||
mainwin = QtWidgets.QMainWindow()
|
||||
title = "BECConsole ({}x{})".format(numColumns, numLines)
|
||||
mainwin.setWindowTitle(title)
|
||||
|
||||
console = BECConsole(mainwin, numColumns, numLines)
|
||||
mainwin.setCentralWidget(console)
|
||||
console.start()
|
||||
|
||||
# Show widget and launch Qt's event loop.
|
||||
mainwin.show()
|
||||
sys.exit(app.exec_())
|
||||
2
bec_widgets/widgets/device_inputs/__init__.py
Normal file
2
bec_widgets/widgets/device_inputs/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .device_combobox.device_combobox import DeviceComboBox
|
||||
from .device_line_edit.device_line_edit import DeviceLineEdit
|
||||
@@ -0,0 +1,92 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
|
||||
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputBase, DeviceInputConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.device_inputs.device_input_base import DeviceInputConfig
|
||||
|
||||
|
||||
class DeviceComboBox(DeviceInputBase, QComboBox):
|
||||
"""
|
||||
Line edit widget for device input with autocomplete for device names.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
client: BEC client object.
|
||||
config: Device input configuration.
|
||||
gui_id: GUI ID.
|
||||
device_filter: Device filter, name of the device class.
|
||||
default: Default device name.
|
||||
arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceInputConfig = None,
|
||||
gui_id: str | None = None,
|
||||
device_filter: str | None = None,
|
||||
default: str | None = None,
|
||||
arg_name: str | None = None,
|
||||
):
|
||||
super().__init__(client=client, config=config, gui_id=gui_id)
|
||||
QComboBox.__init__(self, parent=parent)
|
||||
|
||||
self.populate_combobox()
|
||||
|
||||
if arg_name is not None:
|
||||
self.config.arg_name = arg_name
|
||||
if device_filter is not None:
|
||||
self.set_device_filter(device_filter)
|
||||
if default is not None:
|
||||
self.set_default_device(default)
|
||||
|
||||
def set_device_filter(self, device_filter: str):
|
||||
"""
|
||||
Set the device filter.
|
||||
|
||||
Args:
|
||||
device_filter(str): Device filter, name of the device class.
|
||||
"""
|
||||
super().set_device_filter(device_filter)
|
||||
self.populate_combobox()
|
||||
|
||||
def set_default_device(self, default_device: str):
|
||||
"""
|
||||
Set the default device.
|
||||
|
||||
Args:
|
||||
default_device(str): Default device name.
|
||||
"""
|
||||
super().set_default_device(default_device)
|
||||
self.setCurrentText(default_device)
|
||||
|
||||
def populate_combobox(self):
|
||||
"""Populate the combobox with the devices."""
|
||||
self.devices = self.get_device_list(self.config.device_filter)
|
||||
self.clear()
|
||||
self.addItems(self.devices)
|
||||
|
||||
def get_device(self) -> object:
|
||||
"""
|
||||
Get the selected device object.
|
||||
|
||||
Returns:
|
||||
object: Device object.
|
||||
"""
|
||||
device_name = self.currentText()
|
||||
device_obj = getattr(self.dev, device_name.lower(), None)
|
||||
if device_obj is None:
|
||||
raise ValueError(f"Device {device_name} is not found.")
|
||||
return device_obj
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
super().cleanup()
|
||||
|
||||
def closeEvent(self, event):
|
||||
super().cleanup()
|
||||
return QComboBox.closeEvent(self, event)
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": ["device_combobox.py", "launch_device_combobox.py",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
from bec_widgets.widgets.device_inputs import DeviceComboBox
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='DeviceComboBox' name='device_combobox'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class DeviceComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = DeviceComboBox(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return QIcon()
|
||||
|
||||
def includeFile(self):
|
||||
return "device_combobox"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "DeviceComboBox"
|
||||
|
||||
def toolTip(self):
|
||||
return "Device ComboBox Example for BEC Widgets"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,11 @@
|
||||
from bec_widgets.widgets.device_inputs import DeviceComboBox
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = DeviceComboBox()
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user