Compare commits
1253 Commits
v0.95.1
...
feat/gradi
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c76509e7 | |||
| 644e4ecae0 | |||
| 5c9c28141f | |||
| 73528eef18 | |||
| c2780d629c | |||
| d090f8f7e5 | |||
| f9c03f1daa | |||
| 78485d3df0 | |||
| dc30e6b022 | |||
| fd2b8918f5 | |||
| 0f392efdce | |||
| e708ee56f4 | |||
| e74eea199e | |||
| 1b299b9334 | |||
| aed22c605b | |||
| 557371f3ba | |||
| 31d01abe18 | |||
| 86a966f10f | |||
| a9039c5ee6 | |||
| 0119332bcf | |||
| 22651f1c58 | |||
| bd2d47f18f | |||
| 00e4786651 | |||
| 6c6408e87f | |||
| d259de227d | |||
| 28c0b3b18b | |||
| 003fff7e4b | |||
| b9643691b9 | |||
| 652bf2d00b | |||
| a20d8b269d | |||
| 1ab915468b | |||
| 578be47880 | |||
| 5b364c5aa5 | |||
| 38134ec849 | |||
| 937bea5103 | |||
| 0ba14b2dbb | |||
| 7f488c09ef | |||
| 8776784507 | |||
| 79d64c43d5 | |||
| fa5135d2d7 | |||
| e1e0fbd390 | |||
| 4284d572ec | |||
| 96e7ca34ab | |||
| 0d0eb1d8ee | |||
| da1a3ddfc4 | |||
| 7cd4f448b5 | |||
| 752d9ba575 | |||
| 7a43111de0 | |||
| b43abfafcd | |||
| 4dc128c64a | |||
| cb233b877d | |||
| 16d88fe8df | |||
| 6b5aa74934 | |||
| 140f126560 | |||
| a57d3c6e1c | |||
| bb363d8280 | |||
| b81e92b3ee | |||
| bc77d3199e | |||
| 3522caba67 | |||
| 979de3519b | |||
| 4e38365d00 | |||
| caaa9e5d7f | |||
| c97c50ffdb | |||
| cbf9d21b7d | |||
| 8e66bae6ec | |||
| b2af9c421c | |||
| de5c2e37ed | |||
| 60c207f1fe | |||
| c609a2c9cb | |||
| 8b9295356d | |||
| e52afd569f | |||
| 91e8a72e07 | |||
| bd9db4f20e | |||
| fcd7b3d642 | |||
| 718f4564ab | |||
| 657648c833 | |||
| 99007d8cf5 | |||
| 016e1af79e | |||
| 1b7f9a3323 | |||
| 12a454f762 | |||
| d8900a579f | |||
| ca9007a6e2 | |||
| 421e6d38e4 | |||
| fb2dfff4f3 | |||
| 0cad33cbd3 | |||
| 9cd4e10408 | |||
| f7f37509b6 | |||
| 7e3de2c64e | |||
| bd9be10aaf | |||
| 5fde098974 | |||
| 97fddf0196 | |||
| 9f755997e2 | |||
| cac1066861 | |||
| 5281d380cb | |||
| 24c213a705 | |||
| f41ea592bb | |||
| a4e02460ad | |||
| a998793d00 | |||
| be812322b0 | |||
| 7676e863b2 | |||
| d467ecc5e0 | |||
| 72c86b9d6c | |||
| 4ada3085a9 | |||
| 42fc7c8568 | |||
| 7f77b9b9f4 | |||
| 03441e93d0 | |||
| 73731c8142 | |||
| 17bccb8211 | |||
| 7935b9b820 | |||
| 5895147c00 | |||
| 6f78c914b7 | |||
| c20bc00f45 | |||
| 67d50d7b68 | |||
| 3a78c1b177 | |||
| 66a29d1f07 | |||
| c72789c8fd | |||
| fa642aaf49 | |||
| 2aef05056c | |||
| e0d65b42f4 | |||
| 8c226b66c6 | |||
| 39809e88b8 | |||
| 3dba8321f2 | |||
| 0daeb389ef | |||
| bdd732b236 | |||
| d21464f98f | |||
| b02994558b | |||
| b2d6b8dac0 | |||
| 3e23ba9aa2 | |||
| daeec7e0c9 | |||
| 03fe3a83c4 | |||
| 43d49276ce | |||
| b6f8866667 | |||
| dcdf5a4a54 | |||
| 125c94cbbe | |||
| 138fd1bea7 | |||
| 63b7f92f22 | |||
| 66f01dc9a6 | |||
| d4ea9f2992 | |||
| 9b9b8ab718 | |||
| 6b1b7a6b56 | |||
| 4e96d6c53c | |||
| cbcf23965b | |||
| 24f54fc252 | |||
| 6643dc2b57 | |||
| a58fb3fa53 | |||
| 5ecdaf4121 | |||
| 77295f13f7 | |||
| faa0aa659d | |||
| 00c0232833 | |||
| 8f359f8d37 | |||
| 5a49c41a52 | |||
| e4fb126fad | |||
| bf123550e1 | |||
|
|
741ca2fd8a | ||
| 3941050883 | |||
|
|
1d746c6829 | ||
| ef27de40ce | |||
| 37df95ead8 | |||
| c87a6cfce9 | |||
| 3d807eaa63 | |||
| 28ac9c5cc3 | |||
| 1dd20d5986 | |||
|
|
13299aeeb3 | ||
| d681ba538b | |||
| 2bf489600e | |||
| 7e88a002b6 | |||
| 20a59af648 | |||
| 540cfc37be | |||
| e59f27a22d | |||
| df8065ea40 | |||
| 2f3dc2ce6b | |||
| a006f95f21 | |||
| 8111a4a21b | |||
| 962ab774e6 | |||
| 2f798be7b0 | |||
| 5a5d32312b | |||
| 0844a9e119 | |||
| db7dd4f8d4 | |||
| f083dff612 | |||
| 4be70580a6 | |||
| d19001c94e | |||
| f25f86522f | |||
|
|
948283bc13 | ||
| 50696bce4c | |||
|
|
1d988a4c57 | ||
| 565c0bd1e7 | |||
| 975404f483 | |||
|
|
165e5e7d84 | ||
| 108ddae6ca | |||
|
|
9737acad58 | ||
| 65bc5f5421 | |||
| 475ca9f2d8 | |||
| bbb5fc6ce1 | |||
| b1b6c5e6a5 | |||
| 3e339348dd | |||
|
|
4f075151d5 | ||
| 0a24ac2c40 | |||
| 3a2ec9f1b7 | |||
| 4dc4ede1d2 | |||
| 556832fd48 | |||
| 72b6f74252 | |||
| b703b37bbd | |||
| 18ef35f22a | |||
| fe67a4f325 | |||
|
|
f1c3d77a45 | ||
| ad7cdc60dd | |||
|
|
ba047fd776 | ||
| 6e05157abb | |||
|
|
f4bc759e72 | ||
| 1bec9bd9b2 | |||
|
|
8b013d5dce | ||
| f2e5a85e61 | |||
|
|
a2f8880459 | ||
| 926d722955 | |||
| 44ba7201b4 | |||
|
|
0717426db2 | ||
| f4af6ebc5f | |||
| a923f12c97 | |||
| a5a7607a83 | |||
| 9de548446b | |||
| 49ac7decf7 | |||
|
|
092bed38fa | ||
| 50c84a766a | |||
| d22a3317ba | |||
| 6df1d0c31f | |||
| 946752a4b0 | |||
| c1f62ad6cb | |||
| a5adf3a97d | |||
|
|
76e3e0b60f | ||
| f18eeb9c5d | |||
| 32ce8e2818 | |||
| 23413cffab | |||
|
|
4bbb8fa519 | ||
|
|
a972369a72 | ||
| cd81e7f9ba | |||
|
|
e2b8118f67 | ||
| 5f925ba4e3 | |||
| fc68d2cf2d | |||
| 627b49b33a | |||
| a51ef04cdf | |||
| 40f4bce285 | |||
| 2b9fe6c959 | |||
| c2e16429c9 | |||
|
|
85ce2aa136 | ||
| fd5af01842 | |||
| 8a214c8978 | |||
|
|
f3214445f2 | ||
| 6bf84aea25 | |||
|
|
aace071f11 | ||
| bf86a030a0 | |||
|
|
358c979bf2 | ||
| c1bdc506e8 | |||
|
|
4febfb79df | ||
| 0854175acb | |||
| e090ac49b7 | |||
| e4521d9528 | |||
| 1d0490fff4 | |||
| 10cbb9a05c | |||
| 7073e75adf | |||
| e42ffd7c01 | |||
| 2bd6d00899 | |||
| c2a918ef4b | |||
| 6bbf5126cf | |||
| 728d4efd96 | |||
|
|
7926969996 | ||
| 61e5bde15f | |||
|
|
c8aa770de3 | ||
| 4d5df9608a | |||
| b718b438ba | |||
|
|
2f978c93c4 | ||
| b4e0664011 | |||
|
|
45fbf4015d | ||
|
|
0d81bdd4dd | ||
|
|
bb4c30ad80 | ||
| 3fd09fceef | |||
| 8eb8225a7f | |||
| 491d04467c | |||
|
|
3bcff75107 | ||
| 608590c542 | |||
|
|
012f7cf970 | ||
| cd17a4aad9 | |||
| f0dc992586 | |||
| fd1f9941e0 | |||
| 3384ca02bd | |||
| 959cedbbd5 | |||
| ca4f97503b | |||
| 22beadcad0 | |||
| b9af36a4f1 | |||
|
|
bdff736aa2 | ||
| 7cda2ed846 | |||
| cd9d22d0b4 | |||
|
|
37b80e16a0 | ||
| 7f0098f153 | |||
| 8489ef4a69 | |||
| 13976557fb | |||
|
|
06ad87ce0a | ||
| 00e3713181 | |||
|
|
62020f9965 | ||
| 2373c7e996 | |||
|
|
1f3566c105 | ||
| b8ae7b2e96 | |||
| 23674ccf59 | |||
| 1d8069e391 | |||
| 44cc06137c | |||
| 46a91784d2 | |||
| debd347b64 | |||
|
|
a13c3c44c8 | ||
| 25b2737aac | |||
| cf97cc1805 | |||
| 694a6c4960 | |||
| 9caae4cf40 | |||
| 2b06e34ecf | |||
| a9c8995ac0 | |||
|
|
1262c66fd6 | ||
| bde523806f | |||
|
|
16bca25d9c | ||
| 130cc24b35 | |||
| 8b2d6052e8 | |||
| 530797a556 | |||
| c660e5141f | |||
| 900153bc0b | |||
| 8dc72656ef | |||
| 170be0c7d3 | |||
| 1925e6ac7f | |||
|
|
b6cef2d27b | ||
| a9fce175b7 | |||
| 783d042e8c | |||
|
|
319a4206f2 | ||
| 76439866c1 | |||
|
|
ca600b057e | ||
| 6c494258f8 | |||
| 63a8da680d | |||
|
|
0f2bde1a0a | ||
| 0c76b0c495 | |||
| e594de3ca3 | |||
| adaad4f4d5 | |||
| 39c316d6ea | |||
| 3ba0fc4b44 | |||
| a6fc7993a3 | |||
| 324a5bd3d9 | |||
| 8929778f07 | |||
|
|
72b5c46912 | ||
| 244bca4e1e | |||
|
|
c50ace5818 | ||
| 25f28c47e3 | |||
| db720e8fa4 | |||
|
|
f10140e0f3 | ||
| 09c5a443aa | |||
| 3f5ab142a3 | |||
|
|
422d06d141 | ||
| 371bc485d0 | |||
|
|
70970ecf00 | ||
| 3d59c25aa9 | |||
|
|
70a06c5fd1 | ||
| 7ba8863d6a | |||
|
|
00ea8bb6c6 | ||
| e841468892 | |||
| 48a0e5831f | |||
| 1e9dd4cd25 | |||
| d10328cb5c | |||
|
|
6b248e93f5 | ||
| bc3085ab8c | |||
| 9cba696afd | |||
|
|
881b7a7e9d | ||
| 29a26b19f9 | |||
|
|
cba4d47f76 | ||
| 9f3dcc3ab3 | |||
| 57f75bd4d5 | |||
| 4456297beb | |||
|
|
ae26b43fb1 | ||
| 7484f5160c | |||
| 6421050116 | |||
|
|
5a137d1219 | ||
| d5a40dabc7 | |||
| f3da6e959e | |||
| 3a103410e7 | |||
| 3378051250 | |||
|
|
77db658f3d | ||
| 6e2f2cea91 | |||
| eea5f7ebbd | |||
| a9708f6d8f | |||
| b51de1a00e | |||
| 8e8acd672c | |||
| 4c2c0c5525 | |||
| 5a564a5f3f | |||
|
|
43ad207aa8 | ||
| a4274ff8cd | |||
| b2a46e284d | |||
| 9ff170660e | |||
| 6c04eac18c | |||
| aca6efb567 | |||
| 88b42e49e3 | |||
| d3a9e0903a | |||
| 3bbb8daa24 | |||
| e8ae9725fa | |||
| 497e394deb | |||
| d5ca7b8433 | |||
| b02c870dbf | |||
| 92d0ffee65 | |||
| c4b85381a4 | |||
| a451625a5a | |||
|
|
54dd0a9913 | ||
| 3146d98c57 | |||
| a3ffcefe80 | |||
|
|
1a7052073d | ||
| 235aabf307 | |||
|
|
c1cb69b0e8 | ||
| 11131ef14c | |||
| 5e4c129af6 | |||
| 4d8c07cdd1 | |||
| 8f4c8e45b3 | |||
| 5623547e92 | |||
| be73349c70 | |||
| 1a350c3b16 | |||
| 138d4cabbd | |||
| b0d03c0648 | |||
| a9613a07b0 | |||
| 886964bb54 | |||
| 7fc85bac7f | |||
| d626caae3d | |||
| dea2568de3 | |||
| a55f561971 | |||
| 9ce31c9833 | |||
|
|
95ce98c622 | ||
| 187bf493a5 | |||
| 1612933dd9 | |||
|
|
8c3d6334f6 | ||
| 30acc4c236 | |||
| 0dec78afba | |||
| 57b9a57a63 | |||
| 644be621f2 | |||
|
|
d07265b86d | ||
| f0d48a0508 | |||
| af8db0bede | |||
|
|
0ae4b652a4 | ||
| 32fd959e67 | |||
|
|
73b1886bb8 | ||
| 9f853b0864 | |||
|
|
18636e723a | ||
| 594185dde9 | |||
| 46d7e3f517 | |||
| f9044996f6 | |||
|
|
03474cf7f7 | ||
| 9ef418bf55 | |||
| b3ce68070d | |||
|
|
784b54af6e | ||
| 3740ac8e32 | |||
| edfac87868 | |||
| 271116453d | |||
| 12f5233745 | |||
|
|
392ddf9d1a | ||
| 85705383e4 | |||
|
|
224863569f | ||
| 3e2544e52a | |||
|
|
4d5daf6557 | ||
| 718116afc3 | |||
| 2dda58f7d2 | |||
| 594912136e | |||
| 5188b38c86 | |||
| a10e6f7820 | |||
| e0e26c205b | |||
| 92d1d6435d | |||
| a25c1a8039 | |||
|
|
fed068f857 | ||
| 7eb2f54e0e | |||
| 92b89e7275 | |||
| a4f3117941 | |||
| 3e789ca35b | |||
| 92dade0950 | |||
| 4a343b2041 | |||
| c2b0c8c433 | |||
| 8a299a8268 | |||
| 99ecf6a18f | |||
| 4c0bd977fc | |||
| 7c47505c5a | |||
| e211e4d716 | |||
| 10f292def9 | |||
|
|
d111ded737 | ||
| 2d0ed94f3f | |||
|
|
f68f072da3 | ||
| 1df6c1925b | |||
| 6b939ac34d | |||
|
|
6bcf20af07 | ||
| a64cf0dd87 | |||
| cd4e90a79f | |||
|
|
49a96a18d6 | ||
| 2b4454a291 | |||
| d12bd9fe1a | |||
| d0c1ac0cf5 | |||
| f90150d1c7 | |||
|
|
c684b6c230 | ||
| 91126168b6 | |||
| 7322cd194f | |||
| d9dc60ee99 | |||
|
|
e4cd4891ad | ||
| 12f8c82eb5 | |||
|
|
f46ffb14e1 | ||
| 2b9919bb34 | |||
| 822e7d06ff | |||
| 91195ae0fd | |||
| a6c5c21afa | |||
|
|
ff06954cb7 | ||
| c8128faf79 | |||
|
|
6b65a94c81 | ||
| bf172b8431 | |||
| 05329ab50f | |||
| b225a7cc90 | |||
|
|
3d8af05688 | ||
| 0bdd4e86a2 | |||
|
|
104e4e427b | ||
| ada0977a1b | |||
|
|
1ea467c5fc | ||
| 4f69f5da45 | |||
| d8547c7a56 | |||
| 3484507c75 | |||
| 8abebb7286 | |||
|
|
1d07e88b44 | ||
| 1a4eb1db67 | |||
| f57950c4e3 | |||
| a8811c9d91 | |||
| ec740d31fd | |||
|
|
5c12ab1992 | ||
| ce88787e88 | |||
| e12e9e534d | |||
| 66e9445760 | |||
|
|
6bf4c53805 | ||
| a939c3b1c4 | |||
| 41b7ca8e64 | |||
| 7a531c17d6 | |||
| a020f2dc7e | |||
| 53377d26e2 | |||
| 05489a1c56 | |||
|
|
0dfff71e4a | ||
| d4def09a4e | |||
|
|
713653a4a5 | ||
| bcab66b187 | |||
| a345253c6e | |||
|
|
bdf33a5249 | ||
| f8276f0224 | |||
| 8227c44c33 | |||
|
|
83098d930c | ||
| a7ae856c8f | |||
|
|
06f43e4883 | ||
|
|
5ec9697271 | ||
|
|
41296b5471 | ||
| 1d018e863c | |||
| 6ee0f5004d | |||
|
|
40b5081632 | ||
| f064baae68 | |||
|
|
58f01fb3a2 | ||
| 1e344eacb7 | |||
|
|
34002fa51a | ||
| a00d510a75 | |||
|
|
120faf9523 | ||
| d7bd61f69e | |||
| 94bcfff724 | |||
| a17e7a0d52 | |||
| 7f67d28887 | |||
| 52d8e4b332 | |||
| dea2b44e6a | |||
| dc70ea6dfb | |||
| 133ddda3e3 | |||
| 8eee92e5cf | |||
|
|
85de24aa89 | ||
| 56b6a0b8c2 | |||
| d579d894f0 | |||
| d915d2f507 | |||
| 7d7a88669f | |||
| a42dcec6d4 | |||
| 8cf1f09926 | |||
| 83b153a14a | |||
| aed450ef2c | |||
| e60d0cb5ca | |||
| 01870f9cda | |||
| 483886495d | |||
| 42502f6eed | |||
| 59d87e1c2f | |||
|
|
3a5fa3d01a | ||
| dbb3a1c1fb | |||
| ca8211572f | |||
| 7584af4e44 | |||
| 95ef26565b | |||
| abbf7a7f44 | |||
| a301d37c4f | |||
| 88a17a566c | |||
| bf3746da0e | |||
| e3205d6c97 | |||
|
|
507ac10e8d | ||
| 16e167019f | |||
| d712944e6b | |||
| d9b60c6cc9 | |||
| aee83e1a9e | |||
| f5317341bf | |||
| 8345dacb26 | |||
|
|
531d9c621d | ||
| dc151cdfe3 | |||
|
|
e0dfd56a0d | ||
| 1fb680abb4 | |||
| b9e56c96cb | |||
|
|
dd956f18fe | ||
| cf59d31113 | |||
|
|
bc0e277332 | ||
| 75a2780fe0 | |||
| a6c479e42e | |||
| 64a4824054 | |||
| 1619446ec9 | |||
| 37f002427a | |||
|
|
50cb70dcc6 | ||
| 55f7efc4f5 | |||
| be72c9f270 | |||
| c8cedc0124 | |||
|
|
3fdbe4031e | ||
| c16b9dce9c | |||
| 9387275851 | |||
| 94463afdba | |||
| 02563b10f3 | |||
| fff4af2489 | |||
| 452124b528 | |||
|
|
9c84e158ba | ||
| 58a0bc7974 | |||
| 770dbd4b63 | |||
| d22035f897 | |||
|
|
fe21b39b7f | ||
| 1b78840fd8 | |||
|
|
46519342b6 | ||
| 9079ddd727 | |||
|
|
205745cc72 | ||
| 717017e69e | |||
| a3de1f0a31 | |||
| 8eef4253b0 | |||
| 1f2db927f5 | |||
| 98f159b25f | |||
| 061f3481da | |||
| f35f4c4b29 | |||
| c36852b2ef | |||
| 4eaadd1545 | |||
|
|
d04770fe91 | ||
| 23fee22ef8 | |||
| 6e7920c119 | |||
| e3d0d5566c | |||
| e5b532274e | |||
| eb0323b989 | |||
| 60852e228f | |||
| b3dbe922de | |||
| fde912005d | |||
| 5e4965fe1f | |||
| aff5a51f4c | |||
| b4af2cc77a | |||
| 25bd905cef | |||
| 2f0d213e32 | |||
| b6695b45d0 | |||
| 77f9d42576 | |||
| 8cca510fa1 | |||
| 06a4954d3d | |||
| 4acf5befb1 | |||
| 99d76236ca | |||
| afc818bf7d | |||
| 8e846d4499 | |||
| a1c859c743 | |||
| 75cc45d767 | |||
| 1d091071e1 | |||
| 8e64b65c2d | |||
| 27ea92d120 | |||
| 3ddfeaa49f | |||
| 074bbbc166 | |||
| 3709cdc866 | |||
| 9d6d0b406a | |||
| 6318b2d822 | |||
| f89e74b199 | |||
| 0ac14a74b8 | |||
| 1910993b2b | |||
| 7c303d0129 | |||
| 113938e71a | |||
| e0f146beeb | |||
| fc1cdc814f | |||
| a13de45131 | |||
| 8ff2063bc8 | |||
| cdc613b6e7 | |||
| 1fc6125369 | |||
| fef07ac8e1 | |||
| 86647b9b7e | |||
| 36dc174bfe | |||
| a06f0600c1 | |||
| f88dfc8f1b | |||
| c70cd9d6e8 | |||
| 8fbd54c3aa | |||
| ef4a52cc17 | |||
| b460ea9955 | |||
| 1fe052e9da | |||
| f2d5b57e86 | |||
| 6630ba1c42 | |||
| ef148317de | |||
| e10f5ec088 | |||
| 33a8a767f3 | |||
| 8efa93d2d2 | |||
| 29653239c5 | |||
| 778230b5ed | |||
| b7795b4d0a | |||
| c434af9b92 | |||
| be722683a7 | |||
| 9a940bb8d5 | |||
| a6ce312f7c | |||
| d5e422c7fc | |||
| 3cd6e05b24 | |||
| 3089ca15ec | |||
| d60cf6c843 | |||
| 45cd82e635 | |||
| f653fc5f7e | |||
| d6fccd10f5 | |||
| 064343acf2 | |||
| 82b82659b7 | |||
| 1921444e15 | |||
| 3b16c9f5a2 | |||
| 4381fcc4c2 | |||
| e4e9febc98 | |||
| ac9224e5f2 | |||
| 18e4ba6cfe | |||
| cfc8272ac2 | |||
| d2c90757c2 | |||
| 1d7b423bb3 | |||
| cb91ebc0c3 | |||
| 08168f28d3 | |||
| 125afc8907 | |||
| 4dc59aa5e9 | |||
| 96b31a4509 | |||
| 20a86ad325 | |||
| 7e65d4f2d6 | |||
| 11feeff37c | |||
| c1bbb16dad | |||
| a5f1f4781e | |||
| 56c2827140 | |||
| b03d2eaeed | |||
| 3a82c95f60 | |||
| 5f272a66a4 | |||
| 55baa84eb6 | |||
| b51d637c5f | |||
| c97db6aaae | |||
| e725de3c45 | |||
| 6082e7a690 | |||
| 8914f1d506 | |||
| d06605122e | |||
| a8adb064f5 | |||
| 31c3b64d7b | |||
| 23bdd95d8c | |||
| d1712552ff | |||
| 20a1c5ddb3 | |||
| 2511056557 | |||
| 99383b7715 | |||
| 337a332ed1 | |||
| a1bec75115 | |||
| a2128ad8d6 | |||
| 5f27a90989 | |||
| 39164feb18 | |||
| af28e2e433 | |||
| 515d7ad055 | |||
| 0e276d4c09 | |||
| ed2d958de6 | |||
| 25820a1cde | |||
| 7f7891dfa5 | |||
| b5015e4e72 | |||
| 7653e0877c | |||
| 52a9f29bdc | |||
| ca2bb4f9b4 | |||
| b4925918f7 | |||
| 43e1aa9505 | |||
| 28ae0d2b57 | |||
| 7726d83b68 | |||
| be552d3ece | |||
| 8d17f7e32f | |||
| 4a74891184 | |||
| c2d2c484cd | |||
| b91f1fe487 | |||
| d4106c548e | |||
| 288ea4dbbd | |||
| 9fb9a1cfd2 | |||
| 378398a29b | |||
| 6ade934356 | |||
| 6ca4aa0f9b | |||
| b58a098ed4 | |||
| 42e3b9c137 | |||
| 4e29291b3a | |||
| f76d9319bd | |||
| 6c90ca3107 | |||
| 94c2e2db65 | |||
| 7c31bbd9c2 | |||
| 77f96160ab | |||
| 1cc2a98489 | |||
| 112eed694c | |||
| 1a0097e027 | |||
| 8558b46114 | |||
| 75b24467de | |||
| c8bdcaabde | |||
| a5f06c8f83 | |||
| d05179a519 | |||
| be83c7d5f4 | |||
| 757375f117 | |||
| 5872253123 | |||
| 7ba93ce934 | |||
| bd5e251ee9 | |||
| f3d3c9425d | |||
| ee2eefdace | |||
| 43b747ec8a | |||
| 58b0c7ddc1 | |||
| 2ba9b4cb23 | |||
| 9f2a083abb | |||
| f878e87ad5 | |||
| fec26d793e | |||
| 98eda03f4d | |||
| 0204d9c86f | |||
| e6795dd87c | |||
| 95fcf016c3 | |||
| 0dd9617e6e | |||
| 4f9514fbd1 | |||
| 890b50115f | |||
| de10609b3c | |||
| cb39ff3fbd | |||
| ac08bdfab2 | |||
| 30db18367e | |||
| a85402dde1 | |||
| 17f2dda977 | |||
| d211bd67ab | |||
| 0b00cd24fd | |||
| ac3c5a38e4 | |||
| b085ef6e73 | |||
| 96cff49cd4 | |||
| 360fe4c9c3 | |||
| 4865341010 | |||
| 4bec181f3a | |||
| da05877dd0 | |||
| fc24c8b3a5 | |||
| 19d8aeb162 | |||
| 055b96818a | |||
| 39cf4ddd5a | |||
| 584b945005 | |||
| 9dabf2c66c | |||
|
|
8f2f42f818 | ||
| e5c9dd288c | |||
| be274a10fc | |||
| d86ef4e763 | |||
| 6cf39b3796 | |||
|
|
15e11b287d | ||
| 7cbebbb1f0 | |||
|
|
66f4f9bfa8 | ||
| 66c6c7fa50 | |||
|
|
31c3337300 | ||
| 2c506ee3c8 | |||
|
|
25423f4a3a | ||
| fa91366dcb | |||
|
|
4db0f9f10c | ||
| 46b1a228be | |||
|
|
531018b0ac | ||
| 8679b5f08b | |||
| 6f2c2401ac | |||
| 6d1106e33e | |||
| 90a184643a | |||
| 3aa2f2225f | |||
|
|
f54e69f1cf | ||
| 7309c1dede | |||
| 1c0021f98b | |||
| d32952a0d5 | |||
| 5206528fec | |||
| 42665b69c5 | |||
|
|
209c898e3d | ||
| 6a43554f3b | |||
|
|
95c931af0b | ||
| f19d9485df | |||
|
|
575c988c4f | ||
| 6b08f7cfb2 | |||
| 6ae33a23a6 | |||
| facb8c30ff | |||
| 333570ba2f | |||
| ef36a7124d | |||
| c2c022154b | |||
| 4c4f1592c2 | |||
|
|
d7fb291877 | ||
| ae18279685 | |||
| 97c0ed53df | |||
| ff8e282034 | |||
|
|
440f36f289 | ||
| 0addef5f17 | |||
|
|
8c2a5e61fc | ||
| 056731c9ad | |||
|
|
911c81a167 | ||
| 8651314d93 | |||
| 383936ffc2 | |||
|
|
4378d33880 | ||
| 1708bd405f | |||
| 12811eccdb | |||
|
|
5959fa87de | ||
| b3217b7ca5 | |||
|
|
35b941d054 | ||
| fc6d7c0824 | |||
| fb051865d5 | |||
|
|
8aba3d975f | ||
| 5e3289f5bd | |||
| d07744397e | |||
|
|
dc7bf6b3c4 | ||
| f219c6fb57 | |||
| f048880277 | |||
| 50a572dacd | |||
|
|
b87549ba99 | ||
| f0c4efefa0 | |||
| db70442cc2 | |||
|
|
07b8910686 | ||
| e7c97290cd | |||
| 48fc63d83e | |||
| a20935e862 | |||
| 4f8e6835fe | |||
|
|
042adfa51e | ||
| b2b0450bcb | |||
|
|
12e06fa971 | ||
| 6f2f2aa06a | |||
|
|
21965a0ee3 | ||
| 6df57103bb | |||
| 9a8cc31f6c | |||
| d2ffddb6d8 | |||
| 3770db51be | |||
| 2419521f5f | |||
|
|
80937cba97 | ||
| df961a9b88 | |||
| 219d43d325 | |||
|
|
229833eb99 | ||
| 141e1a34c9 | |||
|
|
d40075f85b | ||
| dfa2908c3d | |||
| 02a4862afd | |||
|
|
13438e22d3 | ||
| 889ea8629f | |||
|
|
0ef509e9ca | ||
| b40d2c5f0b | |||
|
|
6cd7ff6ef7 | ||
| 0fd5dd5a26 | |||
| 508abfa8a5 | |||
| 001e6fc807 | |||
|
|
111dcef35a | ||
| 3b04b985b6 | |||
|
|
5944626d93 | ||
| a00d368c25 | |||
| 01b4608331 | |||
|
|
b7221d1151 | ||
| fa9ecaf433 | |||
|
|
c751d25f85 | ||
| e2c7dc98d2 | |||
| 507d46f88b | |||
| 57dc1a3afc | |||
|
|
6a78da0e71 | ||
| fb545eebb3 | |||
| b4a240e463 | |||
| 54e64c9f10 | |||
| 1c8b06cbe6 | |||
| 52c5286d64 | |||
| c405421db9 | |||
| 0ff0c06bd1 | |||
| 955cc64257 | |||
| 09cb08a233 | |||
| 5c83702382 | |||
| 1b0382524f | |||
| 92b802021f | |||
| 48c140f937 | |||
| 42fd78df40 | |||
| 271a4a24e7 | |||
| 1b03ded906 | |||
| bde5618699 | |||
| 6f2eb6b4cd | |||
| 2742a3c6cf | |||
| 809e654087 | |||
| bdb25206d9 | |||
| bd5414288c | |||
| 95f6a7ceb7 | |||
|
|
b75c4c88fe | ||
| e38048964f | |||
|
|
ce11d1382c | ||
| ff654b56ae | |||
| a434d3ee57 | |||
|
|
b467b29f77 | ||
| 17a63e3b63 | |||
|
|
66fc5306d6 | ||
| 6563abfddc | |||
|
|
0d470ddf05 | ||
| 9b95b5d616 | |||
| c7d7c6d9ed | |||
|
|
4686a643f5 | ||
| 9370351abb | |||
| a55134c3bf | |||
| 5fdb2325ae | |||
| 6a36ca512d | |||
|
|
a274a14900 | ||
| da579b6d21 | |||
| 02086aeae0 | |||
| 3aeb0b66fb | |||
|
|
b4b8ae81d8 | ||
| da18c2ceec | |||
| 31d87036c9 | |||
| cffcdf2923 | |||
| 2fe7f5e151 | |||
| 3ba0b1daf5 | |||
| e68e2b5978 | |||
| daf6ea0159 | |||
| f80ec33ae5 | |||
| c27d058b01 | |||
| 96e255e4ef | |||
| 60292465e9 | |||
| 2047e484d5 | |||
| 1f71d8e5ed | |||
| 1f60fec720 | |||
| e9983521ed | |||
|
|
ed72393699 | ||
| e71e3b2956 | |||
| 6e39bdbf53 | |||
|
|
2e7383a10c | ||
| 746359b2cc | |||
|
|
0219f7c78a | ||
| aab0229a40 | |||
| 7a1b8748a4 | |||
|
|
245ebb444e | ||
| 0cd85ed9fa | |||
| 42d4f182f7 | |||
| f3a39a69e2 | |||
|
|
ec39dae273 | ||
| 8e5c0ad8c8 | |||
|
|
bf0b49b863 | ||
| 11e5937ae0 | |||
| 4f31ea655c | |||
| 64df805a9e | |||
| 035136d517 | |||
| b2eb71aae0 | |||
|
|
1e6659c379 | ||
| 5fabd4bea9 | |||
| 4f0693cae3 | |||
|
|
ba76d6bb86 | ||
| 2304c9f849 | |||
| c6e48ec1fe | |||
|
|
f837129023 | ||
| 940ee6552c | |||
|
|
86b60b4aed | ||
| 14dd8c5b29 | |||
| b039933405 | |||
|
|
d8c80293c7 | ||
| 40c9fea35f | |||
| 5d4b86e1c6 | |||
|
|
5681c0cbd1 | ||
| 91959e82de | |||
| 5eb15b785f | |||
| 6fb20552ff | |||
| 0350833f36 | |||
| acb79020d4 | |||
|
|
9c6ba6ae73 | ||
| 4f5448cf51 | |||
|
|
6f0182115f | ||
| 7469c892c8 | |||
|
|
cb45527f3e | ||
| f9a889fc6d | |||
| 2ab12ed60a | |||
|
|
98c68e9ff4 | ||
| 19f4e407e0 | |||
| a23841b255 | |||
| 6982711fea | |||
| 0015f0e2d6 | |||
| af9655de0c | |||
| e4121a01cb | |||
| a69d2870e2 | |||
| e3d0a7bbf9 | |||
| 523cc43572 | |||
| 261578796f | |||
| 0b9b1a3c89 | |||
| 9801d2769e | |||
|
|
dfccf97a99 | ||
| 9ef1d1c9ac | |||
| b23695167a | |||
|
|
92cc808d65 | ||
| 3a22392780 | |||
| f5f1f6c304 | |||
| 923867947f | |||
|
|
91260bb579 | ||
| 8dc892df0a | |||
| 8c5ef26843 | |||
|
|
b681b13a33 | ||
| 499b6b9a12 | |||
| 94ce92f5b0 | |||
| 49268e3829 | |||
| 908dbc1760 | |||
|
|
d7e6506a27 | ||
| c5e9ed6e42 | |||
| b207e45a67 | |||
| 8bf4842788 | |||
|
|
49b9bfc9d3 | ||
| 04cfb1edf1 | |||
| efa276358b | |||
| f084e2514b | |||
|
|
7cd0b3630e | ||
| dc0c825fd5 | |||
| 1dcfeb6cfc | |||
| f554f3c167 | |||
| 0f9953e8fd | |||
| 63c24f97a3 | |||
| efe90eb163 | |||
| 281cb27d8b | |||
| 5c740371d8 | |||
| 28ee3856be | |||
| 7cc0726398 | |||
| e039304fd3 | |||
|
|
6fa7ca8f09 | ||
| b2f7d3c5f3 | |||
| e3b5e338bf | |||
| c8e614b575 | |||
|
|
8e44ca1ad0 | ||
| 286ad7196b | |||
|
|
adef25f4e2 | ||
| 60f7d54e2b | |||
| dd932dd8f3 | |||
| d3c1a1b2ed | |||
| 7ea4a482e7 | |||
| 9045323049 | |||
| d15b22250f | |||
|
|
5557bfe717 | ||
| a8576c164c | |||
|
|
f5807ec5cd | ||
| b0d786b991 | |||
|
|
774044d2a7 | ||
| 84a59f70ee | |||
| de303f0227 | |||
|
|
cb2131b1de | ||
| 7d07cea946 | |||
| f6d1d0bbe3 | |||
| a52182dca9 | |||
|
|
6731b655e7 | ||
| bd126dddbb | |||
| e6976dc151 | |||
| b1aff6d791 | |||
| 7bdca84314 | |||
|
|
6b3ea0101e | ||
| 06d7741622 | |||
| 6b15abcc73 | |||
| 998a745133 | |||
|
|
3c519461ec | ||
| 0fd5cee776 | |||
| cc691d4039 | |||
| 3a5d7d0796 | |||
|
|
814c823875 | ||
| 90479167fb | |||
| 730e25fd3a | |||
| b07e67715c | |||
| 85dcbdaa88 | |||
| ec3bc8b519 | |||
| 2cd9c7f585 | |||
| d28f9b04c4 | |||
| fe8dc55eb1 | |||
| 26920f8482 | |||
|
|
8a354690c9 | ||
| d5eb30cd7d | |||
| 52da835803 | |||
| 9be19d4abe | |||
| 9866075100 | |||
| 158c19eda7 | |||
| 39f98ec223 | |||
|
|
e12a85feaa | ||
| 047aa26a60 | |||
| 9dd43aa1fd | |||
| 0d7c10e670 | |||
| df5eff3147 | |||
| 18d8561c96 | |||
|
|
103410d4c7 | ||
| 61ecf491e5 | |||
| 9781b77de2 | |||
|
|
162e0ae78b | ||
| 99d5e8e71c | |||
| 6c1f89ad39 | |||
| 7fb938a850 | |||
|
|
08c3d7d175 | ||
| af23e74f71 | |||
| 0bf1cf9b8a | |||
|
|
6dd64dd8e1 | ||
| 99a98de8a3 | |||
| 3c0e501c56 | |||
|
|
9d76d8bf6c | ||
| a3110d9814 | |||
| ec9c8f2963 | |||
| b32ced85ff | |||
|
|
d0e5643d4f | ||
| 2efd48736c | |||
| 6ed1efc6af | |||
| a568633c32 | |||
|
|
6a919be88f | ||
| 8be8295b2b | |||
| 5d73fe455a | |||
| 7dadab1f14 | |||
|
|
664bbce01d | ||
| 097946fd68 | |||
| 4a890281f7 | |||
| cdd175207e | |||
|
|
3210a42e42 | ||
| 719254cf0a | |||
|
|
02193967de | ||
| 5f37e862c9 | |||
| 9925bbdb48 | |||
|
|
1f7ca4813c | ||
| ffc871ebbd | |||
|
|
7b9a36403d | ||
| 09c6c93c39 | |||
| c31e9a3aff | |||
|
|
960d84b7fe | ||
| e6f204b6aa | |||
| 02239de0a3 | |||
|
|
0aad9a0988 | ||
| c5501860e8 | |||
| 4e5520aee2 | |||
| 4591ba8f73 | |||
| f335763280 | |||
| e890091d86 | |||
| ac2cb5197d | |||
|
|
65345187b3 | ||
| d48243483e | |||
| 1ca9499edd | |||
| 060935ffc5 | |||
|
|
50dbef52c0 | ||
| bb385f07ca | |||
| cf28730515 | |||
|
|
13ae383455 | ||
| 2265458dcc | |||
|
|
0a59f08fcc | ||
| c70724a456 | |||
| 406c263746 | |||
| df35aabff3 | |||
| cc8c166b5c | |||
| c4f3308dc0 | |||
|
|
8f3824c0e7 | ||
| afdf4e8782 | |||
| 2a82032644 | |||
| 88a2f66758 | |||
| 3f3b207295 | |||
| 44cfda1c07 | |||
| e42b84c636 | |||
| 77c5aa741c | |||
| 2b4449afeb | |||
| 36ad464159 | |||
|
|
e8ae6f2e43 | ||
| 3ecbd60627 | |||
| 82a55ddf3e | |||
|
|
7d190719b1 | ||
| 8c2e7c8259 | |||
| dd7c71bb1e | |||
|
|
7b5b7a8cbb | ||
| af28574bd5 | |||
| 617db36ed4 | |||
|
|
ebc2e44c7c | ||
| 44738057a3 | |||
| f98a9f9771 | |||
| 2fe72c9ccb | |||
| f0203d9bf6 | |||
| 37835cbf76 | |||
|
|
e005be33d1 | ||
| 9d7718c3d9 | |||
| 9d8fb0b761 |
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Bug report
|
||||
description: File a bug report.
|
||||
title: "[BUG]: "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Bug report:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Provide a brief description of the bug.
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Describe what you expected to happen and what actually happened.
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Outline the steps that lead to the bug's occurrence. Be specific and provide a clear sequence of actions.
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: bec_widgets version
|
||||
description: which version of BEC widgets was running?
|
||||
- type: input
|
||||
id: bec-version
|
||||
attributes:
|
||||
label: bec core version
|
||||
description: which version of BEC core was running?
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Any extra info / data? e.g. log output...
|
||||
- type: input
|
||||
id: issues
|
||||
attributes:
|
||||
label: Related issues
|
||||
description: please tag any related issues
|
||||
@@ -1,3 +1,13 @@
|
||||
---
|
||||
name: Documentation update request
|
||||
about: Suggest an update to the docs
|
||||
title: '[DOCS]: '
|
||||
type: documentation
|
||||
label: documentation
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Documentation Section
|
||||
|
||||
[Specify the section or page of the documentation that needs updating]
|
||||
@@ -1,3 +1,13 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: '[FEAT]: '
|
||||
type: feature
|
||||
label: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Feature Summary
|
||||
|
||||
[Provide a brief and clear summary of the new feature you are requesting]
|
||||
@@ -37,4 +47,3 @@
|
||||
## 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]
|
||||
|
||||
65
.github/actions/bw_install/action.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: "BEC Widgets Install"
|
||||
description: "Install BEC Widgets and related os dependencies"
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of BEC Widgets to install"
|
||||
BEC_CORE_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of BEC Core to install"
|
||||
OPHYD_DEVICES_BRANCH: # id of input
|
||||
required: false
|
||||
default: "main"
|
||||
description: "Branch of Ophyd Devices to install"
|
||||
PYTHON_VERSION: # id of input
|
||||
required: false
|
||||
default: "3.11"
|
||||
description: "Python version to use"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.PYTHON_VERSION }}
|
||||
|
||||
- name: Checkout BEC Core
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
path: ./bec
|
||||
|
||||
- name: Checkout Ophyd Devices
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/ophyd_devices
|
||||
ref: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
path: ./ophyd_devices
|
||||
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
path: ./bec_widgets
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
sudo apt-get -y install ttyd
|
||||
|
||||
- name: Install Python dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install --system -e ./ophyd_devices
|
||||
uv pip install --system -e ./bec/bec_lib[dev]
|
||||
uv pip install --system -e ./bec/bec_ipython_client
|
||||
uv pip install --system -e ./bec_widgets[dev,pyside6]
|
||||
6
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -1,19 +1,24 @@
|
||||
## Description
|
||||
|
||||
[Provide a brief description of the changes introduced by this merge request.]
|
||||
[Provide a brief description of the changes introduced by this pull 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`.]
|
||||
[Cite any related issues or feature requests that are addressed or resolved by this pull request. Link the associated issue, for example, with `fixes #123` or `closes #123`.]
|
||||
|
||||
## Type of Change
|
||||
|
||||
- Change 1
|
||||
- Change 2
|
||||
|
||||
## How to test
|
||||
|
||||
- Run unit tests
|
||||
- Open [widget] in designer and play around with the properties
|
||||
|
||||
## Potential side effects
|
||||
|
||||
[Describe any potential side effects or risks of merging this MR.]
|
||||
[Describe any potential side effects or risks of merging this PR.]
|
||||
|
||||
## Screenshots / GIFs (if applicable)
|
||||
|
||||
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
Normal file
@@ -0,0 +1,342 @@
|
||||
import functools
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
import requests
|
||||
from github import Github
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GHConfig(BaseModel):
|
||||
token: str
|
||||
organization: str
|
||||
repository: str
|
||||
project_number: int
|
||||
graphql_url: str
|
||||
rest_url: str
|
||||
headers: dict
|
||||
|
||||
|
||||
class ProjectItemHandler:
|
||||
"""
|
||||
A class to handle GitHub project items.
|
||||
"""
|
||||
|
||||
def __init__(self, gh_config: GHConfig):
|
||||
self.gh_config = gh_config
|
||||
self.gh = Github(gh_config.token)
|
||||
self.repo = self.gh.get_repo(f"{gh_config.organization}/{gh_config.repository}")
|
||||
self.project_node_id = self.get_project_node_id()
|
||||
|
||||
def set_issue_status(
|
||||
self,
|
||||
status: Literal[
|
||||
"Selected for Development",
|
||||
"Weekly Backlog",
|
||||
"In Development",
|
||||
"Ready For Review",
|
||||
"On Hold",
|
||||
"Done",
|
||||
],
|
||||
issue_number: int | None = None,
|
||||
issue_node_id: str | None = None,
|
||||
):
|
||||
"""
|
||||
Set the status field of a GitHub issue in the project.
|
||||
|
||||
Args:
|
||||
status (str): The status to set. Must be one of the predefined statuses.
|
||||
issue_number (int, optional): The issue number. If not provided, issue_node_id must be provided.
|
||||
issue_node_id (str, optional): The issue node ID. If not provided, issue_number must be provided.
|
||||
"""
|
||||
if not issue_number and not issue_node_id:
|
||||
raise ValueError("Either issue_number or issue_node_id must be provided.")
|
||||
if issue_number and issue_node_id:
|
||||
raise ValueError("Only one of issue_number or issue_node_id must be provided.")
|
||||
if issue_number is not None:
|
||||
issue = self.repo.get_issue(issue_number)
|
||||
issue_id = self.get_issue_info(issue.node_id)[0]["id"]
|
||||
else:
|
||||
issue_id = issue_node_id
|
||||
field_id, option_id = self.get_status_field_id(field_name=status)
|
||||
self.set_field_option(issue_id, field_id, option_id)
|
||||
|
||||
def run_graphql(self, query: str, variables: dict) -> dict:
|
||||
"""
|
||||
Execute a GraphQL query against the GitHub API.
|
||||
|
||||
Args:
|
||||
query (str): The GraphQL query to execute.
|
||||
variables (dict): The variables to pass to the query.
|
||||
|
||||
Returns:
|
||||
dict: The response from the GitHub API.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.gh_config.graphql_url,
|
||||
json={"query": query, "variables": variables},
|
||||
headers=self.gh_config.headers,
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(
|
||||
f"Query failed with status code {response.status_code}: {response.text}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
def get_project_node_id(self):
|
||||
"""
|
||||
Retrieve the project node ID from the GitHub API.
|
||||
"""
|
||||
query = """
|
||||
query($owner: String!, $number: Int!) {
|
||||
organization(login: $owner) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"owner": self.gh_config.organization, "number": self.gh_config.project_number}
|
||||
resp = self.run_graphql(query, variables)
|
||||
return resp["data"]["organization"]["projectV2"]["id"]
|
||||
|
||||
def get_issue_info(self, issue_node_id: str):
|
||||
"""
|
||||
Get the project-related information for a given issue node ID.
|
||||
|
||||
Args:
|
||||
issue_node_id (str): The node ID of the issue. Please note that this is not the issue number and typically starts with "I".
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of project items associated with the issue.
|
||||
"""
|
||||
query = """
|
||||
query($issueId: ID!) {
|
||||
node(id: $issueId) {
|
||||
... on Issue {
|
||||
projectItems(first: 10) {
|
||||
nodes {
|
||||
project {
|
||||
id
|
||||
title
|
||||
}
|
||||
id
|
||||
fieldValues(first: 20) {
|
||||
nodes {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
name
|
||||
field {
|
||||
... on ProjectV2SingleSelectField {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"issueId": issue_node_id}
|
||||
resp = self.run_graphql(query, variables)
|
||||
return resp["data"]["node"]["projectItems"]["nodes"]
|
||||
|
||||
def get_status_field_id(
|
||||
self,
|
||||
field_name: Literal[
|
||||
"Selected for Development",
|
||||
"Weekly Backlog",
|
||||
"In Development",
|
||||
"Ready For Review",
|
||||
"On Hold",
|
||||
"Done",
|
||||
],
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Get the status field ID and option ID for the given field name in the project.
|
||||
|
||||
Args:
|
||||
field_name (str): The name of the field to retrieve.
|
||||
Must be one of the predefined statuses.
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: A tuple containing the field ID and option ID.
|
||||
"""
|
||||
field_id = None
|
||||
option_id = None
|
||||
project_fields = self.get_project_fields()
|
||||
for field in project_fields:
|
||||
if field["name"] != "Status":
|
||||
continue
|
||||
field_id = field["id"]
|
||||
for option in field["options"]:
|
||||
if option["name"] == field_name:
|
||||
option_id = option["id"]
|
||||
break
|
||||
if not field_id or not option_id:
|
||||
raise ValueError(f"Field '{field_name}' not found in project fields.")
|
||||
|
||||
return field_id, option_id
|
||||
|
||||
def set_field_option(self, item_id, field_id, option_id):
|
||||
"""
|
||||
Set the option of a project item for a single-select field.
|
||||
|
||||
Args:
|
||||
item_id (str): The ID of the project item to update.
|
||||
field_id (str): The ID of the field to update.
|
||||
option_id (str): The ID of the option to set.
|
||||
"""
|
||||
|
||||
mutation = """
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { singleSelectOptionId: $optionId }
|
||||
}
|
||||
) {
|
||||
projectV2Item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {
|
||||
"projectId": self.project_node_id,
|
||||
"itemId": item_id,
|
||||
"fieldId": field_id,
|
||||
"optionId": option_id,
|
||||
}
|
||||
return self.run_graphql(mutation, variables)
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_project_fields(self) -> list[dict]:
|
||||
"""
|
||||
Get the available fields in the project.
|
||||
This method caches the result to avoid multiple API calls.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of fields in the project.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query($projectId: ID!) {
|
||||
node(id: $projectId) {
|
||||
... on ProjectV2 {
|
||||
fields(first: 50) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"projectId": self.project_node_id}
|
||||
resp = self.run_graphql(query, variables)
|
||||
return list(filter(bool, resp["data"]["node"]["fields"]["nodes"]))
|
||||
|
||||
def get_pull_request_linked_issues(self, pr_number: int) -> list[dict]:
|
||||
"""
|
||||
Get the linked issues of a pull request.
|
||||
|
||||
Args:
|
||||
pr_number (int): The pull request number.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of linked issues.
|
||||
"""
|
||||
query = """
|
||||
query($number: Int!, $owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
id
|
||||
closingIssuesReferences(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
body
|
||||
number
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {
|
||||
"number": pr_number,
|
||||
"owner": self.gh_config.organization,
|
||||
"repo": self.gh_config.repository,
|
||||
}
|
||||
resp = self.run_graphql(query, variables)
|
||||
edges = resp["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["edges"]
|
||||
return [edge["node"] for edge in edges if edge.get("node")]
|
||||
|
||||
|
||||
def main():
|
||||
# GitHub settings
|
||||
token = os.getenv("TOKEN")
|
||||
org = os.getenv("ORG")
|
||||
repo = os.getenv("REPO")
|
||||
project_number = os.getenv("PROJECT_NUMBER")
|
||||
pr_number = os.getenv("PR_NUMBER")
|
||||
|
||||
if not token:
|
||||
raise ValueError("GitHub token is not set. Please set the TOKEN environment variable.")
|
||||
if not org:
|
||||
raise ValueError("GitHub organization is not set. Please set the ORG environment variable.")
|
||||
if not repo:
|
||||
raise ValueError("GitHub repository is not set. Please set the REPO environment variable.")
|
||||
if not project_number:
|
||||
raise ValueError(
|
||||
"GitHub project number is not set. Please set the PROJECT_NUMBER environment variable."
|
||||
)
|
||||
if not pr_number:
|
||||
raise ValueError(
|
||||
"Pull request number is not set. Please set the PR_NUMBER environment variable."
|
||||
)
|
||||
|
||||
project_number = int(project_number)
|
||||
pr_number = int(pr_number)
|
||||
|
||||
gh_config = GHConfig(
|
||||
token=token,
|
||||
organization=org,
|
||||
repository=repo,
|
||||
project_number=project_number,
|
||||
graphql_url="https://api.github.com/graphql",
|
||||
rest_url=f"https://api.github.com/repos/{org}/{repo}/issues",
|
||||
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"},
|
||||
)
|
||||
project_item_handler = ProjectItemHandler(gh_config=gh_config)
|
||||
|
||||
# Get PR info
|
||||
pr = project_item_handler.repo.get_pull(pr_number)
|
||||
|
||||
# Get the linked issues of the pull request
|
||||
linked_issues = project_item_handler.get_pull_request_linked_issues(pr_number=pr_number)
|
||||
print(f"Linked issues: {linked_issues}")
|
||||
|
||||
target_status = "In Development" if pr.draft else "Ready For Review"
|
||||
print(f"Target status: {target_status}")
|
||||
for issue in linked_issues:
|
||||
project_item_handler.set_issue_status(issue_number=issue["number"], status=target_status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
2
.github/scripts/pr_issue_sync/requirements.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
pydantic
|
||||
pygithub
|
||||
28
.github/workflows/check_pr.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Check PR status for branch
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
branch-pr:
|
||||
description: The PR number if the branch is in one
|
||||
value: ${{ jobs.pr.outputs.branch-pr }}
|
||||
|
||||
jobs:
|
||||
pr:
|
||||
runs-on: "ubuntu-latest"
|
||||
outputs:
|
||||
branch-pr: ${{ steps.script.outputs.result }}
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
id: script
|
||||
if: github.event_name == 'push' && github.event.ref_type != 'tag'
|
||||
with:
|
||||
script: |
|
||||
const prs = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
head: context.repo.owner + ':${{ github.ref_name }}'
|
||||
})
|
||||
if (prs.data.length) {
|
||||
console.log(`::notice ::Skipping CI on branch push as it is already run in PR #${prs.data[0]["number"]}`)
|
||||
return prs.data[0]["number"]
|
||||
}
|
||||
64
.github/workflows/child_repos.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch for BEC Core'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch for Ophyd Devices'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch for BEC Widgets'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
|
||||
jobs:
|
||||
bec:
|
||||
name: BEC Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Install BEC and dependencies
|
||||
uses: ./.github/actions/bec_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
cd ./bec
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --maxfail=2 --junitxml=report.xml --random-order ./bec_server/tests ./bec_ipython_client/tests/client_tests ./bec_lib/tests
|
||||
bec-e2e-test:
|
||||
name: BEC End2End Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout BEC
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec
|
||||
ref: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/bec_e2e_install
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
PYTHON_VERSION: '3.11'
|
||||
80
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Full CI
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
type: string
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check_pr_status:
|
||||
uses: ./.github/workflows/check_pr.yml
|
||||
|
||||
formatter:
|
||||
needs: check_pr_status
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/formatter.yml
|
||||
|
||||
unit-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
secrets:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
unit-test-matrix:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/pytest-matrix.yml
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha}}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main' }}
|
||||
|
||||
generate-cli-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/generate-cli-check.yml
|
||||
|
||||
end2end-test:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/end2end-conda.yml
|
||||
|
||||
child-repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: ./.github/workflows/child_repos.yml
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH || 'main'}}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
plugin_repos:
|
||||
needs: [check_pr_status, formatter]
|
||||
if: needs.check_pr_status.outputs.branch-pr == ''
|
||||
uses: bec-project/bec/.github/workflows/plugin_repos.yml@main
|
||||
with:
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH || 'main' }}
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH || github.head_ref || github.sha }}
|
||||
|
||||
secrets:
|
||||
GH_READ_TOKEN: ${{ secrets.GH_READ_TOKEN }}
|
||||
58
.github/workflows/end2end-conda.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
name: Run Pytest with Coverage
|
||||
on: [workflow_call]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Conda
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
auto-update-conda: true
|
||||
auto-activate-base: true
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Conda install and run pytest
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
|
||||
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
|
||||
cd ./bec
|
||||
conda create -q -n test-environment python=3.11
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
- name: Upload logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./logs/*.log
|
||||
retention-days: 7
|
||||
66
.github/workflows/formatter.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Formatter and Pylint jobs
|
||||
on: [workflow_call]
|
||||
jobs:
|
||||
|
||||
Formatter:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Run black and isort
|
||||
run: |
|
||||
pip install uv
|
||||
uv pip install --system black isort
|
||||
uv pip install --system -e .[dev]
|
||||
black --check --diff --color .
|
||||
isort --check --diff ./
|
||||
|
||||
- name: Check for disallowed imports from PySide
|
||||
run: '! grep -re "from PySide6\." bec_widgets/ tests/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
|
||||
|
||||
Pylint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pylint pylint-exit anybadge
|
||||
|
||||
- name: Run Pylint
|
||||
run: |
|
||||
mkdir -p ./pylint
|
||||
set +e
|
||||
pylint ./${{ github.event.repository.name }} --output-format=text > ./pylint/pylint.log
|
||||
pylint-exit $?
|
||||
set -e
|
||||
|
||||
- name: Extract Pylint Score
|
||||
id: score
|
||||
run: |
|
||||
SCORE=$(sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' ./pylint/pylint.log)
|
||||
echo "score=$SCORE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Badge
|
||||
run: |
|
||||
anybadge --label=Pylint --file=./pylint/pylint.svg --value="${{ steps.score.outputs.score }}" 2=red 4=orange 8=yellow 10=green
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pylint-artifacts
|
||||
path: |
|
||||
# ./pylint/pylint.log # not sure why this isn't working
|
||||
./pylint/pylint.svg
|
||||
49
.github/workflows/generate-cli-check.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Run bw-generate-cli
|
||||
on: [workflow_call]
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -el {0}
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install os dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb
|
||||
sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1
|
||||
|
||||
- name: Clone and install dependencies
|
||||
run: |
|
||||
echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
git clone --branch $BEC_CORE_BRANCH https://github.com/bec-project/bec.git
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
pip install -e ./ophyd_devices
|
||||
pip install -e ./bec/bec_lib[dev]
|
||||
pip install -e ./bec/bec_ipython_client
|
||||
pip install -e .[dev,pyside6]
|
||||
|
||||
- name: Run bw-generate-cli
|
||||
run: |
|
||||
bw-generate-cli --target bec_widgets
|
||||
git diff --exit-code
|
||||
|
||||
59
.github/workflows/pytest-matrix.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Run Pytest with different Python versions
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
pytest-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
env:
|
||||
BEC_WIDGETS_BRANCH: main # Set the branch you want for bec_widgets
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install BEC Widgets and dependencies
|
||||
uses: ./.github/actions/bw_install
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: ${{ matrix.python-version }}
|
||||
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
pip install pytest pytest-random-order
|
||||
pytest -v --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
72
.github/workflows/pytest.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Run Pytest with Coverage
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull request number'
|
||||
required: false
|
||||
type: number
|
||||
BEC_CORE_BRANCH:
|
||||
description: 'Branch of BEC Core to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: 'Branch of Ophyd Devices to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
BEC_WIDGETS_BRANCH:
|
||||
description: 'Branch of BEC Widgets to install'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
secrets:
|
||||
CODECOV_TOKEN:
|
||||
required: true
|
||||
|
||||
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
steps:
|
||||
- name: Checkout BEC Widgets
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: bec-project/bec_widgets
|
||||
ref: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
|
||||
- name: Install BEC Widgets and dependencies
|
||||
uses: ./.github/actions/bw_install
|
||||
with:
|
||||
BEC_WIDGETS_BRANCH: ${{ inputs.BEC_WIDGETS_BRANCH }}
|
||||
BEC_CORE_BRANCH: ${{ inputs.BEC_CORE_BRANCH }}
|
||||
OPHYD_DEVICES_BRANCH: ${{ inputs.OPHYD_DEVICES_BRANCH }}
|
||||
PYTHON_VERSION: 3.11
|
||||
|
||||
- name: Run Pytest with Coverage
|
||||
id: coverage
|
||||
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: image-references
|
||||
path: bec_widgets/tests/reference_failures/
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: bec-project/bec_widgets
|
||||
103
.github/workflows/semantic_release.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
name: Continuous Delivery
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# default: least privileged permissions across all jobs
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
# Note: We checkout the repository at the branch that triggered the workflow
|
||||
# with the entire history to ensure to match PSR's release branch detection
|
||||
# and history evaluation.
|
||||
# However, we forcefully reset the branch to the workflow sha because it is
|
||||
# possible that the branch was updated while the workflow was running. This
|
||||
# prevents accidentally releasing un-evaluated changes.
|
||||
- name: Setup | Checkout Repository on Release Branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
ssh-key: ${{ secrets.CI_DEPLOY_SSH_KEY }}
|
||||
ssh-known-hosts: ${{ secrets.CI_DEPLOY_SSH_KNOWN_HOSTS }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup | Force release branch to be at workflow sha
|
||||
run: |
|
||||
git reset --hard ${{ github.sha }}
|
||||
- name: Evaluate | Verify upstream has NOT changed
|
||||
# Last chance to abort before causing an error as another PR/push was applied to
|
||||
# the upstream branch while this workflow was running. This is important
|
||||
# because we are committing a version change (--commit). You may omit this step
|
||||
# if you have 'commit: false' in your configuration.
|
||||
#
|
||||
# You may consider moving this to a repo script and call it from this step instead
|
||||
# of writing it in-line.
|
||||
shell: bash
|
||||
run: |
|
||||
set +o pipefail
|
||||
|
||||
UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)"
|
||||
printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME"
|
||||
|
||||
set -o pipefail
|
||||
|
||||
if [ -z "$UPSTREAM_BRANCH_NAME" ]; then
|
||||
printf >&2 '%s\n' "::error::Unable to determine upstream branch name!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch "${UPSTREAM_BRANCH_NAME%%/*}"
|
||||
|
||||
if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then
|
||||
printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then
|
||||
printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]"
|
||||
printf >&2 '%s\n' "::error::Upstream has changed, aborting release..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "Verified upstream branch has not changed, continuing with release..."
|
||||
|
||||
- name: Semantic Version Release
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
pip install python-semantic-release==9.* wheel build twine
|
||||
semantic-release -vv version
|
||||
if [ ! -d dist ]; then echo No release will be made; exit 0; fi
|
||||
twine upload dist/* -u __token__ -p ${{ secrets.CI_PYPI_TOKEN }} --skip-existing
|
||||
semantic-release publish
|
||||
19
.github/workflows/stale-issues.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '00 10 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
40
.github/workflows/sync-issues-pr.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Sync PR to Project
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
sync-project:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PROJECT_NUMBER: 3 # BEC Project
|
||||
ORG: 'bec-project'
|
||||
REPO: 'bec_widgets'
|
||||
TOKEN: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
steps:
|
||||
- name: Set up python environment
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.11
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r ./.github/scripts/pr_issue_sync/requirements.txt
|
||||
- name: Sync PR to Project
|
||||
run: |
|
||||
python ./.github/scripts/pr_issue_sync/pr_issue_sync.py
|
||||
3
.gitignore
vendored
@@ -64,6 +64,9 @@ coverage.xml
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Output from end2end testing
|
||||
tests/reference_failures/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
263
.gitlab-ci.yml
@@ -1,263 +0,0 @@
|
||||
# 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_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
|
||||
- echo -e "\033[35;1m Using branch $BEC_CORE_BRANCH of BEC CORE \033[0;m";
|
||||
- git clone --branch $BEC_CORE_BRANCH https://gitlab.psi.ch/bec/bec.git
|
||||
- echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
- git clone --branch $OPHYD_DEVICES_BRANCH https://gitlab.psi.ch/bec/ophyd_devices.git
|
||||
- export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
|
||||
.install-repos: &install-repos
|
||||
- pip install -e ./ophyd_devices
|
||||
- pip install -e ./bec/bec_lib[dev]
|
||||
- pip install -e ./bec/bec_ipython_client
|
||||
|
||||
.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 xvfb
|
||||
- *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 isort
|
||||
- isort --check --diff ./
|
||||
- black --check --diff --color ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- 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)
|
||||
- anybadge --label=Pylint --file=pylint/pylint.svg --value=$PYLINT_SCORE 2=red 4=orange 8=yellow 10=green
|
||||
- echo "Pylint score is $PYLINT_SCORE"
|
||||
artifacts:
|
||||
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"
|
||||
script:
|
||||
- *clone-repos
|
||||
- *install-os-packages
|
||||
- *install-repos
|
||||
- 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
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: coverage.xml
|
||||
paths:
|
||||
- tests/reference_failures/
|
||||
when: always
|
||||
|
||||
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
|
||||
- *install-repos
|
||||
- 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
|
||||
- cd ../
|
||||
- pip install -e ./ophyd_devices
|
||||
|
||||
- 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
|
||||
needs: ["tests"]
|
||||
script:
|
||||
- git config --global user.name "ci_update_bot"
|
||||
- git config --global user.email "ci_update_bot@bec.ch"
|
||||
- git checkout "$CI_COMMIT_REF_NAME"
|
||||
- git reset --hard origin/"$CI_COMMIT_REF_NAME"
|
||||
|
||||
# delete all local tags
|
||||
- git tag -l | xargs git tag -d
|
||||
- git fetch --tags
|
||||
- git tag
|
||||
|
||||
# build and publish package
|
||||
- pip install python-semantic-release==9.* wheel build twine
|
||||
- export GL_TOKEN=$CI_UPDATES
|
||||
- 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 == "main" && $CI_PROJECT_PATH == "bec/bec_widgets"'
|
||||
|
||||
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/
|
||||
@@ -1,17 +0,0 @@
|
||||
## 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.]
|
||||
@@ -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.10
|
||||
py-version=3.11
|
||||
|
||||
# When enabled, pylint would attempt to guess common misconfiguration and emit
|
||||
# user-friendly hints instead of false-positive error messages.
|
||||
|
||||
@@ -7,13 +7,13 @@ version: 2
|
||||
|
||||
# Set the version of Python and other tools you might need
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.10"
|
||||
python: "3.11"
|
||||
|
||||
# Build documentation in the docs/ directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
configuration: docs/conf.py
|
||||
|
||||
# If using Sphinx, optionally build your docs in additional formats such as PDF
|
||||
# formats:
|
||||
@@ -21,5 +21,7 @@ sphinx:
|
||||
|
||||
# Optionally declare the Python requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
- method: pip
|
||||
path: .[dev]
|
||||
|
||||
8827
CHANGELOG.md
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2023, bec
|
||||
Copyright (c) 2025, Paul Scherrer Institute
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
|
||||
45
README.md
@@ -1,12 +1,28 @@
|
||||
# BEC Widgets
|
||||
|
||||
|
||||
[](https://github.com/bec-project/bec_widgets/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/bec-widgets/)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://www.python.org)
|
||||
[](https://doc.qt.io/qtforpython/)
|
||||
[](https://conventionalcommits.org)
|
||||
[](https://codecov.io/gh/bec-project/bec_widgets)
|
||||
|
||||
|
||||
**⚠️ Important Notice:**
|
||||
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
|
||||
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
|
||||
pip install bec_widgets[pyside6]
|
||||
```
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
@@ -14,22 +30,12 @@ For development purposes, you can clone the repository and install the package l
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyqt6]
|
||||
pip install -e .[dev,pyside6]
|
||||
```
|
||||
|
||||
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.
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
|
||||
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/).
|
||||
@@ -39,7 +45,7 @@ Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
@@ -53,13 +59,13 @@ All commits should use the Angular commit scheme:
|
||||
> │
|
||||
> └─⫸ 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
|
||||
@@ -71,4 +77,5 @@ All commits should use the Angular commit scheme:
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||
if qt_platform != "offscreen":
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# Default QtAds configuration
|
||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||
QtAds.CDockManager.setConfigFlag(
|
||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||
)
|
||||
|
||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||
|
||||
23
bec_widgets/applications/bw_launch.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
|
||||
def dock_area(object_name: str | None = None) -> BECDockArea:
|
||||
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
|
||||
return _dock_area
|
||||
|
||||
|
||||
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
|
||||
"""
|
||||
Create a dock area with auto update enabled.
|
||||
|
||||
Args:
|
||||
object_name(str): The name of the dock area.
|
||||
|
||||
Returns:
|
||||
BECDockArea: The created dock area.
|
||||
"""
|
||||
_auto_update = AutoUpdates(object_name=object_name)
|
||||
return _auto_update
|
||||
612
bec_widgets/applications/launch_window.py
Normal file
@@ -0,0 +1,612 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import enable_hover_gradient
|
||||
from qtpy.QtCore import Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpacerItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class LaunchTile(QFrame):
|
||||
DEFAULT_SIZE = (250, 300)
|
||||
open_signal = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None = None,
|
||||
icon_path: str | None = None,
|
||||
top_label: str | None = None,
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
show_selector: bool = False,
|
||||
tile_size: tuple[int, int] | None = None,
|
||||
gradient: list[str] | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setProperty("skip_settings", True)
|
||||
self.setProperty("variant", "tile")
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
# Provide a per‑instance TILE_SIZE so the class can compute layout
|
||||
if tile_size is None:
|
||||
tile_size = self.DEFAULT_SIZE
|
||||
self.tile_size = tile_size
|
||||
|
||||
self.icon_label = QLabel(parent=self)
|
||||
self.icon_label.setFixedSize(100, 100)
|
||||
self.icon_label.setScaledContents(True)
|
||||
pixmap = QPixmap(icon_path)
|
||||
if not pixmap.isNull():
|
||||
size = 100
|
||||
circular_pixmap = QPixmap(size, size)
|
||||
circular_pixmap.fill(Qt.transparent)
|
||||
|
||||
painter = QPainter(circular_pixmap)
|
||||
painter.setRenderHints(QPainter.Antialiasing, True)
|
||||
path = QPainterPath()
|
||||
path.addEllipse(0, 0, size, size)
|
||||
painter.setClipPath(path)
|
||||
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
painter.end()
|
||||
|
||||
self.icon_label.setPixmap(circular_pixmap)
|
||||
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Top label
|
||||
self.top_label = QLabel(top_label.upper())
|
||||
font_top = self.top_label.font()
|
||||
font_top.setPointSize(10)
|
||||
self.top_label.setFont(font_top)
|
||||
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
|
||||
|
||||
# Main label
|
||||
self.main_label = QLabel(main_label)
|
||||
|
||||
# Desired default appearance
|
||||
font_main = self.main_label.font()
|
||||
font_main.setPointSize(14)
|
||||
font_main.setBold(True)
|
||||
self.main_label.setFont(font_main)
|
||||
self.main_label.setAlignment(Qt.AlignCenter)
|
||||
|
||||
# Shrink font if the default would wrap on this platform / DPI
|
||||
content_width = (
|
||||
self.tile_size[0]
|
||||
- self.layout.contentsMargins().left()
|
||||
- self.layout.contentsMargins().right()
|
||||
)
|
||||
self._fit_label_to_width(self.main_label, content_width)
|
||||
|
||||
# Give every tile the same reserved height for the title so the
|
||||
# description labels start at an identical y‑offset.
|
||||
self.main_label.setFixedHeight(QFontMetrics(self.main_label.font()).height() + 2)
|
||||
|
||||
self.layout.addWidget(self.main_label)
|
||||
|
||||
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.layout.addItem(self.spacer_top)
|
||||
|
||||
# Description
|
||||
self.description_label = QLabel(description)
|
||||
self.description_label.setWordWrap(True)
|
||||
self.description_label.setAlignment(Qt.AlignCenter)
|
||||
self.layout.addWidget(self.description_label)
|
||||
|
||||
# Selector
|
||||
if show_selector:
|
||||
self.selector = QComboBox(self)
|
||||
self.layout.addWidget(self.selector)
|
||||
else:
|
||||
self.selector = None
|
||||
|
||||
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
self.layout.addItem(self.spacer_bottom)
|
||||
|
||||
# Action button
|
||||
self.action_button = QPushButton("Open")
|
||||
self.action_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
background-color: #007AFF;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #005BB5;
|
||||
}
|
||||
"""
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
|
||||
if gradient is not None:
|
||||
enable_hover_gradient(self, colours=gradient)
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""Allow tiles to be theme-aware without custom styling logic."""
|
||||
self.update()
|
||||
|
||||
def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10):
|
||||
"""
|
||||
Fit the label text to the specified maximum width by adjusting the font size.
|
||||
|
||||
Args:
|
||||
label(QLabel): The label to adjust.
|
||||
max_width(int): The maximum width the label can occupy.
|
||||
min_pt(int): The minimum font point size to use.
|
||||
"""
|
||||
font = label.font()
|
||||
for pt in range(font.pointSize(), min_pt - 1, -1):
|
||||
font.setPointSize(pt)
|
||||
metrics = QFontMetrics(font)
|
||||
if metrics.horizontalAdvance(label.text()) <= max_width:
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
return
|
||||
# If nothing fits, fall back to eliding
|
||||
metrics = QFontMetrics(font)
|
||||
label.setFont(font)
|
||||
label.setWordWrap(False)
|
||||
label.setText(metrics.elidedText(label.text(), Qt.ElideRight, max_width))
|
||||
|
||||
|
||||
class LaunchWindow(BECMainWindow):
|
||||
RPC = True
|
||||
TILE_SIZE = (250, 300)
|
||||
USER_ACCESS = ["show_launcher", "hide_launcher"]
|
||||
|
||||
def __init__(
|
||||
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
|
||||
|
||||
self.app = QApplication.instance()
|
||||
self.tiles: dict[str, LaunchTile] = {}
|
||||
# Track the smallest main‑label font size chosen so far
|
||||
self._min_main_label_pt: int | None = None
|
||||
|
||||
# Toolbar
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
|
||||
self.spacer = QWidget(self)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
# Main Widget
|
||||
self.central_widget = QWidget(self)
|
||||
self.central_widget.layout = QHBoxLayout(self.central_widget)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.register_tile(
|
||||
name="dock_area",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
|
||||
top_label="Get started",
|
||||
main_label="BEC Dock Area",
|
||||
description="Highly flexible and customizable dock area application with modular widgets.",
|
||||
action_button=lambda: self.launch("dock_area"),
|
||||
show_selector=False,
|
||||
gradient=["#B73665", "#232770"],
|
||||
)
|
||||
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
)
|
||||
self.register_tile(
|
||||
name="auto_update",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
|
||||
top_label="Get automated",
|
||||
main_label="BEC Auto Update Dock Area",
|
||||
description="Dock area with auto update functionality for BEC widgets plotting.",
|
||||
action_button=self._open_auto_update,
|
||||
show_selector=True,
|
||||
selector_items=list(self.available_auto_updates.keys()) + ["Default"],
|
||||
gradient=["#EE0678", "#FF6A00"],
|
||||
)
|
||||
|
||||
self.register_tile(
|
||||
name="custom_ui_file",
|
||||
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
|
||||
top_label="Get customized",
|
||||
main_label="Launch Custom UI File",
|
||||
description="GUI application with custom UI file.",
|
||||
action_button=self._open_custom_ui_file,
|
||||
show_selector=False,
|
||||
gradient=["#155799", "#179655"],
|
||||
)
|
||||
|
||||
# plugin widgets
|
||||
self.available_widgets: dict[str, type[BECWidget]] = get_all_plugin_widgets().as_dict()
|
||||
if self.available_widgets:
|
||||
plugin_repo_name = next(iter(self.available_widgets.values())).__module__.split(".")[0]
|
||||
plugin_repo_name = plugin_repo_name.removesuffix("_bec").upper()
|
||||
self.register_tile(
|
||||
name="widget",
|
||||
icon_path=os.path.join(
|
||||
MODULE_PATH, "assets", "app_icons", "widget_launch_tile.png"
|
||||
),
|
||||
top_label="Get quickly started",
|
||||
main_label=f"Launch a {plugin_repo_name} Widget",
|
||||
description=f"GUI application with one widget from the {plugin_repo_name} repository.",
|
||||
action_button=self._open_widget,
|
||||
show_selector=True,
|
||||
selector_items=list(self.available_widgets.keys()),
|
||||
gradient=["#000046", "#1CB5E0"],
|
||||
)
|
||||
|
||||
self._update_theme()
|
||||
|
||||
self.register = RPCRegister()
|
||||
self.register.callbacks.append(self._turn_off_the_lights)
|
||||
self.register.broadcast()
|
||||
|
||||
def register_tile(
|
||||
self,
|
||||
name: str,
|
||||
icon_path: str | None = None,
|
||||
top_label: str | None = None,
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
action_button: Callable | None = None,
|
||||
show_selector: bool = False,
|
||||
selector_items: list[str] | None = None,
|
||||
gradient: list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Register a tile in the launcher window.
|
||||
|
||||
Args:
|
||||
name(str): The name of the tile.
|
||||
icon_path(str): The path to the icon.
|
||||
top_label(str): The top label of the tile.
|
||||
main_label(str): The main label of the tile.
|
||||
description(str): The description of the tile.
|
||||
action_button(callable): The action to be performed when the button is clicked.
|
||||
show_selector(bool): Whether to show a selector or not.
|
||||
selector_items(list[str]): The items to be shown in the selector.
|
||||
"""
|
||||
|
||||
tile = LaunchTile(
|
||||
icon_path=icon_path,
|
||||
top_label=top_label,
|
||||
main_label=main_label,
|
||||
description=description,
|
||||
show_selector=show_selector,
|
||||
tile_size=self.TILE_SIZE,
|
||||
gradient=gradient,
|
||||
)
|
||||
tile.setFixedWidth(self.TILE_SIZE[0])
|
||||
tile.setMinimumHeight(self.TILE_SIZE[1])
|
||||
tile.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)
|
||||
if action_button:
|
||||
tile.action_button.clicked.connect(action_button)
|
||||
if show_selector and selector_items:
|
||||
tile.selector.addItems(selector_items)
|
||||
self.central_widget.layout.addWidget(tile)
|
||||
|
||||
# keep all tiles' main labels at a unified point size
|
||||
current_pt = tile.main_label.font().pointSize()
|
||||
if self._min_main_label_pt is None or current_pt < self._min_main_label_pt:
|
||||
# New global minimum – shrink every existing tile to this size
|
||||
self._min_main_label_pt = current_pt
|
||||
for t in self.tiles.values():
|
||||
f = t.main_label.font()
|
||||
f.setPointSize(self._min_main_label_pt)
|
||||
t.main_label.setFont(f)
|
||||
t.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
|
||||
elif current_pt > self._min_main_label_pt:
|
||||
# Tile is larger than global minimum – shrink it to match
|
||||
f = tile.main_label.font()
|
||||
f.setPointSize(self._min_main_label_pt)
|
||||
tile.main_label.setFont(f)
|
||||
tile.main_label.setFixedHeight(QFontMetrics(f).height() + 2)
|
||||
|
||||
self.tiles[name] = tile
|
||||
|
||||
def launch(
|
||||
self,
|
||||
launch_script: str,
|
||||
name: str | None = None,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
**kwargs,
|
||||
) -> QWidget | None:
|
||||
"""Launch the specified script. If the launch script creates a QWidget, it will be
|
||||
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
|
||||
as a separate window.
|
||||
|
||||
Args:
|
||||
launch_script(str): The name of the script to be launched.
|
||||
name(str): The name of the dock area.
|
||||
geometry(tuple): The geometry parameters to be passed to the dock area.
|
||||
Returns:
|
||||
QWidget: The created dock area.
|
||||
"""
|
||||
from bec_widgets.applications import bw_launch
|
||||
|
||||
with RPCRegister.delayed_broadcast() as rpc_register:
|
||||
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
|
||||
if name is not None:
|
||||
if name in existing_dock_areas:
|
||||
raise ValueError(
|
||||
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
else:
|
||||
name = "dock_area"
|
||||
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
|
||||
|
||||
if launch_script is None:
|
||||
launch_script = "dock_area"
|
||||
if not isinstance(launch_script, str):
|
||||
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
|
||||
|
||||
if launch_script == "custom_ui_file":
|
||||
ui_file = kwargs.pop("ui_file", None)
|
||||
if not ui_file:
|
||||
return None
|
||||
return self._launch_custom_ui_file(ui_file)
|
||||
|
||||
if launch_script == "auto_update":
|
||||
auto_update = kwargs.pop("auto_update", None)
|
||||
return self._launch_auto_update(auto_update)
|
||||
|
||||
if launch_script == "widget":
|
||||
widget = kwargs.pop("widget", None)
|
||||
if widget is None:
|
||||
raise ValueError("Widget name must be provided.")
|
||||
return self._launch_widget(widget)
|
||||
|
||||
launch = getattr(bw_launch, launch_script, None)
|
||||
if launch is None:
|
||||
raise ValueError(f"Launch script {launch_script} not found.")
|
||||
|
||||
result_widget = launch(name)
|
||||
result_widget.resize(result_widget.minimumSizeHint())
|
||||
# TODO Should we simply use the specified name as title here?
|
||||
result_widget.window().setWindowTitle(f"BEC - {name}")
|
||||
logger.info(f"Created new dock area: {name}")
|
||||
|
||||
if geometry is not None:
|
||||
result_widget.setGeometry(*geometry)
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindowNoRPC()
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {result_widget.objectName()}")
|
||||
window.show()
|
||||
return result_widget
|
||||
|
||||
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
|
||||
"""
|
||||
Load a custom .ui file. If the top-level widget is a MainWindow subclass,
|
||||
instantiate it directly; otherwise, embed it in a UILaunchWindow.
|
||||
"""
|
||||
if ui_file is None:
|
||||
raise ValueError("UI file must be provided for custom UI file launch.")
|
||||
filename = os.path.basename(ui_file).split(".")[0]
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(filename)
|
||||
|
||||
# Parse the UI to detect top-level widget class
|
||||
tree = ET.parse(ui_file)
|
||||
root = tree.getroot()
|
||||
# Check if the top-level widget is a QMainWindow
|
||||
widget = root.find("widget")
|
||||
if widget is None:
|
||||
raise ValueError("No widget found in the UI file.")
|
||||
|
||||
# Load the UI into a widget
|
||||
loader = UILoader(None)
|
||||
loaded = loader.loader(ui_file)
|
||||
|
||||
# Display the UI in a BECMainWindow
|
||||
if isinstance(loaded, BECMainWindow):
|
||||
window = loaded
|
||||
window.object_name = filename
|
||||
else:
|
||||
window = BECMainWindow(object_name=filename)
|
||||
window.setCentralWidget(loaded)
|
||||
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {filename}")
|
||||
window.show()
|
||||
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
|
||||
return window
|
||||
|
||||
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
|
||||
if auto_update in self.available_auto_updates:
|
||||
auto_update_cls = self.available_auto_updates[auto_update]
|
||||
window = auto_update_cls()
|
||||
else:
|
||||
|
||||
auto_update = "auto_updates"
|
||||
window = AutoUpdates()
|
||||
|
||||
window.resize(window.minimumSizeHint())
|
||||
QApplication.processEvents()
|
||||
window.setWindowTitle(f"BEC - {window.objectName()}")
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def _launch_widget(self, widget: type[BECWidget]) -> QWidget:
|
||||
name = pascal_to_snake(widget.__name__)
|
||||
|
||||
WidgetContainerUtils.raise_for_invalid_name(name)
|
||||
|
||||
window = BECMainWindowNoRPC()
|
||||
|
||||
widget_instance = widget(root_widget=True, object_name=name)
|
||||
assert isinstance(widget_instance, QWidget)
|
||||
QApplication.processEvents()
|
||||
|
||||
window.setCentralWidget(widget_instance)
|
||||
window.resize(window.minimumSizeHint())
|
||||
window.setWindowTitle(f"BEC - {widget_instance.objectName()}")
|
||||
window.show()
|
||||
return window
|
||||
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Change the theme of the application.
|
||||
"""
|
||||
for tile in self.tiles.values():
|
||||
tile.apply_theme(theme)
|
||||
|
||||
super().apply_theme(theme)
|
||||
|
||||
def _open_auto_update(self):
|
||||
"""
|
||||
Open the auto update window.
|
||||
"""
|
||||
if self.tiles["auto_update"].selector is None:
|
||||
auto_update = None
|
||||
else:
|
||||
auto_update = self.tiles["auto_update"].selector.currentText()
|
||||
if auto_update == "Default":
|
||||
auto_update = None
|
||||
return self.launch("auto_update", auto_update=auto_update)
|
||||
|
||||
def _open_widget(self):
|
||||
"""
|
||||
Open a widget from the available widgets.
|
||||
"""
|
||||
if self.tiles["widget"].selector is None:
|
||||
return
|
||||
widget = self.tiles["widget"].selector.currentText()
|
||||
if widget not in self.available_widgets:
|
||||
raise ValueError(f"Widget {widget} not found in available widgets.")
|
||||
return self.launch("widget", widget=self.available_widgets[widget])
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def _open_custom_ui_file(self):
|
||||
"""
|
||||
Open a file dialog to select a custom UI file and launch it.
|
||||
"""
|
||||
ui_file, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
|
||||
)
|
||||
self.launch("custom_ui_file", ui_file=ui_file)
|
||||
|
||||
@staticmethod
|
||||
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
|
||||
"""
|
||||
Load all available auto updates from the plugin repository.
|
||||
"""
|
||||
try:
|
||||
auto_updates = get_plugin_auto_updates()
|
||||
logger.info(f"Available auto updates: {auto_updates.keys()}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to load auto updates: {exc}")
|
||||
return {}
|
||||
return auto_updates
|
||||
|
||||
def show_launcher(self):
|
||||
"""
|
||||
Show the launcher window.
|
||||
"""
|
||||
self.show()
|
||||
|
||||
def hide_launcher(self):
|
||||
"""
|
||||
Hide the launcher window.
|
||||
"""
|
||||
self.hide()
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self.setFixedSize(self.size())
|
||||
|
||||
def _launcher_is_last_widget(self, connections: dict) -> bool:
|
||||
"""
|
||||
Check if the launcher is the last widget in the application.
|
||||
"""
|
||||
|
||||
remaining_connections = [
|
||||
connection for connection in connections.values() if connection.parent_id != self.gui_id
|
||||
]
|
||||
return len(remaining_connections) <= 4
|
||||
|
||||
def _turn_off_the_lights(self, connections: dict):
|
||||
"""
|
||||
If there is only one connection remaining, it is the launcher, so we show it.
|
||||
Once the launcher is closed as the last window, we quit the application.
|
||||
"""
|
||||
if self._launcher_is_last_widget(connections):
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True) # type: ignore
|
||||
return
|
||||
|
||||
self.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False) # type: ignore
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""
|
||||
Close the launcher window.
|
||||
"""
|
||||
connections = self.register.list_all_connections()
|
||||
if self._launcher_is_last_widget(connections):
|
||||
event.accept()
|
||||
return
|
||||
|
||||
event.ignore()
|
||||
self.hide()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
launcher = LaunchWindow()
|
||||
launcher.show()
|
||||
sys.exit(app.exec())
|
||||
229
bec_widgets/applications/main_app.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
|
||||
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar import SideBar
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
|
||||
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
|
||||
class BECMainApp(BECMainWindow):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*args,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
show_examples: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self._show_examples = bool(show_examples)
|
||||
|
||||
# --- Compose central UI (sidebar + stack)
|
||||
self.sidebar = SideBar(parent=self, anim_duration=anim_duration)
|
||||
self.stack = QStackedWidget(self)
|
||||
|
||||
container = QWidget(self)
|
||||
layout = QHBoxLayout(container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.sidebar, 0)
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(container)
|
||||
|
||||
# Mapping for view switching
|
||||
self._view_index: dict[str, int] = {}
|
||||
self._current_view_id: str | None = None
|
||||
self.sidebar.view_selected.connect(self._on_view_selected)
|
||||
|
||||
self._add_views()
|
||||
|
||||
def _add_views(self):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = AdvancedDockArea(
|
||||
self, profile_namespace="main_workspace", auto_profile_namespace=False
|
||||
)
|
||||
self.ads.setObjectName("MainWorkspace")
|
||||
self.device_manager = DeviceManagerWidget(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
|
||||
self.add_view(
|
||||
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||
)
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
title="Device Manager",
|
||||
id="device_manager",
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
self.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=self.developer_view,
|
||||
id="developer_view",
|
||||
exclusive=True,
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
waveform_view_popup = WaveformViewPopup(
|
||||
parent=self, id="waveform_view_popup", title="Waveform Plot"
|
||||
)
|
||||
waveform_view_stack = WaveformViewInline(
|
||||
parent=self, id="waveform_view_stack", title="Waveform Plot"
|
||||
)
|
||||
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform With Popup",
|
||||
id="waveform_popup",
|
||||
widget=waveform_view_popup,
|
||||
mini_text="Popup",
|
||||
)
|
||||
self.add_view(
|
||||
icon="show_chart",
|
||||
title="Waveform InLine Stack",
|
||||
id="waveform_stack",
|
||||
widget=waveform_view_stack,
|
||||
mini_text="Stack",
|
||||
)
|
||||
|
||||
self.set_current("dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# --- Public API ------------------------------------------------------
|
||||
def add_section(self, title: str, id: str, position: int | None = None):
|
||||
return self.sidebar.add_section(title, id, position)
|
||||
|
||||
def add_separator(self):
|
||||
return self.sidebar.add_separator()
|
||||
|
||||
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
|
||||
return self.sidebar.add_dark_mode_item(id=id, position=position)
|
||||
|
||||
def add_view(
|
||||
self,
|
||||
*,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
widget: QWidget,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Register a view in the stack and create a matching nav item in the sidebar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the view/item.
|
||||
widget(QWidget): The widget to add to the stack.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
|
||||
|
||||
"""
|
||||
item = self.sidebar.add_item(
|
||||
icon=icon,
|
||||
title=title,
|
||||
id=id,
|
||||
mini_text=mini_text,
|
||||
position=position,
|
||||
from_top=from_top,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
)
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
view_widget = widget
|
||||
view_widget.view_id = id
|
||||
view_widget.view_title = title
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||
|
||||
idx = self.stack.addWidget(view_widget)
|
||||
self._view_index[id] = idx
|
||||
return item
|
||||
|
||||
def set_current(self, id: str) -> None:
|
||||
if id in self._view_index:
|
||||
self.sidebar.activate_item(id)
|
||||
|
||||
# Internal: route sidebar selection to the stack
|
||||
def _on_view_selected(self, vid: str) -> None:
|
||||
# Determine current view
|
||||
current_index = self.stack.currentIndex()
|
||||
current_view = (
|
||||
self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None
|
||||
)
|
||||
|
||||
# Ask current view whether we may leave
|
||||
if current_view is not None and hasattr(current_view, "on_exit"):
|
||||
may_leave = current_view.on_exit()
|
||||
if may_leave is False:
|
||||
# Veto: restore previous highlight without re-emitting selection
|
||||
if self._current_view_id is not None:
|
||||
self.sidebar.activate_item(self._current_view_id, emit_signal=False)
|
||||
return
|
||||
|
||||
# Proceed with switch
|
||||
idx = self._view_index.get(vid)
|
||||
if idx is None or not (0 <= idx < self.stack.count()):
|
||||
return
|
||||
self.stack.setCurrentIndex(idx)
|
||||
new_view = self.stack.widget(idx)
|
||||
self._current_view_id = vid
|
||||
if hasattr(new_view, "on_enter"):
|
||||
new_view.on_enter()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description="BEC Main Application")
|
||||
parser.add_argument(
|
||||
"--examples", action="store_true", help="Show the Examples section with waveform demo views"
|
||||
)
|
||||
# Let Qt consume the remaining args
|
||||
args, qt_args = parser.parse_known_args(sys.argv[1:])
|
||||
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
w.resize(width, height)
|
||||
w.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
114
bec_widgets/applications/navigation_centre/reveal_animator.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation
|
||||
from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget
|
||||
|
||||
ANIMATION_DURATION = 500 # ms
|
||||
|
||||
|
||||
class RevealAnimator:
|
||||
"""Animate reveal/hide for a single widget using opacity + max W/H.
|
||||
|
||||
This keeps the widget always visible to avoid jitter from setVisible().
|
||||
Collapsed state: opacity=0, maxW=0, maxH=0.
|
||||
Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: QWidget,
|
||||
duration: int = ANIMATION_DURATION,
|
||||
easing: QEasingCurve.Type = QEasingCurve.InOutCubic,
|
||||
initially_revealed: bool = False,
|
||||
*,
|
||||
animate_opacity: bool = True,
|
||||
animate_width: bool = True,
|
||||
animate_height: bool = True,
|
||||
):
|
||||
self.widget = widget
|
||||
self.animate_opacity = animate_opacity
|
||||
self.animate_width = animate_width
|
||||
self.animate_height = animate_height
|
||||
# Opacity effect
|
||||
self.fx = QGraphicsOpacityEffect(widget)
|
||||
widget.setGraphicsEffect(self.fx)
|
||||
# Animations
|
||||
self.opacity_anim = (
|
||||
QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None
|
||||
)
|
||||
self.width_anim = (
|
||||
QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None
|
||||
)
|
||||
self.height_anim = (
|
||||
QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None
|
||||
)
|
||||
for anim in (self.opacity_anim, self.width_anim, self.height_anim):
|
||||
if anim is not None:
|
||||
anim.setDuration(duration)
|
||||
anim.setEasingCurve(easing)
|
||||
# Initialize to requested state
|
||||
self.set_immediate(initially_revealed)
|
||||
|
||||
def _natural_sizes(self) -> tuple[int, int]:
|
||||
sh = self.widget.sizeHint()
|
||||
w = max(sh.width(), 1)
|
||||
h = max(sh.height(), 1)
|
||||
return w, h
|
||||
|
||||
def set_immediate(self, revealed: bool):
|
||||
"""
|
||||
Immediately set the widget to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
revealed(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
w, h = self._natural_sizes()
|
||||
if self.animate_opacity:
|
||||
self.fx.setOpacity(1.0 if revealed else 0.0)
|
||||
if self.animate_width:
|
||||
self.widget.setMaximumWidth(w if revealed else 0)
|
||||
if self.animate_height:
|
||||
self.widget.setMaximumHeight(h if revealed else 0)
|
||||
|
||||
def setup(self, reveal: bool):
|
||||
"""
|
||||
Prepare animations to transition to the target revealed/collapsed state.
|
||||
|
||||
Args:
|
||||
reveal(bool): True to reveal, False to collapse.
|
||||
"""
|
||||
# Prepare animations from current state to target
|
||||
target_w, target_h = self._natural_sizes()
|
||||
if self.opacity_anim is not None:
|
||||
self.opacity_anim.setStartValue(self.fx.opacity())
|
||||
self.opacity_anim.setEndValue(1.0 if reveal else 0.0)
|
||||
if self.width_anim is not None:
|
||||
self.width_anim.setStartValue(self.widget.maximumWidth())
|
||||
self.width_anim.setEndValue(target_w if reveal else 0)
|
||||
if self.height_anim is not None:
|
||||
self.height_anim.setStartValue(self.widget.maximumHeight())
|
||||
self.height_anim.setEndValue(target_h if reveal else 0)
|
||||
|
||||
def add_to_group(self, group: QParallelAnimationGroup):
|
||||
"""
|
||||
Add the prepared animations to the given animation group.
|
||||
|
||||
Args:
|
||||
group(QParallelAnimationGroup): The animation group to add to.
|
||||
"""
|
||||
if self.opacity_anim is not None:
|
||||
group.addAnimation(self.opacity_anim)
|
||||
if self.width_anim is not None:
|
||||
group.addAnimation(self.width_anim)
|
||||
if self.height_anim is not None:
|
||||
group.addAnimation(self.height_anim)
|
||||
|
||||
def animations(self):
|
||||
"""
|
||||
Get a list of all animations (non-None) for adding to a group.
|
||||
"""
|
||||
return [
|
||||
anim
|
||||
for anim in (self.opacity_anim, self.height_anim, self.width_anim)
|
||||
if anim is not None
|
||||
]
|
||||
357
bec_widgets/applications/navigation_centre/side_bar.py
Normal file
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtWidgets
|
||||
from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QGraphicsOpacityEffect,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QScrollArea,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty, SafeSlot
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
|
||||
from bec_widgets.applications.navigation_centre.side_bar_components import (
|
||||
DarkModeNavItem,
|
||||
NavigationItem,
|
||||
SectionHeader,
|
||||
SideBarSeparator,
|
||||
)
|
||||
|
||||
|
||||
class SideBar(QScrollArea):
|
||||
view_selected = Signal(str)
|
||||
toggled = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title: str = "Control Panel",
|
||||
collapsed_width: int = 56,
|
||||
expanded_width: int = 250,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("SideBar")
|
||||
|
||||
# private attributes
|
||||
self._is_expanded = False
|
||||
self._collapsed_width = collapsed_width
|
||||
self._expanded_width = expanded_width
|
||||
self._anim_duration = anim_duration
|
||||
|
||||
# containers
|
||||
self.components = {}
|
||||
self._item_opts: dict[str, dict] = {}
|
||||
|
||||
# Scroll area properties
|
||||
self.setWidgetResizable(True)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||||
self.setFixedWidth(self._collapsed_width)
|
||||
|
||||
# Content widget holding buttons for switching views
|
||||
self.content = QWidget(self)
|
||||
self.content_layout = QVBoxLayout(self.content)
|
||||
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.content_layout.setSpacing(4)
|
||||
self.setWidget(self.content)
|
||||
|
||||
# Track active navigation item
|
||||
self._active_id = None
|
||||
|
||||
# Top row with title and toggle button
|
||||
self.toggle_row = QWidget(self)
|
||||
self.toggle_row_layout = QHBoxLayout(self.toggle_row)
|
||||
|
||||
self.title_label = QLabel(title, self)
|
||||
self.title_label.setObjectName("TopTitle")
|
||||
self.title_label.setStyleSheet("font-weight: 600;")
|
||||
self.title_fx = QGraphicsOpacityEffect(self.title_label)
|
||||
self.title_label.setGraphicsEffect(self.title_fx)
|
||||
self.title_fx.setOpacity(0.0)
|
||||
self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
self.toggle = QToolButton(self)
|
||||
self.toggle.setCheckable(False)
|
||||
self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False))
|
||||
self.toggle.clicked.connect(self.on_expand)
|
||||
|
||||
self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
|
||||
# To push the content up always
|
||||
self._bottom_spacer = QtWidgets.QSpacerItem(
|
||||
0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
|
||||
# Add core widgets to layout
|
||||
self.content_layout.addWidget(self.toggle_row)
|
||||
self.content_layout.addItem(self._bottom_spacer)
|
||||
|
||||
# Animations
|
||||
self.width_anim = QPropertyAnimation(self, b"bar_width")
|
||||
self.width_anim.setDuration(self._anim_duration)
|
||||
self.width_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.title_anim = QPropertyAnimation(self.title_fx, b"opacity")
|
||||
self.title_anim.setDuration(self._anim_duration)
|
||||
self.title_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
self.group = QParallelAnimationGroup(self)
|
||||
self.group.addAnimation(self.width_anim)
|
||||
self.group.addAnimation(self.title_anim)
|
||||
self.group.finished.connect(self._on_anim_finished)
|
||||
|
||||
app = QtWidgets.QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"):
|
||||
app.theme.theme_changed.connect(self._on_theme_changed)
|
||||
|
||||
@SafeProperty(int)
|
||||
def bar_width(self) -> int:
|
||||
"""
|
||||
Get the current width of the side bar.
|
||||
|
||||
Returns:
|
||||
int: The current width of the side bar.
|
||||
"""
|
||||
return self.width()
|
||||
|
||||
@bar_width.setter
|
||||
def bar_width(self, width: int):
|
||||
"""
|
||||
Set the width of the side bar.
|
||||
|
||||
Args:
|
||||
width(int): The new width of the side bar.
|
||||
"""
|
||||
self.setFixedWidth(width)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def is_expanded(self) -> bool:
|
||||
"""
|
||||
Check if the side bar is expanded.
|
||||
|
||||
Returns:
|
||||
bool: True if the side bar is expanded, False otherwise.
|
||||
"""
|
||||
return self._is_expanded
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def on_expand(self):
|
||||
"""
|
||||
Toggle the expansion state of the side bar.
|
||||
"""
|
||||
self._is_expanded = not self._is_expanded
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
|
||||
if self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
self.group.stop()
|
||||
# Setting limits for animations of the side bar
|
||||
self.width_anim.setStartValue(self.width())
|
||||
self.width_anim.setEndValue(
|
||||
self._expanded_width if self._is_expanded else self._collapsed_width
|
||||
)
|
||||
self.title_anim.setStartValue(self.title_fx.opacity())
|
||||
self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0)
|
||||
|
||||
# Setting limits for animations of the components
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "setup_animations"):
|
||||
comp.setup_animations(self._is_expanded)
|
||||
|
||||
self.group.start()
|
||||
if self._is_expanded:
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
self.toggled.emit(self._is_expanded)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_anim_finished(self):
|
||||
if not self._is_expanded:
|
||||
self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter)
|
||||
# TODO do not like this trick, but it is what it is for now
|
||||
self.title_label.setVisible(self._is_expanded)
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "set_visible"):
|
||||
comp.set_visible(self._is_expanded)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_theme_changed(self, theme_name: str):
|
||||
# Refresh toggle arrow icon so it picks up the new theme
|
||||
self.toggle.setIcon(
|
||||
material_icon(
|
||||
"keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right",
|
||||
convert_to_pixmap=False,
|
||||
)
|
||||
)
|
||||
# Refresh each component that supports it
|
||||
for comp in self.components.values():
|
||||
if hasattr(comp, "refresh_theme"):
|
||||
comp.refresh_theme()
|
||||
else:
|
||||
comp.style().unpolish(comp)
|
||||
comp.style().polish(comp)
|
||||
comp.update()
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader:
|
||||
"""
|
||||
Add a section header to the side bar.
|
||||
|
||||
Args:
|
||||
title(str): The title of the section.
|
||||
id(str): Unique ID for the section.
|
||||
position(int, optional): Position to insert the section header.
|
||||
|
||||
Returns:
|
||||
SectionHeader: The created section header.
|
||||
|
||||
"""
|
||||
header = SectionHeader(self, title, anim_duration=self._anim_duration)
|
||||
position = position if position is not None else self.content_layout.count() - 1
|
||||
self.content_layout.insertWidget(position, header)
|
||||
for anim in header.animations:
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = header
|
||||
return header
|
||||
|
||||
def add_separator(
|
||||
self, *, from_top: bool = True, position: int | None = None
|
||||
) -> SideBarSeparator:
|
||||
"""
|
||||
Add a separator line to the side bar. Separators are treated like regular
|
||||
items; you can place multiple separators anywhere using `from_top` and `position`.
|
||||
"""
|
||||
line = SideBarSeparator(self)
|
||||
line.setStyleSheet("margin:12px;")
|
||||
self._insert_nav_item(line, from_top=from_top, position=position)
|
||||
return line
|
||||
|
||||
def add_item(
|
||||
self,
|
||||
icon: str,
|
||||
title: str,
|
||||
id: str,
|
||||
mini_text: str | None = None,
|
||||
position: int | None = None,
|
||||
*,
|
||||
from_top: bool = True,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
) -> NavigationItem:
|
||||
"""
|
||||
Add a navigation item to the side bar.
|
||||
|
||||
Args:
|
||||
icon(str): Icon name for the nav item.
|
||||
title(str): Title for the nav item.
|
||||
id(str): Unique ID for the nav item.
|
||||
mini_text(str, optional): Short text for the nav item when sidebar is collapsed.
|
||||
position(int, optional): Position to insert the nav item.
|
||||
from_top(bool, optional): Whether to count position from the top or bottom.
|
||||
toggleable(bool, optional): Whether the nav item is toggleable.
|
||||
exclusive(bool, optional): Whether the nav item is exclusive.
|
||||
|
||||
Returns:
|
||||
NavigationItem: The created navigation item.
|
||||
"""
|
||||
item = NavigationItem(
|
||||
parent=self,
|
||||
title=title,
|
||||
icon_name=icon,
|
||||
mini_text=mini_text,
|
||||
toggleable=toggleable,
|
||||
exclusive=exclusive,
|
||||
anim_duration=self._anim_duration,
|
||||
)
|
||||
self._insert_nav_item(item, from_top=from_top, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
# Connect activation to activation logic, passing id unchanged
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def activate_item(self, target_id: str, *, emit_signal: bool = True):
|
||||
target = self.components.get(target_id)
|
||||
if target is None:
|
||||
return
|
||||
# Non-toggleable acts like an action: do not change any toggled states
|
||||
if hasattr(target, "toggleable") and not target.toggleable:
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
return
|
||||
|
||||
is_exclusive = getattr(target, "exclusive", True)
|
||||
if is_exclusive:
|
||||
# Radio-like behavior among exclusive items only
|
||||
for comp_id, comp in self.components.items():
|
||||
if not isinstance(comp, NavigationItem):
|
||||
continue
|
||||
if comp is target:
|
||||
comp.set_active(True)
|
||||
else:
|
||||
# Only untoggle other items that are also exclusive
|
||||
if getattr(comp, "exclusive", True):
|
||||
comp.set_active(False)
|
||||
# Leave non-exclusive items as they are
|
||||
else:
|
||||
# Non-exclusive toggles independently
|
||||
target.set_active(not target.is_active())
|
||||
|
||||
self._active_id = target_id
|
||||
if emit_signal:
|
||||
self.view_selected.emit(target_id)
|
||||
|
||||
def add_dark_mode_item(
|
||||
self, id: str = "dark_mode", position: int | None = None
|
||||
) -> DarkModeNavItem:
|
||||
"""
|
||||
Add a dark mode toggle item to the side bar.
|
||||
|
||||
Args:
|
||||
id(str): Unique ID for the dark mode item.
|
||||
position(int, optional): Position to insert the dark mode item.
|
||||
|
||||
Returns:
|
||||
DarkModeNavItem: The created dark mode navigation item.
|
||||
"""
|
||||
item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration)
|
||||
# compute bottom insertion point (same semantics as from_top=False)
|
||||
self._insert_nav_item(item, from_top=False, position=position)
|
||||
for anim in item.build_animations():
|
||||
self.group.addAnimation(anim)
|
||||
self.components[id] = item
|
||||
item.activated.connect(lambda id=id: self.activate_item(id))
|
||||
return item
|
||||
|
||||
def _insert_nav_item(
|
||||
self, item: QWidget, *, from_top: bool = True, position: int | None = None
|
||||
):
|
||||
if from_top:
|
||||
base_index = self.content_layout.indexOf(self._bottom_spacer)
|
||||
pos = base_index if position is None else min(base_index, position)
|
||||
else:
|
||||
base = self.content_layout.indexOf(self._bottom_spacer) + 1
|
||||
pos = base if position is None else base + max(0, position)
|
||||
self.content_layout.insertWidget(pos, item)
|
||||
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import SafeProperty
|
||||
from bec_widgets.applications.navigation_centre.reveal_animator import (
|
||||
ANIMATION_DURATION,
|
||||
RevealAnimator,
|
||||
)
|
||||
|
||||
|
||||
def get_on_primary():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("ON_PRIMARY")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
def get_fg():
|
||||
app = QApplication.instance()
|
||||
if app is not None and hasattr(app, "theme"):
|
||||
return app.theme.color("FG")
|
||||
return "#FFFFFF"
|
||||
|
||||
|
||||
class SideBarSeparator(QFrame):
|
||||
"""A horizontal line separator for use in SideBar."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SideBarSeparator")
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.setFixedHeight(2)
|
||||
self.setProperty("variant", "separator")
|
||||
|
||||
|
||||
class SectionHeader(QWidget):
|
||||
"""A section header with a label and a horizontal line below."""
|
||||
|
||||
def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SectionHeader")
|
||||
|
||||
self.lbl = QLabel(text, self)
|
||||
self.lbl.setObjectName("SectionHeaderLabel")
|
||||
self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False)
|
||||
|
||||
self.line = SideBarSeparator(self)
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
# keep your margins/spacing preferences here if needed
|
||||
lay.setContentsMargins(12, 0, 12, 0)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.lbl)
|
||||
lay.addWidget(self.line)
|
||||
|
||||
self.animations = self.build_animations()
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return self._reveal.animations()
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self._reveal.setup(expanded)
|
||||
|
||||
|
||||
class NavigationItem(QWidget):
|
||||
"""A nav tile with an icon + labels and an optional expandable body.
|
||||
Provides animations for collapsed/expanded sidebar states via
|
||||
build_animations()/setup_animations(), similar to SectionHeader.
|
||||
"""
|
||||
|
||||
activated = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
title: str,
|
||||
icon_name: str,
|
||||
mini_text: str | None = None,
|
||||
toggleable: bool = True,
|
||||
exclusive: bool = True,
|
||||
anim_duration: int = ANIMATION_DURATION,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.setObjectName("NavigationItem")
|
||||
|
||||
# Private attributes
|
||||
self._title = title
|
||||
self._icon_name = icon_name
|
||||
self._mini_text = mini_text or title
|
||||
self._toggleable = toggleable
|
||||
self._toggled = False
|
||||
self._exclusive = exclusive
|
||||
|
||||
# Main Icon
|
||||
self.icon_btn = QToolButton(self)
|
||||
self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False))
|
||||
self.icon_btn.setAutoRaise(True)
|
||||
self._icon_size_collapsed = QtCore.QSize(20, 20)
|
||||
self._icon_size_expanded = QtCore.QSize(26, 26)
|
||||
self.icon_btn.setIconSize(self._icon_size_collapsed)
|
||||
# Remove QToolButton hover/pressed background/outline
|
||||
self.icon_btn.setStyleSheet(
|
||||
"""
|
||||
QToolButton:hover { background: transparent; border: none; }
|
||||
QToolButton:pressed { background: transparent; border: none; }
|
||||
"""
|
||||
)
|
||||
|
||||
# Mini label below icon
|
||||
self.mini_lbl = QLabel(self._mini_text, self)
|
||||
self.mini_lbl.setObjectName("NavMiniLabel")
|
||||
self.mini_lbl.setAlignment(Qt.AlignCenter)
|
||||
self.mini_lbl.setStyleSheet("font-size: 10px;")
|
||||
self.reveal_mini_lbl = RevealAnimator(
|
||||
widget=self.mini_lbl,
|
||||
initially_revealed=True,
|
||||
animate_width=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
|
||||
# Container for icon + mini label
|
||||
self.mini_icon = QWidget(self)
|
||||
mini_lay = QVBoxLayout(self.mini_icon)
|
||||
mini_lay.setContentsMargins(0, 2, 0, 2)
|
||||
mini_lay.setSpacing(2)
|
||||
mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter)
|
||||
mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter)
|
||||
|
||||
# Title label
|
||||
self.title_lbl = QLabel(self._title, self)
|
||||
self.title_lbl.setObjectName("NavTitleLabel")
|
||||
self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
self.title_lbl.setStyleSheet("font-size: 13px;")
|
||||
self.reveal_title_lbl = RevealAnimator(
|
||||
widget=self.title_lbl,
|
||||
initially_revealed=False,
|
||||
animate_height=False,
|
||||
duration=anim_duration,
|
||||
)
|
||||
self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift
|
||||
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(12, 2, 12, 2)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop)
|
||||
lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter)
|
||||
|
||||
self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize")
|
||||
self.icon_size_anim.setDuration(anim_duration)
|
||||
self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
# Connect icon button to emit activation
|
||||
self.icon_btn.clicked.connect(self._emit_activated)
|
||||
self.setMouseTracking(True)
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Return whether the item is currently active/selected."""
|
||||
return self.property("toggled") is True
|
||||
|
||||
def build_animations(self) -> list[QPropertyAnimation]:
|
||||
"""
|
||||
Build and return animations for expanding/collapsing the sidebar.
|
||||
|
||||
Returns:
|
||||
list[QPropertyAnimation]: List of animations.
|
||||
"""
|
||||
return (
|
||||
self.reveal_title_lbl.animations()
|
||||
+ self.reveal_mini_lbl.animations()
|
||||
+ [self.icon_size_anim]
|
||||
)
|
||||
|
||||
def setup_animations(self, expanded: bool):
|
||||
"""
|
||||
Setup animations for expanding/collapsing the sidebar.
|
||||
|
||||
Args:
|
||||
expanded(bool): True if the sidebar is expanded, False if collapsed.
|
||||
"""
|
||||
self.reveal_mini_lbl.setup(not expanded)
|
||||
self.reveal_title_lbl.setup(expanded)
|
||||
self.icon_size_anim.setStartValue(self.icon_btn.iconSize())
|
||||
self.icon_size_anim.setEndValue(
|
||||
self._icon_size_expanded if expanded else self._icon_size_collapsed
|
||||
)
|
||||
|
||||
def set_visible(self, visible: bool):
|
||||
"""Set visibility of the title label."""
|
||||
self.title_lbl.setVisible(visible)
|
||||
|
||||
def _emit_activated(self):
|
||||
self.activated.emit()
|
||||
|
||||
def set_active(self, active: bool):
|
||||
"""
|
||||
Set the active/selected state of the item.
|
||||
|
||||
Args:
|
||||
active(bool): True to set active, False to deactivate.
|
||||
"""
|
||||
self.setProperty("toggled", active)
|
||||
self.toggled = active
|
||||
# ensure style refresh
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.activated.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggleable(self) -> bool:
|
||||
"""
|
||||
Whether the item is toggleable (like a button) or not (like an action).
|
||||
|
||||
Returns:
|
||||
bool: True if toggleable, False otherwise.
|
||||
"""
|
||||
return self._toggleable
|
||||
|
||||
@toggleable.setter
|
||||
def toggleable(self, value: bool):
|
||||
"""
|
||||
Set whether the item is toggleable (like a button) or not (like an action).
|
||||
Args:
|
||||
value(bool): True to make toggleable, False otherwise.
|
||||
"""
|
||||
self._toggleable = bool(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def toggled(self) -> bool:
|
||||
"""
|
||||
Whether the item is currently toggled/selected.
|
||||
|
||||
Returns:
|
||||
bool: True if toggled, False otherwise.
|
||||
"""
|
||||
return self._toggled
|
||||
|
||||
@toggled.setter
|
||||
def toggled(self, value: bool):
|
||||
"""
|
||||
Set whether the item is currently toggled/selected.
|
||||
|
||||
Args:
|
||||
value(bool): True to set toggled, False to untoggle.
|
||||
"""
|
||||
self._toggled = value
|
||||
if value:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False
|
||||
)
|
||||
else:
|
||||
new_icon = material_icon(
|
||||
self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False
|
||||
)
|
||||
self.icon_btn.setIcon(new_icon)
|
||||
# Re-polish so QSS applies correct colors to icon/labels
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
Whether the item is exclusive in its toggle group.
|
||||
|
||||
Returns:
|
||||
bool: True if exclusive, False otherwise.
|
||||
"""
|
||||
return self._exclusive
|
||||
|
||||
@exclusive.setter
|
||||
def exclusive(self, value: bool):
|
||||
"""
|
||||
Set whether the item is exclusive in its toggle group.
|
||||
|
||||
Args:
|
||||
value(bool): True to make exclusive, False otherwise.
|
||||
"""
|
||||
self._exclusive = bool(value)
|
||||
|
||||
def refresh_theme(self):
|
||||
# Recompute icon/label colors according to current theme and state
|
||||
# Trigger the toggled setter to rebuild the icon with the correct color
|
||||
self.toggled = self._toggled
|
||||
# Ensure QSS-driven text/icon colors refresh
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
|
||||
class DarkModeNavItem(NavigationItem):
|
||||
"""Bottom action item that toggles app theme and updates its icon/text."""
|
||||
|
||||
def __init__(
|
||||
self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION
|
||||
):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
title="Dark mode",
|
||||
icon_name="dark_mode",
|
||||
mini_text="Dark",
|
||||
toggleable=False, # action-like, no selection highlight changes
|
||||
exclusive=False,
|
||||
anim_duration=anim_duration,
|
||||
)
|
||||
self._id = id
|
||||
self._sync_from_qapp_theme()
|
||||
self.activated.connect(self.toggle_theme)
|
||||
|
||||
def _qapp_dark_enabled(self) -> bool:
|
||||
qapp = QApplication.instance()
|
||||
return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark")
|
||||
|
||||
def _sync_from_qapp_theme(self):
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
# Update labels
|
||||
self.title_lbl.setText("Light mode" if is_dark else "Dark mode")
|
||||
self.mini_lbl.setText("Light" if is_dark else "Dark")
|
||||
# Update icon
|
||||
self.icon_btn.setIcon(
|
||||
material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False)
|
||||
)
|
||||
|
||||
def refresh_theme(self):
|
||||
self._sync_from_qapp_theme()
|
||||
for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl):
|
||||
w.style().unpolish(w)
|
||||
w.style().polish(w)
|
||||
w.update()
|
||||
|
||||
def toggle_theme(self):
|
||||
"""Toggle application theme and update icon/text."""
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
is_dark = self._qapp_dark_enabled()
|
||||
|
||||
apply_theme("light" if is_dark else "dark")
|
||||
self._sync_from_qapp_theme()
|
||||
@@ -0,0 +1,60 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
|
||||
|
||||
class DeveloperView(ViewBase):
|
||||
"""
|
||||
A view for users to write scripts and macros and execute them within the application.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.developer_widget = DeveloperWidget(parent=self)
|
||||
self.set_content(self.developer_widget)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||
)
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,383 @@
|
||||
import re
|
||||
|
||||
import markdown
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.script_executor import upload_script
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtGui import QKeySequence, QShortcut
|
||||
from qtpy.QtWidgets import QTextEdit
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
|
||||
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
def markdown_to_html(md_text: str) -> str:
|
||||
"""Convert Markdown with syntax highlighting to HTML (Qt-compatible)."""
|
||||
|
||||
# Preprocess: convert consecutive >>> lines to Python code blocks
|
||||
def replace_python_examples(match):
|
||||
indent = match.group(1)
|
||||
examples = match.group(2)
|
||||
# Remove >>> prefix and clean up the code
|
||||
lines = []
|
||||
for line in examples.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(">>> "):
|
||||
lines.append(line[4:]) # Remove '>>> '
|
||||
elif line.startswith(">>>"):
|
||||
lines.append(line[3:]) # Remove '>>>'
|
||||
code = "\n".join(lines)
|
||||
|
||||
return f"{indent}```python\n{indent}{code}\n{indent}```"
|
||||
|
||||
# Match one or more consecutive >>> lines (with same indentation)
|
||||
pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)"
|
||||
md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE)
|
||||
|
||||
extensions = ["fenced_code", "codehilite", "tables", "sane_lists"]
|
||||
html = markdown.markdown(
|
||||
md_text,
|
||||
extensions=extensions,
|
||||
extension_configs={
|
||||
"codehilite": {"linenums": False, "guess_lang": False, "noclasses": True}
|
||||
},
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
# Remove hardcoded background colors that conflict with themes
|
||||
html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html)
|
||||
html = re.sub(r"background: #[^;]*;", "", html)
|
||||
|
||||
# Add CSS to force code blocks to wrap
|
||||
css = """
|
||||
<style>
|
||||
pre, code {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
.codehilite pre {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
return css + html
|
||||
|
||||
|
||||
class DeveloperWidget(DockAreaWidget):
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, variant="compact", **kwargs)
|
||||
|
||||
# Promote toolbar above the dock manager provided by the base class
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_developer_toolbar()
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
# Initialize the widgets
|
||||
self.explorer = IDEExplorer(self)
|
||||
self.explorer.setObjectName("Explorer")
|
||||
self.console = WebConsole(self)
|
||||
self.console.setObjectName("Console")
|
||||
self.terminal = WebConsole(self, startup_cmd="")
|
||||
self.terminal.setObjectName("Terminal")
|
||||
self.monaco = MonacoDock(self)
|
||||
self.monaco.setObjectName("MonacoEditor")
|
||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||
self.plotting_ads = AdvancedDockArea(
|
||||
self,
|
||||
mode="plot",
|
||||
default_add_direction="bottom",
|
||||
profile_namespace="developer_plotting",
|
||||
auto_profile_namespace=False,
|
||||
enable_profile_management=False,
|
||||
variant="compact",
|
||||
)
|
||||
self.plotting_ads.setObjectName("PlottingArea")
|
||||
self.signature_help = QTextEdit(self)
|
||||
self.signature_help.setObjectName("Signature Help")
|
||||
self.signature_help.setAcceptRichText(True)
|
||||
self.signature_help.setReadOnly(True)
|
||||
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
|
||||
opt = self.signature_help.document().defaultTextOption()
|
||||
opt.setWrapMode(opt.WrapMode.WrapAnywhere)
|
||||
self.signature_help.document().setDefaultTextOption(opt)
|
||||
self.monaco.signature_help.connect(
|
||||
lambda text: self.signature_help.setHtml(markdown_to_html(text))
|
||||
)
|
||||
self._current_script_id: str | None = None
|
||||
self.script_editor_tab = None
|
||||
|
||||
self._initialize_layout()
|
||||
|
||||
# Connect editor signals
|
||||
self.explorer.file_open_requested.connect(self._open_new_file)
|
||||
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
|
||||
|
||||
self.toolbar.show_bundles(["save", "execution", "settings"])
|
||||
|
||||
def _initialize_layout(self) -> None:
|
||||
"""Create the default dock arrangement for the developer workspace."""
|
||||
|
||||
# Monaco editor as the central dock
|
||||
self.monaco_dock = self.new(
|
||||
self.monaco,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
show_title_bar=False,
|
||||
show_settings_action=False,
|
||||
title_buttons={"float": False, "close": False, "menu": False},
|
||||
# promote_central=True,
|
||||
)
|
||||
|
||||
# Explorer on the left without a title bar
|
||||
self.explorer_dock = self.new(
|
||||
self.explorer,
|
||||
where="left",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
show_title_bar=False,
|
||||
)
|
||||
|
||||
# Console and terminal tabbed along the bottom
|
||||
self.console_dock = self.new(
|
||||
self.console,
|
||||
relative_to=self.monaco_dock,
|
||||
where="bottom",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
title_buttons={"float": True, "close": False},
|
||||
)
|
||||
self.terminal_dock = self.new(
|
||||
self.terminal,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
tab_with=self.console_dock,
|
||||
return_dock=True,
|
||||
title_buttons={"float": False, "close": False},
|
||||
)
|
||||
|
||||
# Plotting area on the right with signature help tabbed alongside
|
||||
self.plotting_ads_dock = self.new(
|
||||
self.plotting_ads,
|
||||
where="right",
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
return_dock=True,
|
||||
title_buttons={"float": True},
|
||||
)
|
||||
self.signature_dock = self.new(
|
||||
self.signature_help,
|
||||
closable=False,
|
||||
floatable=False,
|
||||
movable=False,
|
||||
tab_with=self.plotting_ads_dock,
|
||||
return_dock=True,
|
||||
title_buttons={"float": False, "close": False},
|
||||
)
|
||||
|
||||
self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3])
|
||||
|
||||
def init_developer_toolbar(self):
|
||||
"""Initialize the developer toolbar with necessary actions and widgets."""
|
||||
save_button = MaterialIconAction(
|
||||
icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self
|
||||
)
|
||||
save_button.action.triggered.connect(self.on_save)
|
||||
self.toolbar.components.add_safe("save", save_button)
|
||||
|
||||
save_as_button = MaterialIconAction(
|
||||
icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe("save_as", save_as_button)
|
||||
save_as_button.action.triggered.connect(self.on_save_as)
|
||||
|
||||
save_bundle = ToolbarBundle("save", self.toolbar.components)
|
||||
save_bundle.add_action("save")
|
||||
save_bundle.add_action("save_as")
|
||||
self.toolbar.add_bundle(save_bundle)
|
||||
|
||||
run_action = MaterialIconAction(
|
||||
icon_name="play_arrow",
|
||||
tooltip="Run current file",
|
||||
label_text="Run",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
run_action.action.triggered.connect(self.on_execute)
|
||||
self.toolbar.components.add_safe("run", run_action)
|
||||
|
||||
stop_action = MaterialIconAction(
|
||||
icon_name="stop",
|
||||
tooltip="Stop current execution",
|
||||
label_text="Stop",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
stop_action.action.triggered.connect(self.on_stop)
|
||||
self.toolbar.components.add_safe("stop", stop_action)
|
||||
|
||||
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
|
||||
execution_bundle.add_action("run")
|
||||
execution_bundle.add_action("stop")
|
||||
self.toolbar.add_bundle(execution_bundle)
|
||||
|
||||
vim_action = MaterialIconAction(
|
||||
icon_name="vim",
|
||||
tooltip="Toggle Vim Mode",
|
||||
label_text="Vim",
|
||||
filled=True,
|
||||
parent=self,
|
||||
checkable=True,
|
||||
)
|
||||
self.toolbar.components.add_safe("vim", vim_action)
|
||||
vim_action.action.triggered.connect(self.on_vim_triggered)
|
||||
|
||||
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
|
||||
settings_bundle.add_action("vim")
|
||||
self.toolbar.add_bundle(settings_bundle)
|
||||
|
||||
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
|
||||
save_shortcut.activated.connect(self.on_save)
|
||||
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
|
||||
save_as_shortcut.activated.connect(self.on_save_as)
|
||||
|
||||
def _open_new_file(self, file_name: str, scope: str):
|
||||
self.monaco.open_file(file_name, scope)
|
||||
|
||||
# Set read-only mode for shared files
|
||||
if "shared" in scope:
|
||||
self.monaco.set_file_readonly(file_name, True)
|
||||
|
||||
# Add appropriate icon based on file type
|
||||
if "script" in scope:
|
||||
# Use script icon for script files
|
||||
icon = material_icon("script", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
elif "macro" in scope:
|
||||
# Use function icon for macro files
|
||||
icon = material_icon("function", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
|
||||
@SafeSlot()
|
||||
def on_save(self):
|
||||
self.monaco.save_file()
|
||||
|
||||
@SafeSlot()
|
||||
def on_save_as(self):
|
||||
self.monaco.save_file(force_save_as=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_vim_triggered(self):
|
||||
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_save_enabled_update(self, enabled: bool):
|
||||
self.toolbar.components.get_action("save").action.setEnabled(enabled)
|
||||
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def on_execute(self):
|
||||
"""Upload and run the currently focused script in the Monaco editor."""
|
||||
self.script_editor_tab = self.monaco.last_focused_editor
|
||||
if not self.script_editor_tab:
|
||||
return
|
||||
widget = self.script_editor_tab.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
self.current_script_id = upload_script(self.client.connector, widget.get_text())
|
||||
self.console.write(f'bec._run_script("{self.current_script_id}")')
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
if not self.current_script_id:
|
||||
return
|
||||
self.console.send_ctrl_c()
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value: str | None):
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ValueError("Script ID must be a string.")
|
||||
old_script_id = self._current_script_id
|
||||
self._current_script_id = value
|
||||
self._update_subscription(value, old_script_id)
|
||||
|
||||
def _update_subscription(self, new_script_id: str | None, old_script_id: str | None):
|
||||
if old_script_id is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id)
|
||||
)
|
||||
if new_script_id is not None:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id)
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
"""
|
||||
Handle script execution info messages to update the editor highlights.
|
||||
Args:
|
||||
content (dict): The content of the message containing execution info.
|
||||
metadata (dict): Additional metadata for the message.
|
||||
"""
|
||||
print(f"Script execution info: {content}")
|
||||
current_lines = content.get("current_lines")
|
||||
if self.script_editor_tab is None:
|
||||
return
|
||||
widget = self.script_editor_tab.widget()
|
||||
if not isinstance(widget, MonacoWidget):
|
||||
return
|
||||
if not current_lines:
|
||||
widget.clear_highlighted_lines()
|
||||
return
|
||||
line_number = current_lines[0]
|
||||
widget.clear_highlighted_lines()
|
||||
widget.set_highlighted_lines(line_number, line_number)
|
||||
|
||||
def cleanup(self):
|
||||
self.delete_all()
|
||||
return super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,686 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import partial
|
||||
from typing import List, Literal
|
||||
|
||||
import yaml
|
||||
from bec_lib import config_helper
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.file_utils import DeviceConfigWriter
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtCore import Qt, QThreadPool, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets.widgets.containers.qt_ads as QtAds
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.control.device_manager.components import (
|
||||
DeviceTableView,
|
||||
DMConfigView,
|
||||
DMOphydTest,
|
||||
DocstringView,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
|
||||
AvailableDeviceResources,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.config_communicator import (
|
||||
CommunicateConfigAction,
|
||||
)
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
PresetClassDeviceConfigDialog,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_yes_no_question = partial(
|
||||
QMessageBox.question,
|
||||
buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
defaultButton=QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
|
||||
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
"""
|
||||
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||
"""
|
||||
|
||||
def apply():
|
||||
n = splitter.count()
|
||||
if n == 0:
|
||||
return
|
||||
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||
w = [max(0.0, float(x)) for x in w]
|
||||
tot_w = sum(w)
|
||||
if tot_w <= 0:
|
||||
w = [1.0] * n
|
||||
tot_w = float(n)
|
||||
total_px = (
|
||||
splitter.width()
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal
|
||||
else splitter.height()
|
||||
)
|
||||
if total_px < 2:
|
||||
QTimer.singleShot(0, apply)
|
||||
return
|
||||
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||
diff = total_px - sum(sizes)
|
||||
if diff != 0:
|
||||
idx = max(range(n), key=lambda i: w[i])
|
||||
sizes[idx] = max(1, sizes[idx] + diff)
|
||||
splitter.setSizes(sizes)
|
||||
for i, wi in enumerate(w):
|
||||
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class ConfigChoiceDialog(QDialog):
|
||||
REPLACE = 1
|
||||
ADD = 2
|
||||
CANCEL = 0
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Load Config")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
label = QLabel("Do you want to replace the current config or add to it?")
|
||||
label.setWordWrap(True)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Buttons: equal size, stacked vertically
|
||||
self.replace_btn = QPushButton("Replace")
|
||||
self.add_btn = QPushButton("Add")
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
btn_layout = QHBoxLayout()
|
||||
for btn in (self.replace_btn, self.add_btn, self.cancel_btn):
|
||||
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
btn_layout.addWidget(btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
self.replace_btn.clicked.connect(self.accept_replace)
|
||||
self.add_btn.clicked.connect(self.accept_add)
|
||||
self.cancel_btn.clicked.connect(self.reject_cancel)
|
||||
|
||||
self._result = self.CANCEL
|
||||
|
||||
def accept_replace(self):
|
||||
self._result = self.REPLACE
|
||||
self.accept()
|
||||
|
||||
def accept_add(self):
|
||||
self._result = self.ADD
|
||||
self.accept()
|
||||
|
||||
def reject_cancel(self):
|
||||
self._result = self.CANCEL
|
||||
self.reject()
|
||||
|
||||
def result(self):
|
||||
return self._result
|
||||
|
||||
|
||||
AVAILABLE_RESOURCE_IS_READY = False
|
||||
|
||||
|
||||
class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, client=None, *args, **kwargs)
|
||||
|
||||
self._config_helper = config_helper.ConfigHelper(self.client.connector)
|
||||
self._shared_selection = SharedSelectionSignal()
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
self.dock_manager = QtAds.CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTableView(
|
||||
self, shared_selection_signal=self._shared_selection
|
||||
)
|
||||
self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self)
|
||||
self.device_table_view_dock.setWidget(self.device_table_view)
|
||||
|
||||
# Device Config View widget
|
||||
self.dm_config_view = DMConfigView(self)
|
||||
self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self)
|
||||
self.dm_config_view_dock.setWidget(self.dm_config_view)
|
||||
|
||||
# Docstring View
|
||||
self.dm_docs_view = DocstringView(self)
|
||||
self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self)
|
||||
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
|
||||
|
||||
# Ophyd Test view
|
||||
self.ophyd_test_view = DMOphydTest(self)
|
||||
self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self)
|
||||
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
|
||||
|
||||
# Help Inspector
|
||||
widget = QWidget(self)
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
self.help_inspector = HelpInspector(self)
|
||||
layout.addWidget(self.help_inspector)
|
||||
text_box = QTextEdit(self)
|
||||
text_box.setReadOnly(False)
|
||||
text_box.setPlaceholderText("Help text will appear here...")
|
||||
layout.addWidget(text_box)
|
||||
self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self)
|
||||
self.help_inspector_dock.setWidget(widget)
|
||||
|
||||
# Register callback
|
||||
self.help_inspector.bec_widget_help.connect(text_box.setMarkdown)
|
||||
|
||||
# Error Logs View
|
||||
self.error_logs_view = QTextEdit(self)
|
||||
self.error_logs_view.setReadOnly(True)
|
||||
self.error_logs_view.setPlaceholderText("Error logs will appear here...")
|
||||
self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self)
|
||||
self.error_logs_dock.setWidget(self.error_logs_view)
|
||||
self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown)
|
||||
|
||||
# Arrange widgets within the QtAds dock manager
|
||||
# Central widget area
|
||||
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
|
||||
# Right area - should be pushed into view if something is active
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
self.ophyd_test_dock_view,
|
||||
self.central_dock_area,
|
||||
)
|
||||
# create bottom area (2-arg -> area)
|
||||
self.bottom_dock_area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock
|
||||
)
|
||||
|
||||
# YAML view left of docstrings (docks relative to bottom area)
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area
|
||||
)
|
||||
|
||||
# Error/help area right of docstrings (dock relative to bottom area)
|
||||
area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
self.help_inspector_dock,
|
||||
self.bottom_dock_area,
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area)
|
||||
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
dock.setFeature(QtAds.CDockWidget.DockWidgetClosable, False)
|
||||
dock.setFeature(QtAds.CDockWidget.DockWidgetFloatable, False)
|
||||
dock.setFeature(QtAds.CDockWidget.DockWidgetMovable, False)
|
||||
|
||||
# Apply stretch after the layout is done
|
||||
self.set_default_view([2, 8, 2], [7, 3])
|
||||
|
||||
for signal, slots in [
|
||||
(
|
||||
self.device_table_view.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
(
|
||||
self.ophyd_test_view.device_validated,
|
||||
(self.device_table_view.update_device_validation,),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(self.ophyd_test_view.change_device_configs,),
|
||||
),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
# Once available resource is ready, add it to the view again
|
||||
if AVAILABLE_RESOURCE_IS_READY:
|
||||
# Available Resources Widget
|
||||
self.available_devices = AvailableDeviceResources(
|
||||
self, shared_selection_signal=self._shared_selection
|
||||
)
|
||||
self.available_devices_dock = QtAds.CDockWidget(
|
||||
self.dock_manager, "Available Devices", self
|
||||
)
|
||||
self.available_devices_dock.setWidget(self.available_devices)
|
||||
# Connect slots for available reosource
|
||||
for signal, slots in [
|
||||
(
|
||||
self.available_devices.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(self.available_devices.mark_devices_used,),
|
||||
),
|
||||
(
|
||||
self.available_devices.add_selected_devices,
|
||||
(self.device_table_view.add_device_configs,),
|
||||
),
|
||||
(
|
||||
self.available_devices.del_selected_devices,
|
||||
(self.device_table_view.remove_device_configs,),
|
||||
),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
|
||||
# Add toolbar
|
||||
self._add_toolbar()
|
||||
|
||||
def _add_toolbar(self):
|
||||
self.toolbar = ModularToolBar(self)
|
||||
|
||||
# Add IO actions
|
||||
self._add_io_actions()
|
||||
self._add_table_actions()
|
||||
self.toolbar.show_bundles(["IO", "Table"])
|
||||
self._root_layout.insertWidget(0, self.toolbar)
|
||||
|
||||
def _add_io_actions(self):
|
||||
# Create IO bundle
|
||||
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||
|
||||
# Load from disk
|
||||
load = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_open",
|
||||
parent=self,
|
||||
tooltip="Load configuration file from disk",
|
||||
label_text="Load Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("load", load)
|
||||
load.action.triggered.connect(self._load_file_action)
|
||||
io_bundle.add_action("load")
|
||||
|
||||
# Add safe to disk
|
||||
save_to_disk = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_save",
|
||||
parent=self,
|
||||
tooltip="Save config to disk",
|
||||
label_text="Save Config",
|
||||
)
|
||||
self.toolbar.components.add_safe("save_to_disk", save_to_disk)
|
||||
save_to_disk.action.triggered.connect(self._save_to_disk_action)
|
||||
io_bundle.add_action("save_to_disk")
|
||||
|
||||
# Add load config from redis
|
||||
load_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cached",
|
||||
parent=self,
|
||||
tooltip="Load current config from Redis",
|
||||
label_text="Get Current Config",
|
||||
)
|
||||
load_redis.action.triggered.connect(self._load_redis_action)
|
||||
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||
io_bundle.add_action("load_redis")
|
||||
|
||||
# Update config action
|
||||
update_config_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cloud_upload",
|
||||
parent=self,
|
||||
tooltip="Update current config in Redis",
|
||||
label_text="Update Config",
|
||||
)
|
||||
update_config_redis.action.setEnabled(False)
|
||||
update_config_redis.action.triggered.connect(self._update_redis_action)
|
||||
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
|
||||
io_bundle.add_action("update_config_redis")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(io_bundle)
|
||||
|
||||
# Table actions
|
||||
|
||||
def _add_table_actions(self) -> None:
|
||||
table_bundle = ToolbarBundle("Table", self.toolbar.components)
|
||||
|
||||
# Reset composed view
|
||||
reset_composed = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Reset current composed config view",
|
||||
label_text="Reset Config",
|
||||
)
|
||||
reset_composed.action.triggered.connect(self._reset_composed_view)
|
||||
self.toolbar.components.add_safe("reset_composed", reset_composed)
|
||||
table_bundle.add_action("reset_composed")
|
||||
|
||||
# Add device
|
||||
add_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="add",
|
||||
parent=self,
|
||||
tooltip="Add new device",
|
||||
label_text="Add Device",
|
||||
)
|
||||
add_device.action.triggered.connect(self._add_device_action)
|
||||
self.toolbar.components.add_safe("add_device", add_device)
|
||||
table_bundle.add_action("add_device")
|
||||
|
||||
# Remove device
|
||||
remove_device = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="remove",
|
||||
parent=self,
|
||||
tooltip="Remove device",
|
||||
label_text="Remove Device",
|
||||
)
|
||||
remove_device.action.triggered.connect(self._remove_device_action)
|
||||
self.toolbar.components.add_safe("remove_device", remove_device)
|
||||
table_bundle.add_action("remove_device")
|
||||
|
||||
# Rerun validation
|
||||
rerun_validation = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="checklist",
|
||||
parent=self,
|
||||
tooltip="Run device validation with 'connect' on selected devices",
|
||||
label_text="Validate Connection",
|
||||
)
|
||||
rerun_validation.action.triggered.connect(self._rerun_validation_action)
|
||||
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||
table_bundle.add_action("rerun_validation")
|
||||
|
||||
# Add load config from plugin dir
|
||||
self.toolbar.add_bundle(table_bundle)
|
||||
|
||||
# IO actions
|
||||
def _coming_soon(self):
|
||||
return QMessageBox.question(
|
||||
self,
|
||||
"Not implemented yet",
|
||||
"This feature has not been implemented yet, will be coming soon...!!",
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_file_action(self):
|
||||
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
|
||||
try:
|
||||
plugin_path = plugin_repo_path()
|
||||
plugin_name = plugin_package_name()
|
||||
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = self._get_recovery_config_path()
|
||||
logger.warning(
|
||||
f"No plugin repository installed, fallback to recovery config path: {config_path}"
|
||||
)
|
||||
|
||||
# Implement the file loading logic here
|
||||
start_dir = os.path.abspath(config_path)
|
||||
file_path = self._get_file_path(start_dir, "open_file")
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str:
|
||||
if mode == "open_file":
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, caption="Select Config File", dir=start_dir
|
||||
)
|
||||
else:
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, caption="Save Config File", dir=start_dir
|
||||
)
|
||||
return file_path
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
"""
|
||||
Load device config from a given file path and update the device table view.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the configuration file.
|
||||
"""
|
||||
try:
|
||||
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
self._open_config_choice_dialog(config)
|
||||
|
||||
def _open_config_choice_dialog(self, config: List[dict]):
|
||||
"""
|
||||
Open a dialog to choose whether to replace or add the loaded config.
|
||||
|
||||
Args:
|
||||
config (List[dict]): List of device configurations loaded from the file.
|
||||
"""
|
||||
dialog = ConfigChoiceDialog(self)
|
||||
if dialog.exec():
|
||||
if dialog.result() == ConfigChoiceDialog.REPLACE:
|
||||
self.device_table_view.set_device_config(config)
|
||||
elif dialog.result() == ConfigChoiceDialog.ADD:
|
||||
self.device_table_view.add_device_configs(config)
|
||||
|
||||
# TODO would we ever like to add the current config to an existing composition
|
||||
@SafeSlot()
|
||||
def _load_redis_action(self):
|
||||
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Load currently active config",
|
||||
"Do you really want to discard the current config and reload?",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None:
|
||||
self.device_table_view.set_device_config(
|
||||
self.client.device_manager._get_redis_device_config()
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
@SafeSlot()
|
||||
def _update_redis_action(self) -> None | QMessageBox.StandardButton:
|
||||
"""Action to push the current composition to Redis"""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Push composition to Redis",
|
||||
"Do you really want to replace the active configuration in the BEC server with the current composition? ",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
if self.device_table_view.table.contains_invalid_devices():
|
||||
return QMessageBox.warning(
|
||||
self, "Validation has errors!", "Please resolve before proceeding."
|
||||
)
|
||||
if self.ophyd_test_view.validation_running():
|
||||
return QMessageBox.warning(
|
||||
self, "Validation has not completed.", "Please wait for the validation to finish."
|
||||
)
|
||||
self._push_composition_to_redis()
|
||||
|
||||
def _push_composition_to_redis(self):
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()}
|
||||
threadpool = QThreadPool.globalInstance()
|
||||
comm = CommunicateConfigAction(self._config_helper, None, config, "set")
|
||||
threadpool.start(comm)
|
||||
|
||||
@SafeSlot()
|
||||
def _save_to_disk_action(self):
|
||||
"""Action for the 'save_to_disk' action to save the current config to disk."""
|
||||
# Check if plugin repo is installed...
|
||||
try:
|
||||
config_path = self._get_recovery_config_path()
|
||||
except ValueError:
|
||||
# Get the recovery config path as fallback
|
||||
config_path = os.path.abspath(os.path.expanduser("~"))
|
||||
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
|
||||
|
||||
# Implement the file loading logic here
|
||||
file_path = self._get_file_path(config_path, "save_file")
|
||||
if file_path:
|
||||
config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()}
|
||||
with open(file_path, "w") as file:
|
||||
file.write(yaml.dump(config))
|
||||
|
||||
# Table actions
|
||||
@SafeSlot()
|
||||
def _reset_composed_view(self):
|
||||
"""Action for the 'reset_composed_view' action to reset the composed view."""
|
||||
reply = _yes_no_question(
|
||||
self,
|
||||
"Clear View",
|
||||
"You are about to clear the current composed config view, please confirm...",
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.device_table_view.clear_device_configs()
|
||||
|
||||
# TODO Bespoke Form to add a new device
|
||||
@SafeSlot()
|
||||
def _add_device_action(self):
|
||||
"""Action for the 'add_device' action to add a new device."""
|
||||
dialog = PresetClassDeviceConfigDialog(parent=self)
|
||||
dialog.accepted_data.connect(self._add_to_table_from_dialog)
|
||||
dialog.open()
|
||||
|
||||
@SafeSlot(dict)
|
||||
def _add_to_table_from_dialog(self, data):
|
||||
self.device_table_view.add_device_configs([data])
|
||||
|
||||
@SafeSlot()
|
||||
def _remove_device_action(self):
|
||||
"""Action for the 'remove_device' action to remove a device."""
|
||||
self.device_table_view.remove_selected_rows()
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def _rerun_validation_action(self, connect: bool = True):
|
||||
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
|
||||
configs = self.device_table_view.table.selected_configs()
|
||||
self.ophyd_test_view.change_device_configs(configs, True, connect)
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(
|
||||
self, horizontal_weights: list, vertical_weights: list
|
||||
): # TODO separate logic for all ads based widgets
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
splitters_h = []
|
||||
splitters_v = []
|
||||
for splitter in self.findChildren(QSplitter):
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal:
|
||||
splitters_h.append(splitter)
|
||||
elif splitter.orientation() == Qt.Orientation.Vertical:
|
||||
splitters_v.append(splitter)
|
||||
|
||||
def apply_all():
|
||||
for s in splitters_h:
|
||||
set_splitter_weights(s, horizontal_weights)
|
||||
for s in splitters_v:
|
||||
set_splitter_weights(s, vertical_weights)
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(
|
||||
self, *, horizontal=None, vertical=None
|
||||
): # TODO separate logic for all ads based widgets
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
def _get_recovery_config_path(self) -> str:
|
||||
"""Get the recovery config path from the log_writer config."""
|
||||
# pylint: disable=protected-access
|
||||
log_writer_config = self.client._service_config.config.get("log_writer", {})
|
||||
writer = DeviceConfigWriter(service_config=log_writer_config)
|
||||
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
l = QVBoxLayout()
|
||||
w.setLayout(l)
|
||||
apply_theme("dark")
|
||||
button = DarkModeButton()
|
||||
l.addWidget(button)
|
||||
device_manager_view = DeviceManagerView()
|
||||
l.addWidget(device_manager_view)
|
||||
# config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||
# cfg = yaml_load(config_path)
|
||||
# cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||
|
||||
# # config = device_manager_view.client.device_manager._get_redis_device_config()
|
||||
# device_manager_view.device_table_view.set_device_config(cfg)
|
||||
w.show()
|
||||
w.setWindowTitle("Device Manager View")
|
||||
w.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Top Level wrapper for device_manager widget"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent)
|
||||
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.stacked_layout.setSpacing(0)
|
||||
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
|
||||
self.setLayout(self.stacked_layout)
|
||||
|
||||
# Add device manager view
|
||||
self.device_manager_view = DeviceManagerView()
|
||||
self.stacked_layout.addWidget(self.device_manager_view)
|
||||
|
||||
# Add overlay widget
|
||||
self._overlay_widget = QtWidgets.QWidget(self)
|
||||
self._customize_overlay()
|
||||
self.stacked_layout.addWidget(self._overlay_widget)
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
|
||||
def _customize_overlay(self):
|
||||
self._overlay_widget.setAutoFillBackground(True)
|
||||
self._overlay_layout = QtWidgets.QVBoxLayout()
|
||||
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self._overlay_widget.setLayout(self._overlay_layout)
|
||||
self._overlay_widget.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
|
||||
)
|
||||
# Load current config
|
||||
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
|
||||
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
|
||||
self.button_load_current_config.setIcon(icon)
|
||||
self._overlay_layout.addWidget(self.button_load_current_config)
|
||||
self.button_load_current_config.clicked.connect(self._load_config_clicked)
|
||||
# Load config from disk
|
||||
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
|
||||
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
|
||||
self.button_load_config_from_file.setIcon(icon)
|
||||
self._overlay_layout.addWidget(self.button_load_config_from_file)
|
||||
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)
|
||||
self._overlay_widget.setVisible(True)
|
||||
|
||||
def _load_config_from_file_clicked(self):
|
||||
"""Handle click on 'Load Config From File' button."""
|
||||
start_dir = os.path.expanduser("~")
|
||||
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||
self, caption="Select Config File", dir=start_dir
|
||||
)
|
||||
if file_path:
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
try:
|
||||
config = yaml_load(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
config_list = []
|
||||
for name, cfg in config.items():
|
||||
config_list.append(cfg)
|
||||
config_list[-1]["name"] = name
|
||||
self.device_manager_view.device_table_view.set_device_config(config_list)
|
||||
# self.device_manager_view.ophyd_test.on_device_config_update(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_config_clicked(self):
|
||||
"""Handle click on 'Load Current Config' button."""
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
self.device_manager_view.device_table_view.set_device_config(config)
|
||||
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
apply_theme("light")
|
||||
|
||||
widget = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QVBoxLayout(widget)
|
||||
widget.setLayout(layout)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
device_manager = DeviceManagerWidget()
|
||||
# config = device_manager.client.device_manager._get_redis_device_config()
|
||||
# device_manager.device_table_view.set_device_config(config)
|
||||
layout.addWidget(device_manager)
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
dark_mode_button = DarkModeButton()
|
||||
layout.addWidget(dark_mode_button)
|
||||
widget.show()
|
||||
device_manager.setWindowTitle("Device Manager View")
|
||||
device_manager.resize(1600, 1200)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
363
bec_widgets/applications/views/view.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from qtpy.QtCore import QEventLoop, Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
"""
|
||||
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||
"""
|
||||
|
||||
def apply():
|
||||
n = splitter.count()
|
||||
if n == 0:
|
||||
return
|
||||
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||
w = [max(0.0, float(x)) for x in w]
|
||||
tot_w = sum(w)
|
||||
if tot_w <= 0:
|
||||
w = [1.0] * n
|
||||
tot_w = float(n)
|
||||
total_px = (
|
||||
splitter.width()
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal
|
||||
else splitter.height()
|
||||
)
|
||||
if total_px < 2:
|
||||
QTimer.singleShot(0, apply)
|
||||
return
|
||||
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||
diff = total_px - sum(sizes)
|
||||
if diff != 0:
|
||||
idx = max(range(n), key=lambda i: w[i])
|
||||
sizes[idx] = max(1, sizes[idx] + diff)
|
||||
splitter.setSizes(sizes)
|
||||
for i, wi in enumerate(w):
|
||||
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class ViewBase(QWidget):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view.
|
||||
|
||||
Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden.
|
||||
|
||||
Args:
|
||||
content (QWidget): The actual view widget to display.
|
||||
parent (QWidget | None): Parent widget.
|
||||
id (str | None): Optional view id, useful for debugging or introspection.
|
||||
title (str | None): Optional human-readable title.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
self.content: QWidget | None = None
|
||||
self.view_id = id
|
||||
self.view_title = title
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
|
||||
if content is not None:
|
||||
self.set_content(content)
|
||||
|
||||
def set_content(self, content: QWidget) -> None:
|
||||
"""Replace the current content widget with a new one."""
|
||||
if self.content is not None:
|
||||
self.content.setParent(None)
|
||||
self.content = content
|
||||
self.layout().addWidget(content)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
"""Called after the view becomes current/visible.
|
||||
|
||||
Default implementation does nothing. Override in subclasses.
|
||||
"""
|
||||
pass
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
"""Called before the view is switched away/hidden.
|
||||
|
||||
Return True to allow switching, or False to veto.
|
||||
Default implementation allows switching.
|
||||
"""
|
||||
return True
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
splitters_h = []
|
||||
splitters_v = []
|
||||
for splitter in self.findChildren(QSplitter):
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal:
|
||||
splitters_h.append(splitter)
|
||||
elif splitter.orientation() == Qt.Orientation.Vertical:
|
||||
splitters_v.append(splitter)
|
||||
|
||||
def apply_all():
|
||||
for s in splitters_h:
|
||||
set_splitter_weights(s, horizontal_weights)
|
||||
for s in splitters_v:
|
||||
set_splitter_weights(s, vertical_weights)
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(self, *, horizontal=None, vertical=None):
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# Example views for demonstration/testing purposes
|
||||
####################################################################################################
|
||||
|
||||
|
||||
# --- Popup UI version ---
|
||||
class WaveformViewPopup(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
self.waveform = Waveform(parent=self)
|
||||
self.set_content(self.waveform)
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Configure Waveform View")
|
||||
|
||||
label = QLabel("Select device and signal for the waveform plot:", parent=dialog)
|
||||
|
||||
# same as in the CurveRow used in waveform
|
||||
self.device_edit = DeviceComboBox(parent=self)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
self.entry_edit = SignalComboBox(parent=self)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(label)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
buttons.rejected.connect(dialog.reject)
|
||||
|
||||
v = QVBoxLayout(dialog)
|
||||
v.addLayout(form)
|
||||
v.addWidget(buttons)
|
||||
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
self.waveform.plot(
|
||||
y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText()
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
ans = QMessageBox.question(
|
||||
self,
|
||||
"Switch and clear?",
|
||||
"Do you want to switch views and clear the plot?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if ans == QMessageBox.Yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Inline stacked UI version ---
|
||||
class WaveformViewInline(ViewBase): # pragma: no cover
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
|
||||
# Root layout for this view uses a stacked layout
|
||||
self.stack = QStackedLayout()
|
||||
container = QWidget(self)
|
||||
container.setLayout(self.stack)
|
||||
self.set_content(container)
|
||||
|
||||
# --- Page 0: Settings page (inline form)
|
||||
self.settings_page = QWidget()
|
||||
sp_layout = QVBoxLayout(self.settings_page)
|
||||
sp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
sp_layout.setSpacing(12)
|
||||
|
||||
title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page)
|
||||
self.device_edit = DeviceComboBox(parent=self.settings_page)
|
||||
self.device_edit.insertItem(0, "")
|
||||
self.device_edit.setEditable(True)
|
||||
self.device_edit.setCurrentIndex(0)
|
||||
|
||||
self.entry_edit = SignalComboBox(parent=self.settings_page)
|
||||
self.entry_edit.include_config_signals = False
|
||||
self.entry_edit.insertItem(0, "")
|
||||
self.entry_edit.setEditable(True)
|
||||
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
|
||||
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow(title)
|
||||
form.addRow("Device", self.device_edit)
|
||||
form.addRow("Signal", self.entry_edit)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
ok_btn = QPushButton("OK", parent=self.settings_page)
|
||||
cancel_btn = QPushButton("Cancel", parent=self.settings_page)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
btn_row.addWidget(ok_btn)
|
||||
|
||||
sp_layout.addLayout(form)
|
||||
sp_layout.addLayout(btn_row)
|
||||
|
||||
# --- Page 1: Waveform page
|
||||
self.waveform_page = QWidget()
|
||||
wf_layout = QVBoxLayout(self.waveform_page)
|
||||
wf_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.waveform = Waveform(parent=self.waveform_page)
|
||||
wf_layout.addWidget(self.waveform)
|
||||
|
||||
# --- Page 2: Exit confirmation page (inline)
|
||||
self.confirm_page = QWidget()
|
||||
cp_layout = QVBoxLayout(self.confirm_page)
|
||||
cp_layout.setContentsMargins(16, 16, 16, 16)
|
||||
cp_layout.setSpacing(12)
|
||||
qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page)
|
||||
cp_buttons = QHBoxLayout()
|
||||
no_btn = QPushButton("No", parent=self.confirm_page)
|
||||
yes_btn = QPushButton("Yes", parent=self.confirm_page)
|
||||
cp_buttons.addStretch(1)
|
||||
cp_buttons.addWidget(no_btn)
|
||||
cp_buttons.addWidget(yes_btn)
|
||||
cp_layout.addWidget(qlabel)
|
||||
cp_layout.addLayout(cp_buttons)
|
||||
|
||||
# Add pages to the stack
|
||||
self.stack.addWidget(self.settings_page) # index 0
|
||||
self.stack.addWidget(self.waveform_page) # index 1
|
||||
self.stack.addWidget(self.confirm_page) # index 2
|
||||
|
||||
# Wire settings buttons
|
||||
ok_btn.clicked.connect(self._apply_settings_and_show_waveform)
|
||||
cancel_btn.clicked.connect(self._show_waveform_without_changes)
|
||||
|
||||
# Prepare result holder for the inline confirmation
|
||||
self._exit_choice_yes = None
|
||||
yes_btn.clicked.connect(lambda: self._exit_reply(True))
|
||||
no_btn.clicked.connect(lambda: self._exit_reply(False))
|
||||
|
||||
@SafeSlot()
|
||||
def on_enter(self) -> None:
|
||||
# Always start on the settings page when entering
|
||||
self.stack.setCurrentIndex(0)
|
||||
|
||||
@SafeSlot()
|
||||
def on_exit(self) -> bool:
|
||||
# Show inline confirmation page and synchronously wait for a choice
|
||||
# -> trick to make the choice blocking, however popup would be cleaner solution
|
||||
self._exit_choice_yes = None
|
||||
self.stack.setCurrentIndex(2)
|
||||
loop = QEventLoop()
|
||||
self._exit_loop = loop
|
||||
loop.exec_()
|
||||
|
||||
if self._exit_choice_yes:
|
||||
self.waveform.clear_all()
|
||||
return True
|
||||
# Revert to waveform view if user cancelled switching
|
||||
self.stack.setCurrentIndex(1)
|
||||
return False
|
||||
|
||||
def _apply_settings_and_show_waveform(self):
|
||||
dev = self.device_edit.currentText()
|
||||
sig = self.entry_edit.currentText()
|
||||
if dev and sig:
|
||||
self.waveform.plot(y_name=dev, y_entry=sig)
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _show_waveform_without_changes(self):
|
||||
# Just show waveform page without plotting
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def _exit_reply(self, yes: bool):
|
||||
self._exit_choice_yes = bool(yes)
|
||||
if hasattr(self, "_exit_loop") and self._exit_loop.isRunning():
|
||||
self._exit_loop.quit()
|
||||
|
Before Width: | Height: | Size: 1.6 MiB |
BIN
bec_widgets/assets/app_icons/BEC-General-App.png
Normal file
|
After Width: | Height: | Size: 437 KiB |
BIN
bec_widgets/assets/app_icons/alignment_1d.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
bec_widgets/assets/app_icons/auto_update.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 3.8 KiB |
BIN
bec_widgets/assets/app_icons/ui_loader_tile.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
bec_widgets/assets/app_icons/widget_launch_tile.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
|
||||
<path d="M479.85-265.87q19.8 0 32.69-12.46 12.9-12.46 12.9-32.26 0-19.8-12.75-32.98-12.74-13.17-32.54-13.17-19.8 0-32.69 13.16-12.9 13.15-12.9 32.95 0 19.8 12.75 32.28 12.74 12.48 32.54 12.48Zm-36.46-166.56h79.22v-262.61h-79.22v262.61Zm36.95 366.56q-86.2 0-161.5-32.39-75.3-32.4-131.74-88.84-56.44-56.44-88.84-131.73-32.39-75.3-32.39-161.59t32.39-161.67q32.4-75.37 88.75-131.34t131.69-88.62q75.34-32.65 161.67-32.65 86.34 0 161.78 32.61 75.45 32.6 131.37 88.5 55.93 55.89 88.55 131.45 32.63 75.56 32.63 161.87 0 86.29-32.65 161.58t-88.62 131.48q-55.97 56.18-131.42 88.76-75.46 32.58-161.67 32.58Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 718 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#EA3323">
|
||||
<path d="m759.04-283.09-63.13-62q49.31-9.43 84.44-46.02 35.13-36.59 35.13-86.14 0-55.49-39.42-95.08-39.43-39.58-94.93-39.58h-153.3v-79.79h152.74q89.28 0 151.7 62.71Q894.7-566.28 894.7-477q0 63.7-38.26 115.96-38.27 52.26-97.4 77.95ZM596.83-443.61l-65.66-66.78h110.05v66.78h-44.39ZM804.96-56 58.48-802.48 106-850l746.48 746.48L804.96-56ZM443.22-265.87H279.43q-89.28 0-151.7-62.42Q65.3-390.72 65.3-480q0-72.57 43.09-129.54 43.09-56.98 112.09-76.07l70.13 70.7h-11.18q-55.73 0-95.32 39.3-39.59 39.31-39.59 95.61t39.66 95.61q39.66 39.3 95.5 39.3h163.54v79.22ZM319.35-446.61v-66.78h77.3l66.78 66.78H319.35Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 721 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFF55">
|
||||
<path d="M478.3-145.87q-138.65 0-236.39-97.74-97.74-97.74-97.74-236.25t97.74-236.68q97.74-98.16 236.39-98.16 88.4 0 155.45 35.76 67.04 35.76 115.86 98.9V-814.7h66.78v274.92H540.91V-606h165.74q-38.56-57.74-95.3-93.33-56.74-35.58-133.05-35.58-106.88 0-180.89 73.98-74.02 73.99-74.02 180.83 0 106.84 74.02 180.93 74.02 74.08 180.91 74.08 80.16 0 147.74-46.08 67.59-46.09 95.16-121.83H803q-29.56 110.65-119.67 178.89-90.1 68.24-205.03 68.24Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 559 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#75FB4C">
|
||||
<path d="m419.87-289.52 289.22-289.22-57.31-56.87L419.87-403.7 304.96-518.61l-56.31 56.87 171.22 172.22Zm60.21 223.65q-85.47 0-161.01-32.39-75.53-32.4-131.97-88.84-56.44-56.44-88.84-131.89-32.39-75.46-32.39-160.93 0-86.47 32.39-162.01 32.4-75.53 88.75-131.5t131.85-88.62q75.5-32.65 161.01-32.65 86.52 0 162.12 32.61 75.61 32.6 131.53 88.5 55.93 55.89 88.55 131.45Q894.7-566.58 894.7-480q0 85.55-32.65 161.07-32.65 75.53-88.62 131.9-55.97 56.37-131.42 88.77-75.46 32.39-161.93 32.39Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 604 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#F19E39">
|
||||
<path d="M27.56-112.65 480-894.7l452.44 782.05H27.56Zm456.62-125.48q13.15 0 22.61-9.64 9.47-9.65 9.47-22.8t-9.64-22.33q-9.65-9.19-22.8-9.19t-22.61 9.36q-9.47 9.36-9.47 22.51 0 13.15 9.64 22.62 9.65 9.47 22.8 9.47ZM454-348h60v-219.48h-60V-348Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 364 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M440.39-440.39H185.87v-79.22h254.52V-774.7h79.22v255.09H774.7v79.22H519.61v254.52h-79.22v-254.52Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 228 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="m145.26-88.13-57.13-57.13 137.39-137.39H105.87v-79.22h256v256h-79.22v-119.65L145.26-88.13Zm669.48 0L677.91-225.52v119.65h-79.78v-256H854.7v79.22H734.48l137.39 137.39-57.13 57.13Zm-708.87-510v-79.78h119.65L88.13-814.74l57.13-57.13 137.39 137.39V-854.7h79.22v256.57h-256Zm492.26 0V-854.7h79.78v120.22l137.83-138.39 57.13 57.13-138.39 137.83H854.7v79.78H598.13Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 489 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M114.02-114.02v-308.13h68.13v192.02l547.72-547.72H537.85v-68.37h308.37v308.37h-68.37v-192.02L230.13-182.15h192.02v68.13H114.02Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 258 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="m311.5-154.02-47.74-47.74 116.94-116.7H74.02v-68.37H380.7L263.76-503.76l47.74-47.74 198.98 198.74L311.5-154.02Zm337-254.72L449.76-607.48 648.5-806.22l47.74 47.74-116.7 116.94h306.68v68.37H579.54l116.7 116.69-47.74 47.74Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 351 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M17 7h-4v2h4c1.65 0 3 1.35 3 3s-1.35 3-3 3h-4v2h4c2.76 0 5-2.24 5-5s-2.24-5-5-5zm-6 8H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-2zm-3-4h8v2H8z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 341 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M180-404.87v-50.26h289.75v50.26H180Zm0-162.57v-50.25h454.62v50.25H180Zm0-162.3V-780h454.62v50.26H180ZM524.62-180v-105.69l217.15-216.16q7.46-7.07 16.11-10.3 8.65-3.23 17.3-3.23 9.43 0 18.25 3.53 8.82 3.54 16.03 10.62l37 37.38q6.87 7.47 10.21 16.16Q860-439 860-430.31t-3.37 17.69q-3.37 9-10.52 16.46L630.31-180H524.62Zm250.69-211.69 37-38.62-37-37.38-38 38 38 38Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 492 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M480-54 303.43-230.56 361-288.13l79.39 78.83v-231.09H209.3L283.13-366l-57.57 57.57L54-480l172.56-172.57L284.13-595l-74.83 75.39h231.09v-231.65L366-676.87l-57.57-57.57L480-906l171.57 171.56L594-676.87l-74.39-74.39v231.65h231.65L676.87-594l57.57-57.57L906-480 734.44-308.43 676.87-366l74.39-74.39H519.61v231.09L599-288.13l57.57 57.57L480-54Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 470 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px"
|
||||
fill="#FFFFFF">
|
||||
<g>
|
||||
<rect fill="none" height="24" width="24"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M18,15v3H6v-3H4v3c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-3H18z M7,9l1.41,1.41L11,7.83V16h2V7.83l2.59,2.58L17,9l-5-5L7,9z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 371 B |
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 96" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<rect id="Artboard1" x="0" y="0" width="100" height="96.486" style="fill:none;"/>
|
||||
<g id="Artboard11" serif:id="Artboard1">
|
||||
<path d="M11.379,24.832C11.379,24.261 11.843,23.798 12.414,23.798C18.11,23.798 20.117,19.072 22.06,14.503C23.704,10.634 25.403,6.634 29.483,6.634C33.902,6.634 35.376,12.91 36.934,19.555C38.473,26.113 40.065,32.893 44.138,32.893C48.25,32.893 50.78,28.962 53.457,24.8C56.279,20.412 59.198,15.876 64.31,15.876C69.322,15.876 72.165,20.305 74.915,24.588C77.707,28.935 80.343,33.04 84.999,33.04C85.571,33.04 86.034,33.503 86.034,34.075C86.034,34.647 85.571,35.11 84.999,35.11C79.212,35.11 76.004,30.115 73.175,25.708C70.612,21.716 68.192,17.946 64.31,17.946C60.328,17.946 57.835,21.819 55.197,25.92C52.338,30.366 49.381,34.963 44.138,34.963C38.425,34.963 36.643,27.371 34.92,20.028C33.613,14.46 32.262,8.703 29.482,8.703C26.953,8.703 25.71,11.2 23.963,15.311C21.964,20.014 19.479,25.867 12.413,25.867C11.843,25.867 11.379,25.403 11.379,24.832M44.361,44.584C43.504,44.584 42.557,44.882 42.557,45.739L42.586,50.703L39.522,50.703L43.922,61.878L48.807,50.703L45.691,50.703L45.604,46.255C45.602,45.398 45.218,44.584 44.361,44.584ZM6.034,37.487L6.034,6.674L5,6.674L5,38.522L95,38.522L95,37.487L6.034,37.487M77.414,91.881L77.414,63.849C77.414,63.277 76.951,62.814 76.379,62.814C75.808,62.814 75.345,63.277 75.345,63.849L75.345,91.881C75.345,92.045 75.391,92.194 75.458,92.332L61.955,92.332C62.022,92.194 62.068,92.045 62.068,91.881L62.068,82.718C62.068,82.146 61.605,81.683 61.034,81.683C60.462,81.683 59.999,82.146 59.999,82.718L59.999,91.881C59.999,92.045 60.045,92.194 60.112,92.332L45.059,92.332C45.126,92.194 45.172,92.045 45.172,91.881L45.172,75.943C45.172,75.372 44.709,74.909 44.138,74.909C43.567,74.909 43.104,75.372 43.104,75.943L43.104,91.881C43.104,92.045 43.15,92.194 43.217,92.332L23.852,92.332C23.92,92.194 23.966,92.045 23.966,91.881L23.966,63.849C23.966,63.277 23.502,62.814 22.931,62.814C22.36,62.814 21.897,63.277 21.897,63.849L21.897,91.881C21.897,92.045 21.943,92.194 22.011,92.332L6.034,92.332L6.034,62.881L5,62.881L5,93.366L95,93.366L95,92.332L77.301,92.332C77.368,92.194 77.414,92.045 77.414,91.881"
|
||||
style="fill:white;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="m78.89-112.59 263.02-367.17h202l303.5-354.22v721.39H78.89Zm62.24-262.74-54.7-39.78 166.59-233.02h201L640.46-864.8l51.45 44.26-205.82 240.78H287.33l-146.2 204.43Zm70.54 194.37h567.37v-468.78L574.98-411.63H376.22L211.67-180.96Zm567.37 0Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 366 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.25 2.52.77-1.28-3.52-2.09V8z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 392 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M185.09-105.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-589.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h589.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v589.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H185.09Zm43.56-166.04h503.7L578-481.48l-132 171-93-127-124.35 165.57Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 413 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M283.85-289.91h67.41l42.32-112.66h172.59l43.33 112.66h68.85l-164-428h-67.5l-163 428Zm127.74-165.72 67.34-182.87H481l68.41 182.87H411.59Zm68.53 381.61q-86.32 0-160.51-31t-128.89-85.7q-54.7-54.7-85.7-128.89-31-74.19-31-160.51 0-85.31 30.94-159.4t85.7-128.9q54.76-54.8 128.95-86.3t160.51-31.5q85.31 0 159.42 31.47 74.1 31.47 128.91 86.27 54.82 54.8 86.29 128.88 31.48 74.08 31.48 159.6 0 86.2-31.5 160.39-31.5 74.19-86.3 128.95-54.81 54.76-128.9 85.7-74.09 30.94-159.4 30.94ZM480-480Zm-.04 337.85q144.08 0 240.99-96.74 96.9-96.74 96.9-241.07 0-144.32-96.86-241.11-96.86-96.78-240.95-96.78-144.08 0-240.99 96.74-96.9 96.74-96.9 241.07 0 144.32 96.86 241.11 96.86 96.78 240.95 96.78Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 809 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px"
|
||||
fill="#FFFFFF">
|
||||
<g>
|
||||
<rect fill="none" height="24" width="24"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M18,15v3H6v-3H4v3c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-3H18z M17,11l-1.41-1.41L13,12.17V4h-2v8.17L8.41,9.59L7,11l5,5 L17,11z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 377 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M110.39-110.39v-89.57l77.52-77.52v167.09h-77.52Zm165.57 0v-250.13l77.52-77.52v327.65h-77.52Zm165.56 0v-327.65l77.52 77.95v249.7h-77.52Zm165.57 0v-250.83l77.52-76.96v327.79h-77.52Zm165.56 0v-411.83l76.96-76.96v488.79h-76.96ZM110.39-335.65v-112.7L400-735.96l160 160 289.61-290.61v112.14L560-463.26l-160-160-289.61 287.61Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 450 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M725.93-155.93q0-118.18-45-222.09t-122-180.91q-77-77-180.91-122t-222.09-45v-68.14q132.68 0 248.61 50.23 115.92 50.23 202.5 136.75 86.57 86.53 136.8 202.53 50.23 116.01 50.23 248.63h-68.14Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 319 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M571.91-279.09h191v-194h-60v134h-131v60ZM198.09-486.91h60v-134h131v-60h-191v194Zm-53 341.04q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-509.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h669.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v509.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H145.09Zm0-79.22h669.82v-509.82H145.09v509.82Zm0 0v-509.82 509.82Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 487 B |
|
Before Width: | Height: | Size: 6.3 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M443.78-28.43v-75Q305.65-118 211.54-212.11q-94.11-94.11-108.11-231.67h-75v-72.44h75Q118-654.35 212.11-748.46q94.11-94.11 231.67-108.11v-75h72.44v75q137.56 14 231.67 108.11Q842-654.35 856.57-516.22h75v72.44h-75q-14 137.56-108.11 231.67Q654.35-118 516.22-103.43v75h-72.44Zm36.12-152.66q123.4 0 211.21-87.7 87.8-87.71 87.8-211.11 0-123.4-87.7-211.21-87.71-87.8-211.11-87.8-123.4 0-211.21 87.7-87.8 87.71-87.8 211.11 0 123.4 87.7 211.21 87.71 87.8 211.11 87.8ZM480-330q-63 0-106.5-43.5T330-480q0-63 43.5-106.5T480-630q63 0 106.5 43.5T630-480q0 63-43.5 106.5T480-330Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 693 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M354.61-386.61h391l-127-171-103 135-68-87-93 123ZM274.7-195.48q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-549.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h549.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v549.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H274.7Zm0-79.22h549.82v-549.82H274.7v549.82ZM135.48-55.69q-32.74 0-56.26-23.53-23.53-23.52-23.53-56.26v-629.04h79.79v629.04h629.04v79.79H135.48ZM274.7-824.52v549.82-549.82Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 564 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M403.39-158.74 82.13-480l321.26-321.26v642.52Zm153.22 0v-642.52L878.44-480 556.61-158.74Zm81.57-195.74L763.13-480 638.18-605.52v251.04Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 266 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M480-65.87q-87.51 0-162.97-31.89-75.47-31.89-131.43-87.84-55.95-55.96-87.84-131.43Q65.87-392.49 65.87-480q0-87.47 31.88-162.87 31.89-75.39 87.75-131.51 55.87-56.11 131.41-88.21Q392.44-894.7 480-894.7q15.96 0 27.78 12.16 11.83 12.16 11.83 28.07 0 15.9-11.83 27.73-11.82 11.83-27.78 11.83-139.31 0-237.11 97.8-97.8 97.8-97.8 237.1 0 139.31 97.8 237.12 97.8 97.8 237.1 97.8 139.31 0 237.12-97.8 97.8-97.8 97.8-237.11 0-15.96 11.83-27.78 11.83-11.83 27.73-11.83 15.91 0 28.07 11.83Q894.7-495.96 894.7-480q0 87.56-32.14 163.1-32.14 75.54-88.11 131.44-55.97 55.9-131.44 87.74Q567.55-65.87 480-65.87Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 724 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M854.7-105.87H105.87v-209.61H854.7v209.61Zm0-269.61H105.87v-209.04H854.7v209.04Zm0-269.04H105.87V-854.7H854.7v210.18Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 248 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M444.19-421.3q39.72 0 67.4-27.78 27.67-27.78 27.67-66.91 0-39.14-27.71-66.57Q483.83-610 444.4-610q-39.44 0-66.92 27.28Q350-555.44 350-516.3q0 39.13 27.36 67.06 27.35 27.94 66.83 27.94ZM634.52-274 532.78-375.74q-22.56 12.31-44.41 19.02-21.84 6.72-43.28 6.72-69.57 0-117.7-48.62-48.13-48.62-48.13-117.88 0-67.98 48.29-116.39t117.08-48.41q68.79 0 117.08 48.41Q610-584.48 610-515.75q0 21.72-6.93 44.13-6.94 22.4-20.37 44.97l102.73 102.74L634.52-274ZM185.09-105.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87V-352h79.22v166.91H352v79.22H185.09Zm422.91 0v-79.22h166.91V-352h79.79v166.91q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H608ZM105.87-608v-166.91q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53H352v79.79H185.09V-608h-79.22Zm669.04 0v-166.91H608v-79.79h166.91q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26V-608h-79.79Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 946 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="48px" viewBox="0 0 24 24" width="48px"
|
||||
fill="#8B1A10">
|
||||
<rect fill="none" height="24" width="24"/>
|
||||
<path d="M19,19H5V5h14V19z M3,3v18h18V3H3z M17,15.59L15.59,17L12,13.41L8.41,17L7,15.59L10.59,12L7,8.41L8.41,7L12,10.59L15.59,7 L17,8.41L13.41,12L17,15.59z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 357 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M473.54-344.48v-63.59h187.18v63.59H473.54Zm81.92 230.46v-56.15h-81.92v-63.59h81.92v-56.39h63.58v176.13h-63.58Zm103.58-56.15v-63.59h187.18v63.59H659.04Zm41.68-116.92v-177.13h63.58v56.15h81.92v63.59H764.3v57.39h-63.58ZM841.22-540h-68.46q-22.98-101.89-103.94-170.83-80.95-68.93-188.89-68.93-125.29 0-212.37 87.18T180.48-480q0 78.61 36.59 143.32 36.58 64.7 96.95 104.46v-108.26h66.46v226.46H154.02v-66.46h117.41q-71.56-48.72-114.48-127.36-42.93-78.64-42.93-172.16 0-76.22 28.86-142.78 28.86-66.57 78.29-116.04 49.42-49.47 116.01-78.43 66.58-28.97 142.82-28.97 136.52 0 237.87 87.8Q819.22-670.63 841.22-540Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 733 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M336.04-487.39 145.09-678.35v156.65H65.87v-293H358.3v79.79H201.22l191.39 190.95-56.57 56.57ZM145.09-145.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87V-451.7h79.22v226.61H493v79.22H145.09Zm669.82-278.3v-310.74H428.3v-79.79h386.61q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v310.74h-79.79Zm79.79 60v218.87H553v-218.87h341.7Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 455 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M479.87-54q-87.96 0-165.74-33.42-77.79-33.43-135.53-91.18-57.75-57.74-91.18-135.66Q54-392.17 54-480.13q0-87.96 33.42-165.74 33.43-77.79 91.18-135.53 57.74-57.75 135.62-91.18Q392.09-906 480-906h36.22v340.7q24.82 10.69 40.8 33.52Q573-508.96 573-480.17q0 38.43-27.38 65.8Q518.24-387 479.79-387q-38.44 0-65.62-27.37Q387-441.74 387-480.17q0-28.79 15.98-51.61 15.98-22.83 40.8-33.52v-97.22Q378.22-650.39 336-599.76q-42.22 50.63-42.22 119.5 0 78.19 54.12 132.33 54.12 54.15 132.02 54.15 77.91 0 132.1-54.09 54.2-54.1 54.2-131.79 0-42.47-16.28-77.88-16.29-35.42-45.42-60.98l52.05-52.05q38.18 35.64 60.7 84.75 22.51 49.11 22.51 105.82 0 108.57-75.58 183.9-75.59 75.32-184.13 75.32-108.55 0-183.92-75.32-75.37-75.33-75.37-183.89 0-99.62 63.68-172.01 63.67-72.39 159.32-85.65v-93.22Q309.39-817.61 218.2-717.8 127-617.98 127-480.14q0 147.23 103.1 250.18Q333.19-127 480.14-127t249.9-103.02Q833-333.04 833-479.81q0-76.45-29.58-141.91-29.57-65.46-80.81-114.89l51.48-51.48q61.05 58.34 96.48 137.6Q906-571.23 906-479.73q0 87.82-33.42 165.6-33.43 77.79-91.18 135.53-57.74 57.75-135.66 91.18Q567.83-54 479.87-54Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M435-72.11q-49.67-7-95.99-25.36-46.31-18.36-86.55-49.55l49.21-49.74q31.76 24 65.29 37.26 33.52 13.26 68.04 19.02v68.37Zm90 0v-68.37q109.28-21.24 181.07-102.9 71.78-81.66 71.78-197.71 0-124.37-84.11-210.87t-208.72-86.5h-18.8L540.67-664l-49.74 49.74L332.2-773l158.73-158.74 49.74 49.26-75.41 75.65h19.28q75.48 0 141.34 28.72t114.98 78.56q49.12 49.83 77.24 116.29 28.12 66.46 28.12 142.17 0 142.39-90.8 245.07Q664.63-93.35 525-72.11ZM189.46-209.78q-28.96-38.72-47.82-86.3-18.86-47.57-25.62-100.01h68.89q5 37.76 18.38 72.17 13.38 34.4 36.14 64.16l-49.97 49.98Zm-73.44-276.31q6.52-50.71 25-97.29 18.48-46.58 48.44-87.77l50.21 48.26q-22.76 33-36.14 67.52-13.38 34.52-18.62 69.28h-68.89Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 811 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M527-72.11v-68.37q34.52-5.76 68.04-19.02 33.53-13.26 65.29-37.26l49.21 49.74q-40.24 31.19-86.55 49.55Q576.67-79.11 527-72.11Zm-90 0Q297.37-93.35 206.7-196.02q-90.68-102.68-90.68-245.07 0-75.71 28-142.17t77.12-116.29q49.12-49.84 114.98-78.56 65.86-28.72 141.34-28.72h19.28l-75.41-75.65 49.74-49.26L629.8-773 471.07-614.26 421.33-664l74.69-74.46h-19.04q-124.61 0-208.72 86.5t-84.11 210.87q0 116.05 71.78 197.71 71.79 81.66 181.07 102.9v68.37Zm335.54-137.67-49.97-49.98q22.76-29.76 36.14-64.16 13.38-34.41 18.38-72.17h69.13q-7 52.44-25.86 100.01-18.86 47.58-47.82 86.3Zm73.68-276.31h-69.13q-5.24-34.76-18.62-69.28t-36.14-67.52l50.21-48.26q29.96 41.19 48.44 87.77 18.48 46.58 25.24 97.29Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 815 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M854.7-689.22v504.13q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H185.09q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-589.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h504.13L854.7-689.22Zm-79.79 35.48L653.74-774.91H185.09v589.82h589.82v-468.65ZM479.76-250.09q43.24 0 73.74-30.26 30.5-30.27 30.5-73.5 0-43.24-30.26-73.74-30.27-30.5-73.5-30.5-43.24 0-73.74 30.27-30.5 30.26-30.5 73.5 0 43.23 30.26 73.73 30.27 30.5 73.5 30.5ZM238.09-578.91h358v-143h-358v143Zm-53-74.83v468.65-589.82 121.17Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 621 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M185.87-98.52v-681.39q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h429.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v681.39L480-224.17 185.87-98.52Zm79.22-120.39L480-309.18l214.91 90.27v-561H265.09v561Zm0-561h429.82-429.82Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 354 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M185.09-105.87q-32.93 0-56.08-23.14-23.14-23.15-23.14-56.08v-589.82q0-33.16 23.14-56.47 23.15-23.32 56.08-23.32h589.82q33.16 0 56.47 23.32 23.32 23.31 23.32 56.47v589.82q0 32.93-23.32 56.08-23.31 23.14-56.47 23.14H185.09Zm439.21-79.22h71l79.61-79.61v-40.39H744.3l-120 120ZM281-389.48l141-140 90 90L725.52-654 679-700.52l-167 167-90-90L234.48-436 281-389.48Zm-95.91 204.39h34.56l120-120h-71l-83.56 83.57v36.43Zm350.91 0 120-120h-71l-120 120h71Zm-159.87 0 120-120h-71l-120 120h71Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 609 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 0 24 24" width="48px" fill="#FFFFFF">
|
||||
<path d="M0 0h24v24H0V0z" fill="none"/>
|
||||
<path d="M19.43 12.98c.04-.32.07-.64.07-.98 0-.34-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.09-.16-.26-.25-.44-.25-.06 0-.12.01-.17.03l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.06-.02-.12-.03-.18-.03-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98 0 .33.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.09.16.26.25.44.25.06 0 .12-.01.17-.03l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.06.02.12.03.18.03.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zm-1.98-1.71c.04.31.05.52.05.73 0 .21-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7.14 1.13zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M286.83-277h60v-205h-60v205Zm326.91 0h60v-420h-60v420ZM450-277h60v-118h-60v118Zm0-205h60v-60h-60v60ZM185.09-105.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-589.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h589.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v589.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H185.09Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 452 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M145.09-145.87q-32.51 0-55.87-23.35-23.35-23.36-23.35-55.87v-509.82q0-32.74 23.35-56.26 23.36-23.53 55.87-23.53h669.82q32.74 0 56.26 23.53 23.53 23.52 23.53 56.26v509.82q0 32.51-23.53 55.87-23.52 23.35-56.26 23.35H145.09Zm0-79.22h669.82v-425.82H145.09v425.82ZM300-292l-42-42 103-104-104-104 43-42 146 146-146 146Zm190 4v-60h220v60H490Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 466 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M636.17-32.59 480.35-188.41l45.82-46.07 77.13 77.37v-137.32H356.93q-28.1 0-46.8-18.55-18.7-18.54-18.7-46.95V-600.3H74.02v-65.27h217.41v-137.32l-77.36 77.37-45.59-45.83 155.59-155.82 156.06 155.82-46.06 45.83-77.14-77.37v442.96h529.29v65.5H669.04v137.32l77.13-77.37L792-188.41 636.17-32.59ZM603.3-419.93V-600.3H416.93v-65.27H603.3q28.37 0 47.06 18.56 18.68 18.55 18.68 46.71v180.37H603.3Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 518 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="48px" viewBox="0 -960 960 960" width="48px" fill="#FFFFFF">
|
||||
<path d="M126-206.43 66.43-266 380-579.57 539.43-419.7l298-335 56.14 54.57-352.44 399.26L380-460.43l-254 254Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 231 B |
@@ -1 +0,0 @@
|
||||
from .client import *
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from queue import Queue
|
||||
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
|
||||
self.msg_queue = Queue()
|
||||
self.auto_update_thread = None
|
||||
self._shutdown_sentinel = object()
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the auto update thread.
|
||||
"""
|
||||
self.auto_update_thread = threading.Thread(target=self.process_queue)
|
||||
self.auto_update_thread.start()
|
||||
|
||||
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)
|
||||
|
||||
def process_queue(self):
|
||||
"""
|
||||
Process the message queue.
|
||||
"""
|
||||
while True:
|
||||
msg = self.msg_queue.get()
|
||||
if msg is self._shutdown_sentinel:
|
||||
break
|
||||
self.run(msg)
|
||||
|
||||
@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)
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the auto update thread.
|
||||
"""
|
||||
self.msg_queue.put(self._shutdown_sentinel)
|
||||
if self.auto_update_thread:
|
||||
self.auto_update_thread.join()
|
||||
@@ -1,69 +1,60 @@
|
||||
"""Client utilities for the BEC GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata as imd
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
from contextlib import contextmanager
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
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 bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.auto_updates import AutoUpdates
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.device import DeviceBase
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import GUIRegistryStateMessage
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
else:
|
||||
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
|
||||
client = lazy_import("bec_widgets.cli.client")
|
||||
|
||||
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
|
||||
|
||||
IGNORE_WIDGETS = ["LaunchWindow"]
|
||||
|
||||
def rpc_call(func):
|
||||
RegistryState: TypeAlias = dict[
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
|
||||
str | bool | dict,
|
||||
]
|
||||
|
||||
# pylint: disable=redefined-outer-scope
|
||||
|
||||
|
||||
def _filter_output(output: str) -> str:
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
|
||||
Args:
|
||||
func: The function to call.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
Filter out the output from the process.
|
||||
"""
|
||||
|
||||
@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
|
||||
if "IMKClient" in output:
|
||||
# only relevant on macOS
|
||||
# see https://discussions.apple.com/thread/255761734?sortBy=rank
|
||||
return ""
|
||||
return output
|
||||
|
||||
|
||||
def _get_output(process, logger) -> None:
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.error}
|
||||
log_func = {process.stdout: logger.debug, process.stderr: logger.info}
|
||||
stream_buffer = {process.stdout: [], process.stderr: []}
|
||||
try:
|
||||
os.set_blocking(process.stdout.fileno(), False)
|
||||
@@ -75,15 +66,22 @@ def _get_output(process, logger) -> None:
|
||||
if stream in readylist:
|
||||
buf.append(stream.read(4096))
|
||||
output, _, remaining = "".join(buf).rpartition("\n")
|
||||
output = _filter_output(output)
|
||||
if output:
|
||||
log_func[stream](output)
|
||||
buf.clear()
|
||||
buf.append(remaining)
|
||||
except Exception as e:
|
||||
print(f"Error reading process output: {str(e)}")
|
||||
logger.error(f"Error reading process output: {str(e)}")
|
||||
|
||||
|
||||
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
|
||||
def _start_plot_process(
|
||||
gui_id: str,
|
||||
gui_class_id: str,
|
||||
config: dict | str,
|
||||
gui_class: str = "dock_area",
|
||||
logger=None, # FIXME change gui_class back to "launcher" later
|
||||
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
|
||||
"""
|
||||
Start the plot in a new process.
|
||||
|
||||
@@ -92,11 +90,20 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
|
||||
process will not be captured.
|
||||
"""
|
||||
# pylint: disable=subprocess-run-check
|
||||
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__]
|
||||
command = [
|
||||
"bec-gui-server",
|
||||
"--id",
|
||||
gui_id,
|
||||
"--gui_class",
|
||||
gui_class,
|
||||
"--gui_class_id",
|
||||
gui_class_id,
|
||||
"--hide",
|
||||
]
|
||||
if config:
|
||||
if isinstance(config, dict):
|
||||
config = json.dumps(config)
|
||||
command.extend(["--config", config])
|
||||
command.extend(["--config", str(config)])
|
||||
|
||||
env_dict = os.environ.copy()
|
||||
env_dict["PYTHONUNBUFFERED"] = "1"
|
||||
@@ -126,214 +133,439 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
|
||||
return process, process_output_processing_thread
|
||||
|
||||
|
||||
class BECGuiClientMixin:
|
||||
class RepeatTimer(threading.Timer):
|
||||
"""RepeatTimer class."""
|
||||
|
||||
def run(self):
|
||||
while not self.finished.wait(self.interval):
|
||||
self.function(*self.args, **self.kwargs)
|
||||
|
||||
|
||||
# pylint: disable=protected-access
|
||||
@contextmanager
|
||||
def wait_for_server(client: BECGuiClient):
|
||||
"""Context manager to wait for the server to start."""
|
||||
timeout = client._startup_timeout
|
||||
if not timeout:
|
||||
if client._gui_is_alive():
|
||||
# there is hope, let's wait a bit
|
||||
timeout = 1
|
||||
else:
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
if client._gui_started_timer is not None:
|
||||
# cancel the timer, we are done
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
else:
|
||||
raise TimeoutError("Could not connect to GUI server")
|
||||
finally:
|
||||
# after initial waiting period, do not wait so much any more
|
||||
# (only relevant if GUI didn't start)
|
||||
client._startup_timeout = 0
|
||||
yield
|
||||
|
||||
|
||||
class WidgetNameSpace:
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr, value in self.__dict__.items():
|
||||
docs = value.__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr, docs)
|
||||
console.print(table)
|
||||
return ""
|
||||
|
||||
|
||||
class AvailableWidgetsNamespace:
|
||||
"""Namespace for available widgets in the BEC GUI."""
|
||||
|
||||
def __init__(self):
|
||||
for widget in client.Widgets:
|
||||
name = widget.value
|
||||
if name in IGNORE_WIDGETS:
|
||||
continue
|
||||
setattr(self, name, name)
|
||||
|
||||
def __repr__(self):
|
||||
console = Console()
|
||||
table = Table(title="Available widgets for BEC CLI usage")
|
||||
table.add_column("Widget Name", justify="left", style="magenta")
|
||||
table.add_column("Description", justify="left")
|
||||
for attr_name, _ in self.__dict__.items():
|
||||
docs = getattr(client, attr_name).__doc__
|
||||
docs = docs if docs else "No description available"
|
||||
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
|
||||
console.print(table)
|
||||
return ""
|
||||
|
||||
|
||||
class BECGuiClient(RPCBase):
|
||||
"""BEC GUI client class. Container for GUI applications within Python."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._lock = Lock()
|
||||
self._anchor_widget = "launcher"
|
||||
self._killed = False
|
||||
self._top_level: dict[str, RPCReference] = {}
|
||||
self._startup_timeout = 0
|
||||
self._gui_started_timer = None
|
||||
self._gui_started_event = threading.Event()
|
||||
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
|
||||
self._server_registry: dict[str, RegistryState] = {}
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
register_serializer_extension()
|
||||
|
||||
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
|
||||
####################
|
||||
#### Client API ####
|
||||
####################
|
||||
|
||||
@property
|
||||
def selected_device(self):
|
||||
"""
|
||||
Selected device for the plot.
|
||||
"""
|
||||
return self._selected_device
|
||||
def launcher(self) -> RPCBase:
|
||||
"""The launcher object."""
|
||||
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
|
||||
|
||||
@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 connect_to_gui_server(self, gui_id: str) -> None:
|
||||
"""Connect to a GUI server"""
|
||||
# Unregister the old callback
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
self._gui_id = gui_id
|
||||
|
||||
def _start_update_script(self) -> None:
|
||||
# reset the namespace
|
||||
self._update_dynamic_namespace({})
|
||||
self._server_registry = {}
|
||||
self._top_level = {}
|
||||
self._ipython_registry = {}
|
||||
|
||||
# Register the new callback
|
||||
self._client.connector.register(
|
||||
self._target_endpoint, cb=self._handle_msg_update, parent=self
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
from_start=True,
|
||||
)
|
||||
|
||||
@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)
|
||||
@property
|
||||
def windows(self) -> dict:
|
||||
"""Dictionary with dock areas in the GUI."""
|
||||
return {widget.object_name: widget for widget in self._top_level.values()}
|
||||
|
||||
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.msg_queue.put(msg)
|
||||
@property
|
||||
def window_list(self) -> list:
|
||||
"""List with dock areas in the GUI."""
|
||||
return list(self._top_level.values())
|
||||
|
||||
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
|
||||
)
|
||||
while not self.gui_is_alive():
|
||||
print("Waiting for GUI to start...")
|
||||
time.sleep(1)
|
||||
def start(self, wait: bool = False) -> None:
|
||||
"""Start the GUI server."""
|
||||
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
|
||||
return self._start(wait=wait)
|
||||
|
||||
def close(self) -> None:
|
||||
def show(self, wait=True) -> None:
|
||||
"""
|
||||
Close the gui window.
|
||||
Show the GUI window.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
wait(bool): Whether to wait for the server to start. Defaults to True.
|
||||
"""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._show_all()
|
||||
return self._start(wait=wait)
|
||||
|
||||
def hide(self):
|
||||
"""Hide the GUI window."""
|
||||
return self._hide_all()
|
||||
|
||||
def raise_window(self, wait: bool = True) -> None:
|
||||
"""
|
||||
Bring GUI windows to the front.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
wait(bool): Whether to wait for the server to start. Defaults to True.
|
||||
"""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._raise_all()
|
||||
return self._start(wait=wait)
|
||||
|
||||
def new(
|
||||
self,
|
||||
name: str | None = None,
|
||||
wait: bool = True,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
launch_script: str = "dock_area",
|
||||
**kwargs,
|
||||
) -> client.BECDockArea:
|
||||
"""Create a new top-level dock area.
|
||||
|
||||
Args:
|
||||
name(str, optional): The name of the dock area. Defaults to None.
|
||||
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
|
||||
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
|
||||
Returns:
|
||||
client.BECDockArea: The new dock area.
|
||||
"""
|
||||
if not self._check_if_server_is_alive():
|
||||
self.start(wait=True)
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
"""Delete a dock area.
|
||||
|
||||
Args:
|
||||
name(str): The name of the dock area.
|
||||
"""
|
||||
widget = self.windows.get(name)
|
||||
if widget is None:
|
||||
raise ValueError(f"Dock area {name} not found.")
|
||||
widget._run_rpc("close") # pylint: disable=protected-access
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""Delete all dock areas."""
|
||||
for widget_name in self.windows:
|
||||
self.delete(widget_name)
|
||||
|
||||
def kill_server(self) -> None:
|
||||
"""Kill the GUI server."""
|
||||
# Unregister the registry state
|
||||
self._killed = True
|
||||
|
||||
if self._gui_started_timer is not None:
|
||||
self._gui_started_timer.cancel()
|
||||
self._gui_started_timer.join()
|
||||
|
||||
if self._process is None:
|
||||
return
|
||||
|
||||
self._client.shutdown()
|
||||
if self._process:
|
||||
logger.success("Stopping GUI...")
|
||||
self._process.terminate()
|
||||
if self._process_output_processing_thread:
|
||||
self._process_output_processing_thread.join()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
if self.auto_updates is not None:
|
||||
self.auto_updates.shutdown()
|
||||
|
||||
|
||||
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}"
|
||||
# Unregister the registry state
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
|
||||
)
|
||||
# Remove all reference from top level
|
||||
self._top_level.clear()
|
||||
self._server_registry.clear()
|
||||
|
||||
def close(self):
|
||||
"""Deprecated. Use kill_server() instead."""
|
||||
# FIXME, deprecated in favor of kill, will be removed in the future
|
||||
self.kill_server()
|
||||
|
||||
class RPCBase:
|
||||
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
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
|
||||
self._msg_wait_event = threading.Event()
|
||||
self._rpc_response = None
|
||||
super().__init__()
|
||||
# print(f"RPCBase: {self._gui_id}")
|
||||
#########################
|
||||
#### Private methods ####
|
||||
#########################
|
||||
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} object at {hex(id(self))}>"
|
||||
def _check_if_server_is_alive(self):
|
||||
"""Checks if the process is alive"""
|
||||
if self._process is None:
|
||||
return False
|
||||
if self._process.poll() is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def _root(self):
|
||||
def _gui_post_startup(self):
|
||||
timeout = 60
|
||||
# Wait for 'bec' gui to be registered, this may take some time
|
||||
# After 60s timeout. Should this raise an exception on timeout?
|
||||
while time.time() < time.time() + timeout:
|
||||
if len(list(self._server_registry.keys())) < 2 or not hasattr(
|
||||
self, self._anchor_widget
|
||||
):
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
break
|
||||
|
||||
self._gui_started_event.set()
|
||||
|
||||
def _start_server(self, wait: bool = False) -> None:
|
||||
"""
|
||||
Get the root widget. This is the BECFigure widget that holds
|
||||
the anchor gui_id.
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
"""
|
||||
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:
|
||||
self._rpc_response = None
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_instruction_response(request_id),
|
||||
cb=self._on_rpc_response,
|
||||
parent=self,
|
||||
if self._gui_is_alive():
|
||||
self._gui_started_event.set()
|
||||
return
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
logger.success("GUI starting...")
|
||||
self._startup_timeout = 5
|
||||
self._gui_started_event.clear()
|
||||
self._process, self._process_output_processing_thread = _start_plot_process(
|
||||
self._gui_id,
|
||||
gui_class_id="bec",
|
||||
config=self._client._service_config.config, # pylint: disable=protected-access
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
self._client.connector.set_and_publish(MessageEndpoints.gui_instructions(receiver), rpc_msg)
|
||||
def gui_started_callback(callback):
|
||||
try:
|
||||
if callable(callback):
|
||||
callback()
|
||||
finally:
|
||||
threading.current_thread().cancel() # type: ignore
|
||||
|
||||
if wait_for_rpc_response:
|
||||
try:
|
||||
finished = self._msg_wait_event.wait(10)
|
||||
if not finished:
|
||||
raise RPCResponseTimeoutError(request_id, timeout)
|
||||
finally:
|
||||
self._msg_wait_event.clear()
|
||||
self._client.connector.unregister(
|
||||
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
|
||||
)
|
||||
# get class name
|
||||
if not self._rpc_response.accepted:
|
||||
raise ValueError(self._rpc_response.message["error"])
|
||||
msg_result = self._rpc_response.message.get("result")
|
||||
self._rpc_response = None
|
||||
return self._create_widget_from_msg_result(msg_result)
|
||||
self._gui_started_timer = RepeatTimer(
|
||||
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
|
||||
)
|
||||
self._gui_started_timer.start()
|
||||
|
||||
if wait:
|
||||
self._gui_started_event.wait()
|
||||
|
||||
def _start(self, wait: bool = False) -> None:
|
||||
self._killed = False
|
||||
self._client.connector.register(
|
||||
MessageEndpoints.gui_registry_state(self._gui_id),
|
||||
cb=self._handle_registry_update,
|
||||
parent=self,
|
||||
)
|
||||
return self._start_server(wait=wait)
|
||||
|
||||
@staticmethod
|
||||
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
|
||||
msg = msg.value
|
||||
parent._msg_wait_event.set()
|
||||
parent._rpc_response = msg
|
||||
def _handle_registry_update(
|
||||
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
|
||||
) -> None:
|
||||
# This was causing a deadlock during shutdown, not sure why.
|
||||
# with self._lock:
|
||||
self = parent
|
||||
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
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)
|
||||
def _do_show_all(self):
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("show") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window.show()
|
||||
|
||||
if not cls:
|
||||
return msg_result
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
return self._do_show_all()
|
||||
|
||||
cls = getattr(client, cls)
|
||||
# print(msg_result)
|
||||
return cls(parent=self, **msg_result)
|
||||
return msg_result
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
self.launcher._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
def gui_is_alive(self):
|
||||
def _do_raise_all(self):
|
||||
"""Bring GUI windows to the front."""
|
||||
if self.launcher and len(self._top_level) == 0:
|
||||
self.launcher._run_rpc("raise") # pylint: disable=protected-access
|
||||
for window in self._top_level.values():
|
||||
window._run_rpc("raise") # type: ignore[attr-defined]
|
||||
|
||||
def _raise_all(self):
|
||||
with wait_for_server(self):
|
||||
if self._killed:
|
||||
return
|
||||
return self._do_raise_all()
|
||||
|
||||
def _update_dynamic_namespace(self, server_registry: dict):
|
||||
"""
|
||||
Check if the GUI is alive.
|
||||
Update the dynamic name space with the given server registry.
|
||||
Setting the server registry to an empty dictionary will remove all widgets from the namespace.
|
||||
|
||||
Args:
|
||||
server_registry (dict): The server registry
|
||||
"""
|
||||
heart = self._client.connector.get(MessageEndpoints.gui_heartbeat(self._root._gui_id))
|
||||
if heart is None:
|
||||
return False
|
||||
if heart.status == messages.BECStatus.RUNNING:
|
||||
return True
|
||||
return False
|
||||
top_level_widgets: dict[str, RPCReference] = {}
|
||||
for gui_id, state in server_registry.items():
|
||||
widget = self._add_widget(state, self)
|
||||
if widget is None:
|
||||
# ignore widgets that are not supported
|
||||
continue
|
||||
# get all top-level widgets. These are widgets that have no parent
|
||||
if not state["config"].get("parent_id"):
|
||||
top_level_widgets[gui_id] = widget
|
||||
|
||||
remove_from_registry = []
|
||||
for gui_id, widget in self._ipython_registry.items():
|
||||
if gui_id not in server_registry:
|
||||
remove_from_registry.append(gui_id)
|
||||
for gui_id in remove_from_registry:
|
||||
self._ipython_registry.pop(gui_id)
|
||||
|
||||
removed_widgets = [
|
||||
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
|
||||
]
|
||||
|
||||
for widget_name in removed_widgets:
|
||||
# the check is not strictly necessary, but better safe
|
||||
# than sorry; who knows what the user has done
|
||||
if hasattr(self, widget_name):
|
||||
delattr(self, widget_name)
|
||||
|
||||
for gui_id, widget_ref in top_level_widgets.items():
|
||||
setattr(self, widget_ref.object_name, widget_ref)
|
||||
|
||||
self._top_level = top_level_widgets
|
||||
|
||||
for widget in self._ipython_registry.values():
|
||||
widget._refresh_references()
|
||||
|
||||
def _add_widget(self, state: dict, parent: object) -> RPCReference | None:
|
||||
"""Add a widget to the namespace
|
||||
|
||||
Args:
|
||||
state (dict): The state of the widget from the _server_registry.
|
||||
parent (object): The parent object.
|
||||
"""
|
||||
object_name = state["object_name"]
|
||||
gui_id = state["gui_id"]
|
||||
if state["widget_class"] in IGNORE_WIDGETS:
|
||||
return
|
||||
widget_class = getattr(client, state["widget_class"], None)
|
||||
if widget_class is None:
|
||||
return
|
||||
obj = self._ipython_registry.get(gui_id)
|
||||
if obj is None:
|
||||
widget = widget_class(gui_id=gui_id, object_name=object_name, parent=parent)
|
||||
self._ipython_registry[gui_id] = widget
|
||||
else:
|
||||
widget = obj
|
||||
obj = RPCReference(registry=self._ipython_registry, gui_id=gui_id)
|
||||
return obj
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
|
||||
try:
|
||||
config = ServiceConfig()
|
||||
bec_client = BECClient(config)
|
||||
bec_client.start()
|
||||
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
|
||||
gui.show(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
gui.kill_server()
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import black
|
||||
import isort
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property as QtProperty
|
||||
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator, plugin_filenames
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import get_overloads
|
||||
@@ -28,12 +34,29 @@ else:
|
||||
|
||||
|
||||
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
|
||||
def __init__(self, base=False):
|
||||
self._base = base
|
||||
base_imports = (
|
||||
"""import enum
|
||||
import inspect
|
||||
import traceback
|
||||
from functools import reduce
|
||||
from operator import add
|
||||
from typing import Literal, Optional
|
||||
"""
|
||||
if self._base
|
||||
else "\n"
|
||||
)
|
||||
self.header = f"""# This file was automatically generated by generate_cli.py
|
||||
# type: ignore \n
|
||||
from __future__ import annotations
|
||||
{base_imports}
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
|
||||
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# pylint: skip-file"""
|
||||
|
||||
@@ -41,18 +64,26 @@ from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
|
||||
def generate_client(self, class_container: BECClassContainer):
|
||||
"""
|
||||
Generate the client for the published classes.
|
||||
Generate the client for the published classes, skipping any classes
|
||||
that have `RPC = False`.
|
||||
|
||||
Args:
|
||||
class_container: The class container with the classes to generate the client for.
|
||||
"""
|
||||
rpc_top_level_classes = class_container.rpc_top_level_classes
|
||||
# Filter out classes that explicitly have RPC=False
|
||||
rpc_top_level_classes = [
|
||||
cls for cls in class_container.rpc_top_level_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
rpc_top_level_classes.sort(key=lambda x: x.__name__)
|
||||
connector_classes = class_container.connector_classes
|
||||
|
||||
connector_classes = [
|
||||
cls for cls in class_container.connector_classes if getattr(cls, "RPC", True)
|
||||
]
|
||||
connector_classes.sort(key=lambda x: x.__name__)
|
||||
|
||||
self.write_client_enum(rpc_top_level_classes)
|
||||
for cls in connector_classes:
|
||||
logger.debug(f"generating RPC client class for {cls.__name__}")
|
||||
self.content += "\n\n"
|
||||
self.generate_content_for_class(cls)
|
||||
|
||||
@@ -60,14 +91,50 @@ from bec_widgets.cli.client_utils import RPCBase, rpc_call, BECGuiClientMixin
|
||||
"""
|
||||
Write the client enum to the content.
|
||||
"""
|
||||
if self._base:
|
||||
self.content += """
|
||||
class _WidgetsEnumType(str, enum.Enum):
|
||||
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
|
||||
...
|
||||
"""
|
||||
|
||||
self.content += """
|
||||
class Widgets(str, enum.Enum):
|
||||
\"\"\"
|
||||
Enum for the available widgets.
|
||||
\"\"\"
|
||||
|
||||
_Widgets = {
|
||||
"""
|
||||
for cls in published_classes:
|
||||
self.content += f'{cls.__name__} = "{cls.__name__}"\n '
|
||||
self.content += f'"{cls.__name__}": "{cls.__name__}",\n '
|
||||
|
||||
self.content += """}
|
||||
"""
|
||||
if self._base:
|
||||
self.content += """
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets().as_dict()
|
||||
plugin_client = get_plugin_client_module()
|
||||
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
|
||||
|
||||
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
|
||||
for _widget in _overlap:
|
||||
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
|
||||
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
|
||||
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
|
||||
if plugin_name in globals():
|
||||
conflicting_file = (
|
||||
inspect.getfile(_plugin_widgets[plugin_name])
|
||||
if plugin_name in _plugin_widgets
|
||||
else f"{plugin_client}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
|
||||
)
|
||||
continue
|
||||
if plugin_name not in _overlap:
|
||||
globals()[plugin_name] = plugin_class
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
|
||||
"""
|
||||
|
||||
def generate_content_for_class(self, cls):
|
||||
"""
|
||||
@@ -79,22 +146,55 @@ class Widgets(str, enum.Enum):
|
||||
|
||||
class_name = cls.__name__
|
||||
|
||||
# Generate the content
|
||||
if cls.__name__ == "BECDockArea":
|
||||
if class_name == "BECDockArea":
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase, BECGuiClientMixin):"""
|
||||
class {class_name}(RPCBase):"""
|
||||
else:
|
||||
self.content += f"""
|
||||
class {class_name}(RPCBase):"""
|
||||
|
||||
if cls.__doc__:
|
||||
# We only want the first line of the docstring
|
||||
# But skip the first line if it's a blank line
|
||||
first_line = cls.__doc__.split("\n")[0]
|
||||
if first_line:
|
||||
class_docs = first_line
|
||||
else:
|
||||
class_docs = cls.__doc__.split("\n")[1]
|
||||
self.content += f"""
|
||||
\"\"\"{class_docs}\"\"\"
|
||||
"""
|
||||
if not cls.USER_ACCESS:
|
||||
self.content += """...
|
||||
"""
|
||||
|
||||
for method in cls.USER_ACCESS:
|
||||
obj = getattr(cls, method)
|
||||
if isinstance(obj, property):
|
||||
self.content += """
|
||||
is_property_setter = False
|
||||
obj = getattr(cls, method, None)
|
||||
if obj is None:
|
||||
obj = getattr(cls, method.split(".setter")[0], None)
|
||||
is_property_setter = True
|
||||
method = method.split(".setter")[0]
|
||||
if obj is None:
|
||||
raise AttributeError(
|
||||
f"Method {method} not found in class {cls.__name__}. "
|
||||
f"Please check the USER_ACCESS list."
|
||||
)
|
||||
if hasattr(obj, "__rpc_timeout__"):
|
||||
timeout = {"value": obj.__rpc_timeout__}
|
||||
else:
|
||||
timeout = {}
|
||||
if isinstance(obj, (property, QtProperty)):
|
||||
# for the cli, we can map qt properties to regular properties
|
||||
if is_property_setter:
|
||||
self.content += f"""
|
||||
@{method}.setter
|
||||
@rpc_call"""
|
||||
else:
|
||||
self.content += """
|
||||
@property
|
||||
@rpc_call"""
|
||||
|
||||
sig = str(inspect.signature(obj.fget))
|
||||
doc = inspect.getdoc(obj.fget)
|
||||
else:
|
||||
@@ -108,14 +208,26 @@ class {class_name}(RPCBase):"""
|
||||
def {method}{str(sig_overload)}: ...
|
||||
"""
|
||||
|
||||
self.content += """
|
||||
@rpc_call"""
|
||||
self.content += f"""
|
||||
{self._rpc_call(timeout)}"""
|
||||
self.content += f"""
|
||||
def {method}{str(sig)}:
|
||||
\"\"\"
|
||||
{doc}
|
||||
\"\"\""""
|
||||
|
||||
def _rpc_call(self, timeout_info: dict[str, float | None]):
|
||||
"""
|
||||
Decorator to mark a method as an RPC call.
|
||||
This is used to generate the client code for the method.
|
||||
"""
|
||||
if not timeout_info:
|
||||
return "@rpc_call"
|
||||
timeout = timeout_info.get("value", None)
|
||||
return f"""
|
||||
@rpc_timeout({timeout})
|
||||
@rpc_call"""
|
||||
|
||||
def write(self, file_name: str):
|
||||
"""
|
||||
Write the content to a file, automatically formatted with black.
|
||||
@@ -126,18 +238,18 @@ class {class_name}(RPCBase):"""
|
||||
# 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))
|
||||
formatted_content = black.format_str(full_content, mode=black.Mode(line_length=100))
|
||||
except black.NothingChanged:
|
||||
formatted_content = full_content
|
||||
|
||||
isort.Config(
|
||||
config = isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=True,
|
||||
include_trailing_comma=False,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content)
|
||||
formatted_content = isort.code(formatted_content, config=config)
|
||||
|
||||
with open(file_name, "w", encoding="utf-8") as file:
|
||||
file.write(formatted_content)
|
||||
@@ -149,41 +261,78 @@ def main():
|
||||
"""
|
||||
|
||||
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")
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
action="store",
|
||||
type=str,
|
||||
help="Which package to generate plugin files for. Should be installed in the local environment (example: my_plugin_repo)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.target is None:
|
||||
logger.error(
|
||||
"You must provide a target - for safety, the default of running this on bec_widgets core has been removed. To generate the client for bec_widgets, run `bw-generate-cli --target bec_widgets`"
|
||||
)
|
||||
return
|
||||
|
||||
if args.core:
|
||||
current_path = os.path.dirname(__file__)
|
||||
client_path = os.path.join(current_path, "client.py")
|
||||
logger.info(f"BEC Widget code generation tool started with args: {args}")
|
||||
|
||||
rpc_classes = get_rpc_classes("bec_widgets")
|
||||
client_subdir = "cli" if args.target == "bec_widgets" else "widgets"
|
||||
module_name = "bec_widgets" if args.target == "bec_widgets" else f"{args.target}.bec_widgets"
|
||||
|
||||
generator = ClientGenerator()
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(client_path)
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
assert module.__file__ is not None
|
||||
module_file = Path(module.__file__)
|
||||
module_dir = module_file.parent if module_file.is_file() else module_file
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load module {module_name} for code generation: {e}")
|
||||
return
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
client_path = module_dir / client_subdir / "client.py"
|
||||
|
||||
# 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()
|
||||
rpc_classes = get_custom_classes(module_name)
|
||||
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
|
||||
|
||||
generator = ClientGenerator(base=module_name == "bec_widgets")
|
||||
logger.info(f"Generating client file at {client_path}")
|
||||
generator.generate_client(rpc_classes)
|
||||
generator.write(str(client_path))
|
||||
|
||||
if module_name != "bec_widgets":
|
||||
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
|
||||
logger.info(
|
||||
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
|
||||
)
|
||||
else:
|
||||
non_overwrite_classes = []
|
||||
|
||||
for cls in rpc_classes.plugins:
|
||||
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
|
||||
|
||||
if cls.__name__ in non_overwrite_classes:
|
||||
logger.error(
|
||||
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
|
||||
)
|
||||
|
||||
plugin = DesignerPluginGenerator(cls)
|
||||
if not hasattr(plugin, "info"):
|
||||
continue
|
||||
|
||||
def _exists(file: str):
|
||||
return os.path.exists(os.path.join(plugin.info.base_path, file))
|
||||
|
||||
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
|
||||
logger.debug(
|
||||
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
|
||||
)
|
||||
continue
|
||||
|
||||
plugin.run()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.argv = ["generate_cli.py", "--core"]
|
||||
import sys
|
||||
|
||||
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
|
||||
main()
|
||||
|
||||