mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 10:10:55 +02:00
Compare commits
553 Commits
feat/dap_t
...
v2.45.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6aa8e138 | ||
| 198684c65d | |||
| 617f2df2af | |||
|
|
ef83287126 | ||
| d5e6f095fe | |||
| b10efc0f40 | |||
| 44b1dbf911 | |||
|
|
e9d381a18a | ||
|
|
b005542df3 | ||
| 13a9175ba5 | |||
|
|
3f8e60a14f | ||
| 6bc1c3c5f1 | |||
|
|
9f91eb2e08 | ||
| 1e19092319 | |||
| 96664c3923 | |||
|
|
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 |
41
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
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]
|
||||
|
||||
64
.github/actions/bw_install/action.yml
vendored
Normal file
64
.github/actions/bw_install/action.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
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
|
||||
|
||||
- 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
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
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
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
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
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
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
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
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
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
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
|
||||
64
.github/workflows/pytest.yml
vendored
Normal file
64
.github/workflows/pytest.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
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 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
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
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 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 14 days.'
|
||||
days-before-stale: 120
|
||||
days-before-close: 14
|
||||
40
.github/workflows/sync-issues-pr.yml
vendored
Normal file
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
3
.gitignore
vendored
@@ -64,6 +64,9 @@ coverage.xml
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Output from end2end testing
|
||||
tests/reference_failures/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
275
.gitlab-ci.yml
275
.gitlab-ci.yml
@@ -1,275 +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:
|
||||
description: bec branch
|
||||
value: main
|
||||
OPHYD_DEVICES_BRANCH:
|
||||
description: ophyd_devices branch
|
||||
value: main
|
||||
CHILD_PIPELINE_BRANCH: $CI_DEFAULT_BRANCH
|
||||
CHECK_PKG_VERSIONS:
|
||||
description: Whether to run additional tests against min/max/random selection of dependencies. Set to 1 for running.
|
||||
value: 0
|
||||
|
||||
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/unit_tests"
|
||||
pip_args: ".[dev]"
|
||||
|
||||
# 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
|
||||
- pip install -e ./bec/pytest_bec_e2e
|
||||
|
||||
.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 bec_lib[dev]
|
||||
- isort --check --diff --line-length=100 --profile=black --multi-line=3 --trailing-comma ./
|
||||
- black --check --diff --color --line-length=100 --skip-magic-trailing-comma ./
|
||||
rules:
|
||||
- if: $CI_PROJECT_PATH == "bec/bec_widgets"
|
||||
|
||||
pylint:
|
||||
stage: Formatter
|
||||
needs: []
|
||||
before_script:
|
||||
- pip install pylint pylint-exit anybadge
|
||||
- pip install -e .[dev]
|
||||
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,pyside6]
|
||||
- coverage run --source=./bec_widgets -m pytest -v --junitxml=report.xml --maxfail=2 --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"
|
||||
|
||||
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 --maxfail=2 --junitxml=report.xml --random-order ./tests/unit_tests
|
||||
|
||||
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 --show-sources
|
||||
- conda config --add channels conda-forge
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/main
|
||||
- conda config --remove channels https://repo.anaconda.com/pkgs/r
|
||||
- conda config --show-sources
|
||||
- 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,pyside6]
|
||||
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
|
||||
|
||||
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"'
|
||||
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
|
||||
|
||||
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]
|
||||
|
||||
5189
CHANGELOG.md
5189
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
2
LICENSE
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
|
||||
|
||||
220
README.md
220
README.md
@@ -1,70 +1,200 @@
|
||||

|
||||
|
||||
# BEC Widgets
|
||||
|
||||
**⚠️ Important Notice:**
|
||||
[](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)
|
||||
|
||||
🚨 **PyQt6 is no longer supported** due to incompatibilities with Qt Designer. Please use **PySide6** instead. 🚨
|
||||
A modular PySide6(Qt6) toolkit for [BEC (Beamline Experiment Control)](https://github.com/bec-project/bec). Create
|
||||
high-performance, dockable GUIs to move devices, run scans, and stream live or disk data—powered by Redis and a modular
|
||||
plugin system.
|
||||
|
||||
BEC Widgets is a GUI framework designed for interaction with [BEC (Beamline Experiment Control)](https://gitlab.psi.ch/bec/bec).
|
||||
## Highlights
|
||||
|
||||
- **No-code first** — For ~90% of day-to-day workflows, you can compose, operate, and save workspaces **without writing
|
||||
a single line of code**. Just launch, drag widgets, and do your experiment.
|
||||
- **Flexible layout composition** — Build complex experiment GUIs in seconds with the `BECDockArea`: drag‑dock, tab,
|
||||
split, and export profiles/workspaces for reuse.
|
||||
- **CLI / scripting** — Control your beamline experiment from the command line a robust RPC layer using
|
||||
`BECIPythonClient`.
|
||||
- **Designer integration** — Use Qt Designer plugins to drop BEC widgets next to any Qt control, then launch the `.ui`
|
||||
with the custom BEC loader for a zero‑glue workflow.
|
||||
- **Operational integration** — Widgets stay in sync with your running BEC/Redis as the single source of truth:
|
||||
Subscribe to events from BEC and create dynamically updating UIs. BECWidgets also grants you easy access the
|
||||
acquisition history.
|
||||
- **Extensible by design** — Build new widgets with minimal boilerplate using `BECWidget` and `BECDispatcher` for BEC data and
|
||||
messaging. Use the generator command to scaffold RPC interfaces and Designer plugin stubs; beamline plugins can extend
|
||||
or override behavior as needed.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Features](#features)
|
||||
- [1. Dock area interface: build GUIs in seconds](#1-dock-area-interface-build-guis-in-seconds)
|
||||
- [2. Qt Designer plugins + BEC Launcher (no glue)](#2-qt-designer-plugins--bec-launcher-no-glue)
|
||||
- [3. Robust RPC from CLI & remote scripting](#3-robust-rpc-from-cli--remote-scripting)
|
||||
- [4. Rapid development (extensible by design)](#4-rapid-development-extensible-by-design)
|
||||
- [Widget Library](#widget-library)
|
||||
- [Documentation](#documentation)
|
||||
- [License](#license)
|
||||
|
||||
## Installation
|
||||
|
||||
Use any of the following setups:
|
||||
|
||||
### Stable release
|
||||
|
||||
Use the package manager [pip](https://pip.pypa.io/en/stable/) to install BEC Widgets:
|
||||
|
||||
```bash
|
||||
pip install bec_widgets[pyside6]
|
||||
pip install bec_widgets
|
||||
```
|
||||
|
||||
### From source (recommended for development)
|
||||
|
||||
For development purposes, you can clone the repository and install the package locally in editable mode:
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.psi.ch/bec/bec-widgets
|
||||
git clone https://github.com/bec-project/bec_widgets.git
|
||||
cd bec_widgets
|
||||
pip install -e .[dev,pyside6]
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
BEC Widgets now **only supports PySide6**. Users must manually install PySide6 as no default Qt distribution is
|
||||
specified.
|
||||
## Features
|
||||
|
||||
### 1. Dock area interface: build GUIs in seconds
|
||||
|
||||
The fastest way to explore BEC Widgets. Launch the BEC IPython client with simply `bec` in terminal and the **BECDockArea** opens as the default UI:
|
||||
drag widgets, dock/tab/split panes, and explore. Everything is live—widgets auto-connect to BEC/Redis, so you can
|
||||
operate immediately and refine later with RPC or Designer if needed.
|
||||
|
||||

|
||||
|
||||
### 2. Qt Designer plugins + BEC Launcher (no glue)
|
||||
|
||||
All BEC Widgets ship as **Qt Designer plugins** with our custom Qt Designer launchable by `bec-designer`. Design your UI
|
||||
visually in Designer, save a `.ui`, then launch it with
|
||||
the **BEC Launcher**—no glue code. Widgets auto‑connect to BEC/Redis on startup, so your UI is operational immediately.
|
||||
|
||||

|
||||
|
||||
### 3. Robust RPC from CLI & remote scripting
|
||||
|
||||
Operate and automate BEC Widgets directly from the `BECIPythonClient`. Create or attach to GUIs, address any sub-widget
|
||||
via a simple hierarchical API with tab-completion, and script event-driven behavior that reacts to BEC (scan lifecycle,
|
||||
active devices, topics)—so your UI can be heavily automated.
|
||||
|
||||
- Create & control GUIs: launch, load profiles, open/close panels, tweak properties—all from the shell.
|
||||
- Hierarchical addressing: navigate widgets and sub-widgets with discoverable paths and tab-completion.
|
||||
- Event scripting: subscribe to BEC events (e.g., scan start/finish, device readiness, topic updates) and trigger
|
||||
actions,switch profiles, open diagnostic views, or start specific scans.
|
||||
- Remote & headless: run automation on analysis nodes or from notebooks without a local GUI process.
|
||||
- Plays with no-code: Use the Dock Area / BEC Designer to set up the layout and add automation with RPC when needed.
|
||||
|
||||

|
||||
|
||||
### 4. Rapid development (extensible by design)
|
||||
|
||||
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
|
||||
bind endpoints. Then run `bw-generate-cli --target <your-plugin-repo>`. This generates the RPC CLI bindings and a Qt
|
||||
Designer plugin that are immediately usable with your BEC setup. Widgets
|
||||
come online with live BEC/Redis wiring out of the box. 
|
||||
|
||||
<details>
|
||||
<summary> View code: Example Widget </summary>
|
||||
|
||||
```python
|
||||
from typing import Literal
|
||||
|
||||
from qtpy.QtWidgets import QWidget, QLabel, QPushButton, QHBoxLayout, QVBoxLayout, QApplication
|
||||
from qtpy.QtCore import Slot
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
|
||||
|
||||
class SimpleMotorWidget(BECWidget, QWidget):
|
||||
USER_ACCESS = ["move"]
|
||||
|
||||
def __init__(self, parent=None, motor_name="samx", step=5.0, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.motor_name = motor_name
|
||||
self.step = float(step)
|
||||
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self.value_label = QLabel(f"{self.motor_name}: —")
|
||||
self.btn_left = QPushButton("◀︎ -5")
|
||||
self.btn_right = QPushButton("+5 ▶︎")
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.btn_left)
|
||||
row.addWidget(self.btn_right)
|
||||
|
||||
col = QVBoxLayout(self)
|
||||
col.addWidget(self.value_label)
|
||||
col.addLayout(row)
|
||||
|
||||
self.btn_left.clicked.connect(lambda: self.move("left", self.step))
|
||||
self.btn_right.clicked.connect(lambda: self.move("right", self.step))
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.on_readback, MessageEndpoints.device_readback(self.motor_name))
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_readback(self, data: dict, meta: dict):
|
||||
current_value = data.get("signals").get(self.motor_name).get('value')
|
||||
self.value_label.setText(f"{self.motor_name}: {current_value:.3f}")
|
||||
|
||||
@Slot(str, float)
|
||||
def move(self, direction: Literal["left", "right"] = "left", step: float = 5.0):
|
||||
if direction == "left":
|
||||
self.dev[self.motor_name].move(-step, relative=True)
|
||||
else:
|
||||
self.dev[self.motor_name].move(step, relative=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = SimpleMotorWidget(motor_name="samx", step=5.0)
|
||||
w.setWindowTitle("MotorJogWidget")
|
||||
w.resize(280, 90)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Widget Library
|
||||
|
||||
A large and growing catalog—plug, configure, run:
|
||||
|
||||
### Plotting
|
||||
|
||||
Waveform, MultiWaveform, and Image/Heatmap widgets deliver responsive plots with crosshairs and ROIs for live and
|
||||
history data.
|
||||
|
||||
<img width="1108" height="838" alt="plotting_hr" src="https://github.com/user-attachments/assets/f50462a5-178d-44d4-aee5-d378c74b107b" />
|
||||
|
||||
### Scan orchestration and motion control.
|
||||
|
||||
Start and stop scans, track progress, reuse parameter presets, and browse history from a focused control surface.
|
||||
Positioner boxes and tweak controls handle precise moves, homing, and calibration for day‑to‑day alignment.
|
||||
|
||||
<img width="1496" height="1388" alt="control" src="https://github.com/user-attachments/assets/d4fb2e2e-04f9-4621-8087-790680797620" />
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation of BEC Widgets can be found [here](https://bec-widgets.readthedocs.io/en/latest/). The documentation of the BEC can be found [here](https://bec.readthedocs.io/en/latest/).
|
||||
|
||||
## Contributing
|
||||
|
||||
All commits should use the Angular commit scheme:
|
||||
|
||||
> #### <a name="commit-header"></a>Angular Commit Message Header
|
||||
>
|
||||
> ```
|
||||
> <type>(<scope>): <short summary>
|
||||
> │ │ │
|
||||
> │ │ └─⫸ Summary in present tense. Not capitalized. No period at the end.
|
||||
> │ │
|
||||
> │ └─⫸ Commit Scope: animations|bazel|benchpress|common|compiler|compiler-cli|core|
|
||||
> │ elements|forms|http|language-service|localize|platform-browser|
|
||||
> │ platform-browser-dynamic|platform-server|router|service-worker|
|
||||
> │ upgrade|zone.js|packaging|changelog|docs-infra|migrations|ngcc|ve|
|
||||
> │ devtools
|
||||
> │
|
||||
> └─⫸ Commit Type: build|ci|docs|feat|fix|perf|refactor|test
|
||||
> ```
|
||||
>
|
||||
> The `<type>` and `<summary>` fields are mandatory, the `(<scope>)` field is optional.
|
||||
|
||||
> ##### Type
|
||||
>
|
||||
> Must be one of the following:
|
||||
>
|
||||
> * **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
> * **ci**: Changes to our CI configuration files and scripts (examples: CircleCi, SauceLabs)
|
||||
> * **docs**: Documentation only changes
|
||||
> * **feat**: A new feature
|
||||
> * **fix**: A bug fix
|
||||
> * **perf**: A code change that improves performance
|
||||
> * **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
> * **test**: Adding missing tests or correcting existing tests
|
||||
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/).
|
||||
|
||||
## License
|
||||
|
||||
[BSD-3-Clause](https://choosealicense.com/licenses/bsd-3-clause/)
|
||||
|
||||
|
||||
28
THIRD-PARTY-LICENCES
Normal file
28
THIRD-PARTY-LICENCES
Normal file
@@ -0,0 +1,28 @@
|
||||
While BEC Widgets is shipped with BSD-3-Clause license, it includes third-party components with different licenses. Below is a list of these components along with their respective licenses.
|
||||
|
||||
Core Dependencies:
|
||||
- BEC: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- black: MIT License, see [here](https://github.com/psf/black/blob/main/LICENSE)
|
||||
- isort: MIT License, see [here](https://github.com/PyCQA/isort/blob/main/LICENSE)
|
||||
- pydantic: MIT License, see [here](https://github.com/pydantic/pydantic/blob/main/LICENSE)
|
||||
- pyqtgraph: MIT License, see [here](https://github.com/pyqtgraph/pyqtgraph/blob/master/LICENSE.txt)
|
||||
- PySide6: LGPLv3 License, see [here](https://doc.qt.io/qtforpython/licenses.html)
|
||||
- qtconsole: BSD-3-Clause License, see [here](https://github.com/spyder-ide/qtconsole/blob/main/LICENSE)
|
||||
- qtpy: MIT License, see [here](https://github.com/spyder-ide/qtpy/blob/master/LICENSE.txt)
|
||||
- qtmonaco: BSD-3-Clause License, see [here](https://github.com/bec-project/qtmonaco/blob/main/LICENSE)
|
||||
- thefuzz: MIT License, see [here](https://github.com/seatgeek/thefuzz/blob/master/LICENSE.txt)
|
||||
|
||||
|
||||
Additional Dependencies (Testing/Development):
|
||||
- coverage: Apache License 2.0, see [here](https://github.com/coveragepy/coveragepy/blob/main/LICENSE.txt)
|
||||
- fakeredis: BSD-3-Clause License, see [here](https://github.com/cunla/fakeredis-py/blob/master/LICENSE)
|
||||
- pytest-bec-e2e: BSD-3-Clause License, see [here](https://github.com/bec-project/bec/blob/main/LICENSE)
|
||||
- pytest-qt: MIT License, see [here](https://github.com/pytest-dev/pytest-qt/blob/master/LICENSE)
|
||||
- pytest-random-order: MIT License, see [here](https://github.com/pytest-dev/pytest-random-order/blob/main/LICENSE)
|
||||
- pytest-timeout: MIT License, see [here](https://github.com/pytest-dev/pytest-timeout/blob/main/LICENSE)
|
||||
- pytest-xvfb: MIT License, see [here](https://github.com/The-Compiler/pytest-xvfb/blob/master/LICENSE)
|
||||
- pytest: MIT License, see [here](https://github.com/pytest-dev/pytest/blob/main/LICENSE)
|
||||
- pytest-cov: MIT License, see [here](https://github.com/pytest-dev/pytest-cov/blob/main/LICENSE)
|
||||
- watchdog: Apache License 2.0, see [here](https://github.com/gorakhargosh/watchdog/blob/master/LICENSE)
|
||||
- pre_commit: MIT License, see [here](https://github.com/pre-commit/pre-commit/blob/main/LICENSE)
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
""" This module contains the GUI for the 1D alignment application.
|
||||
It is a preliminary version of the GUI, which will be added to the main branch and steadily updated to be improved.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.device import Signal as BECSignal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
|
||||
PositionerGroup,
|
||||
)
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
|
||||
from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import BECProgressBar
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
|
||||
class Alignment1D:
|
||||
"""Alignment GUI to perform 1D scans"""
|
||||
|
||||
def __init__(self, client=None, gui_id: Optional[str] = None) -> None:
|
||||
"""Initialization
|
||||
|
||||
Args:
|
||||
config: Configuration of the application.
|
||||
client: BEC client object.
|
||||
gui_id: GUI ID.
|
||||
"""
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
QApplication.instance().aboutToQuit.connect(self.close)
|
||||
self.dev = self.client.device_manager.devices
|
||||
|
||||
self._accent_colors = get_accent_colors()
|
||||
self.ui_file = "alignment_1d.ui"
|
||||
self.ui = None
|
||||
self.progress_bar = None
|
||||
self.waveform = None
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialise the UI from QT Designer file"""
|
||||
current_path = os.path.dirname(__file__)
|
||||
self.ui = UILoader(None).loader(os.path.join(current_path, self.ui_file))
|
||||
# Customize the plotting widget
|
||||
self.waveform = self.ui.findChild(BECWaveformWidget, "bec_waveform_widget")
|
||||
self._customise_bec_waveform_widget()
|
||||
# Setup comboboxes for motor and signal selection
|
||||
# FIXME after changing the filtering in the combobox
|
||||
self._setup_signal_combobox()
|
||||
# Setup motor indicator
|
||||
self._setup_motor_indicator()
|
||||
# Setup progress bar
|
||||
self._setup_progress_bar()
|
||||
# Add actions buttons
|
||||
self._customise_buttons()
|
||||
# Hook scaninfo updates
|
||||
self.bec_dispatcher.connect_slot(self.scan_status_callback, MessageEndpoints.scan_status())
|
||||
|
||||
def show(self):
|
||||
return self.ui.show()
|
||||
|
||||
##############################
|
||||
############ SLOTS ###########
|
||||
##############################
|
||||
|
||||
@Slot(dict, dict)
|
||||
def scan_status_callback(self, content: dict, _) -> None:
|
||||
"""This slot allows to enable/disable the UI critical components when a scan is running"""
|
||||
if content["status"] in ["open"]:
|
||||
self.enable_ui(False)
|
||||
elif content["status"] in ["aborted", "halted", "closed"]:
|
||||
self.enable_ui(True)
|
||||
|
||||
@Slot(tuple)
|
||||
def move_to_center(self, move_request: tuple) -> None:
|
||||
"""Move the selected motor to the center"""
|
||||
motor = self.ui.device_combobox.currentText()
|
||||
if move_request[0] in ["center", "center1", "center2"]:
|
||||
pos = move_request[1]
|
||||
self.dev.get(motor).move(float(pos), relative=False)
|
||||
|
||||
@Slot()
|
||||
def reset_progress_bar(self) -> None:
|
||||
"""Reset the progress bar"""
|
||||
self.progress_bar.set_value(0)
|
||||
self.progress_bar.set_minimum(0)
|
||||
|
||||
@Slot(dict, dict)
|
||||
def update_progress_bar(self, content: dict, _) -> None:
|
||||
"""Hook to update the progress bar
|
||||
|
||||
Args:
|
||||
content: Content of the scan progress message.
|
||||
metadata: Metadata of the message.
|
||||
"""
|
||||
if content["max_value"] == 0:
|
||||
self.progress_bar.set_value(0)
|
||||
return
|
||||
self.progress_bar.set_maximum(content["max_value"])
|
||||
self.progress_bar.set_value(content["value"])
|
||||
|
||||
@Slot()
|
||||
def clear_queue(self) -> None:
|
||||
"""Clear the scan queue"""
|
||||
self.queue.request_queue_reset()
|
||||
|
||||
##############################
|
||||
######## END OF SLOTS ########
|
||||
##############################
|
||||
|
||||
def enable_ui(self, enable: bool) -> None:
|
||||
"""Enable or disable the UI components"""
|
||||
# Enable/disable motor and signal selection
|
||||
self.ui.device_combobox_2.setEnabled(enable)
|
||||
# Enable/disable DAP selection
|
||||
self.ui.dap_combo_box.setEnabled(enable)
|
||||
# Enable/disable Scan Button
|
||||
# self.ui.scan_button.setEnabled(enable)
|
||||
# Disable move to buttons in LMFitDialog
|
||||
self.ui.findChild(LMFitDialog).set_actions_enabled(enable)
|
||||
|
||||
def _customise_buttons(self) -> None:
|
||||
"""Add action buttons for the Action Control.
|
||||
In addition, we are adding a callback to also clear the queue to the stop button
|
||||
to ensure that upon clicking the button, no scans from another client may be queued
|
||||
which would be confusing without the queue widget.
|
||||
"""
|
||||
fit_dialog = self.ui.findChild(LMFitDialog)
|
||||
fit_dialog.active_action_list = ["center", "center1", "center2"]
|
||||
fit_dialog.move_action.connect(self.move_to_center)
|
||||
stop_button = self.ui.findChild(StopButton)
|
||||
stop_button.button.setText("Stop and Clear Queue")
|
||||
stop_button.button.clicked.connect(self.clear_queue)
|
||||
|
||||
def _customise_bec_waveform_widget(self) -> None:
|
||||
"""Customise the BEC Waveform Widget, i.e. clear the toolbar"""
|
||||
self.waveform.toolbar.clear()
|
||||
|
||||
def _setup_motor_indicator(self) -> None:
|
||||
"""Setup the arrow item"""
|
||||
self.waveform.waveform.tick_item.add_to_plot()
|
||||
positioner_box = self.ui.findChild(PositionerGroup)
|
||||
positioner_box.position_update.connect(self.waveform.waveform.tick_item.set_position)
|
||||
self.waveform.waveform.tick_item.set_position(0)
|
||||
|
||||
def _setup_signal_combobox(self) -> None:
|
||||
"""Setup signal selection"""
|
||||
# FIXME after changing the filtering in the combobox
|
||||
signals = [name for name in self.dev if isinstance(self.dev.get(name), BECSignal)]
|
||||
self.ui.device_combobox_2.setCurrentText(signals[0])
|
||||
self.ui.device_combobox_2.set_device_filter("Signal")
|
||||
|
||||
def _setup_progress_bar(self) -> None:
|
||||
"""Setup progress bar"""
|
||||
# FIXME once the BECScanProgressBar is implemented
|
||||
self.progress_bar = self.ui.findChild(BECProgressBar, "bec_progress_bar")
|
||||
self.progress_bar.set_value(0)
|
||||
self.ui.bec_waveform_widget.new_scan.connect(self.reset_progress_bar)
|
||||
self.bec_dispatcher.connect_slot(self.update_progress_bar, MessageEndpoints.scan_progress())
|
||||
|
||||
def close(self):
|
||||
logger.info("Disconnecting", repr(self.bec_dispatcher))
|
||||
self.bec_dispatcher.disconnect_all()
|
||||
logger.info("Shutting down BEC Client", repr(self.client))
|
||||
self.client.shutdown()
|
||||
|
||||
|
||||
def main():
|
||||
import sys
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
icon = QIcon()
|
||||
icon.addFile(
|
||||
os.path.join(MODULE_PATH, "assets", "app_icons", "alignment_1d.png"), size=QSize(48, 48)
|
||||
)
|
||||
app.setWindowIcon(icon)
|
||||
window = Alignment1D()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,615 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>mainWindow</class>
|
||||
<widget class="QMainWindow" name="mainWindow">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>1611</width>
|
||||
<height>1019</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Alignment tool</string>
|
||||
</property>
|
||||
<widget class="QWidget" name="widget">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_5">
|
||||
<item>
|
||||
<widget class="DarkModeButton" name="dark_mode_button"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_6">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECStatusBox" name="bec_status_box">
|
||||
<property name="compact_view" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="label" stdset="0">
|
||||
<string>BEC Servers</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_3">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECQueue" name="bec_queue">
|
||||
<property name="compact_view" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_5">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioButton">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>SLS Light On</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_7">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QRadioButton" name="radioButton_3">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>BEAMLINE Checks</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="StopButton" name="stop_button">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECProgressBar" name="bec_progress_bar">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<widget class="QWidget" name="ControlTab">
|
||||
<attribute name="title">
|
||||
<string>Alignment Control</string>
|
||||
</attribute>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_6">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_4" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>1</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_3">
|
||||
<item>
|
||||
<widget class="ScanControl" name="scan_control">
|
||||
<property name="current_scan" stdset="0">
|
||||
<string>line_scan</string>
|
||||
</property>
|
||||
<property name="hide_arg_box" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="hide_scan_selection_combobox" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_add_remove_buttons" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="PositionerGroup" name="positioner_group"/>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="verticalSpacer">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Vertical</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>20</width>
|
||||
<height>40</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_3" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>4</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="widget_2" native="true">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Monitor</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DeviceComboBox" name="device_combobox_2"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="font">
|
||||
<font/>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>LMFit Model</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="DapComboBox" name="dap_combo_box"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Enable ROI</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="ToggleSwitch" name="toggle_switch">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>3</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Activate linear region select for LMFit</string>
|
||||
</property>
|
||||
<property name="layoutDirection">
|
||||
<enum>Qt::LayoutDirection::LeftToRight</enum>
|
||||
</property>
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_8">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Orientation::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>20</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="BECWaveformWidget" name="bec_waveform_widget">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>1</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>600</width>
|
||||
<height>450</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="clear_curves_on_plot_update" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="LMFitDialog" name="lm_fit_dialog">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>190</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="always_show_latest" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_curve_selection" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="hide_summary" stdset="0">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="tab_2">
|
||||
<attribute name="title">
|
||||
<string>Logbook</string>
|
||||
</attribute>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<property name="leftMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="WebsiteWidget" name="website_widget">
|
||||
<property name="url" stdset="0">
|
||||
<string>https://scilog.psi.ch/login</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>DapComboBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dap_combo_box</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>StopButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>stop_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>WebsiteWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>website_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECQueue</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_queue</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ScanControl</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>scan_control</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>ToggleSwitch</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>toggle_switch</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECProgressBar</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_progress_bar</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DarkModeButton</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>dark_mode_button</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>PositionerGroup</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>positioner_group</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECWaveformWidget</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_waveform_widget</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>DeviceComboBox</class>
|
||||
<extends>QComboBox</extends>
|
||||
<header>device_combobox</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>LMFitDialog</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>lm_fit_dialog</header>
|
||||
</customwidget>
|
||||
<customwidget>
|
||||
<class>BECStatusBox</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>bec_status_box</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>toggle_switch</sender>
|
||||
<signal>enabled(bool)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>toogle_roi_select(bool)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>1042</x>
|
||||
<y>212</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>322</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>bec_waveform_widget</sender>
|
||||
<signal>dap_summary_update(QVariantMap,QVariantMap)</signal>
|
||||
<receiver>lm_fit_dialog</receiver>
|
||||
<slot>update_summary_tree(QVariantMap,QVariantMap)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>1099</x>
|
||||
<y>258</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1157</x>
|
||||
<y>929</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_combobox_2</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>plot(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>577</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>427</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>device_combobox_2</sender>
|
||||
<signal>currentTextChanged(QString)</signal>
|
||||
<receiver>dap_combo_box</receiver>
|
||||
<slot>select_y_axis(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>577</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>909</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>dap_combo_box</sender>
|
||||
<signal>new_dap_config(QString,QString,QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>add_dap(QString,QString,QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>909</x>
|
||||
<y>215</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>1416</x>
|
||||
<y>447</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>positioner_group</receiver>
|
||||
<slot>set_positioners(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>230</x>
|
||||
<y>306</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>187</x>
|
||||
<y>926</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>bec_waveform_widget</receiver>
|
||||
<slot>set_x(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>187</x>
|
||||
<y>356</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>972</x>
|
||||
<y>509</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>scan_control</sender>
|
||||
<signal>device_selected(QString)</signal>
|
||||
<receiver>dap_combo_box</receiver>
|
||||
<slot>select_x_axis(QString)</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>187</x>
|
||||
<y>356</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>794</x>
|
||||
<y>202</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
Launcher for BEC GUI Applications
|
||||
|
||||
Application must be located in bec_widgets/applications ;
|
||||
in order for the launcher to find the application, it has to be put in
|
||||
a subdirectory with the same name as the main Python module:
|
||||
|
||||
/bec_widgets/applications
|
||||
├── alignment
|
||||
│ └── alignment_1d
|
||||
│ └── alignment_1d.py
|
||||
├── other_app
|
||||
└── other_app.py
|
||||
|
||||
The tree above would contain 2 applications, alignment_1d and other_app.
|
||||
|
||||
The Python module for the application must have `if __name__ == "__main__":`
|
||||
in order for the launcher to execute it (it is run with `python -m`).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
MODULE_PATH = os.path.dirname(__file__)
|
||||
|
||||
|
||||
def find_apps(base_dir: str) -> list[str]:
|
||||
matching_modules = []
|
||||
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
parent_dir = os.path.basename(root)
|
||||
|
||||
for file in files:
|
||||
if file.endswith(".py") and file != "__init__.py":
|
||||
file_name_without_ext = os.path.splitext(file)[0]
|
||||
|
||||
if file_name_without_ext == parent_dir:
|
||||
rel_path = os.path.relpath(root, base_dir)
|
||||
module_path = rel_path.replace(os.sep, ".")
|
||||
|
||||
module_name = f"{module_path}.{file_name_without_ext}"
|
||||
matching_modules.append((file_name_without_ext, module_name))
|
||||
|
||||
return matching_modules
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="BEC application launcher")
|
||||
|
||||
parser.add_argument("-m", "--module", type=str, help="The module to run (string argument).")
|
||||
|
||||
# Add a positional argument for the module, which acts as a fallback if -m is not provided
|
||||
parser.add_argument(
|
||||
"positional_module",
|
||||
nargs="?", # This makes the positional argument optional
|
||||
help="Positional argument that is treated as module if -m is not specified.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
# If the -m/--module is not provided, fallback to the positional argument
|
||||
module = args.module if args.module else args.positional_module
|
||||
|
||||
if module:
|
||||
for app_name, app_module in find_apps(MODULE_PATH):
|
||||
if module in (app_name, app_module):
|
||||
print("Starting:", app_name)
|
||||
python_executable = sys.executable
|
||||
|
||||
# Replace the current process with the new Python module
|
||||
os.execvp(
|
||||
python_executable,
|
||||
[python_executable, "-m", f"bec_widgets.applications.{app_module}"],
|
||||
)
|
||||
print(f"Error: cannot find application {module}")
|
||||
|
||||
# display list of apps
|
||||
print("Available applications:")
|
||||
for app, _ in find_apps(MODULE_PATH):
|
||||
print(f" - {app}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,9 +1,11 @@
|
||||
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)
|
||||
_dock_area = BECDockArea(object_name=object_name, root_widget=True)
|
||||
return _dock_area
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtCore import Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
@@ -21,25 +21,30 @@ from qtpy.QtWidgets import (
|
||||
|
||||
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.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.round_frame import RoundedFrame
|
||||
from bec_widgets.utils.toolbar import ModularToolBar
|
||||
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, UILaunchWindow
|
||||
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(RoundedFrame):
|
||||
DEFAULT_SIZE = (250, 300)
|
||||
open_signal = Signal()
|
||||
|
||||
def __init__(
|
||||
@@ -50,9 +55,15 @@ class LaunchTile(RoundedFrame):
|
||||
main_label: str | None = None,
|
||||
description: str | None = None,
|
||||
show_selector: bool = False,
|
||||
tile_size: tuple[int, int] | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, orientation="vertical")
|
||||
|
||||
# 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)
|
||||
@@ -83,12 +94,26 @@ class LaunchTile(RoundedFrame):
|
||||
|
||||
# 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.setWordWrap(True)
|
||||
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)
|
||||
@@ -129,6 +154,29 @@ class LaunchTile(RoundedFrame):
|
||||
)
|
||||
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
|
||||
|
||||
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
|
||||
@@ -141,12 +189,15 @@ class LaunchWindow(BECMainWindow):
|
||||
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.spacer = QWidget(self)
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
@@ -156,53 +207,124 @@ class LaunchWindow(BECMainWindow):
|
||||
self.central_widget.layout = QHBoxLayout(self.central_widget)
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.tile_dock_area = LaunchTile(
|
||||
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,
|
||||
)
|
||||
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
self.tile_auto_update = LaunchTile(
|
||||
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"],
|
||||
)
|
||||
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
self.tile_ui_file = LaunchTile(
|
||||
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,
|
||||
)
|
||||
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
|
||||
|
||||
# Add tiles to the main layout
|
||||
self.central_widget.layout.addWidget(self.tile_dock_area)
|
||||
self.central_widget.layout.addWidget(self.tile_auto_update)
|
||||
self.central_widget.layout.addWidget(self.tile_ui_file)
|
||||
# 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()),
|
||||
)
|
||||
|
||||
# hacky solution no time to waste
|
||||
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
|
||||
|
||||
# Connect signals
|
||||
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
|
||||
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
|
||||
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
|
||||
self._update_theme()
|
||||
|
||||
# Auto updates
|
||||
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
|
||||
self._update_available_auto_updates()
|
||||
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,
|
||||
):
|
||||
"""
|
||||
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,
|
||||
)
|
||||
if self.tile_auto_update.selector is not None:
|
||||
self.tile_auto_update.selector.addItems(
|
||||
list(self.available_auto_updates.keys()) + ["Default"]
|
||||
)
|
||||
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,
|
||||
@@ -210,7 +332,7 @@ class LaunchWindow(BECMainWindow):
|
||||
name: str | None = None,
|
||||
geometry: tuple[int, int, int, int] | None = None,
|
||||
**kwargs,
|
||||
) -> QWidget:
|
||||
) -> 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.
|
||||
@@ -231,6 +353,8 @@ class LaunchWindow(BECMainWindow):
|
||||
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)
|
||||
@@ -242,12 +366,20 @@ class LaunchWindow(BECMainWindow):
|
||||
|
||||
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.")
|
||||
@@ -263,17 +395,24 @@ class LaunchWindow(BECMainWindow):
|
||||
if isinstance(result_widget, BECMainWindow):
|
||||
result_widget.show()
|
||||
else:
|
||||
window = BECMainWindow()
|
||||
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 the custom UI file
|
||||
"""
|
||||
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
|
||||
@@ -281,19 +420,22 @@ class LaunchWindow(BECMainWindow):
|
||||
if widget is None:
|
||||
raise ValueError("No widget found in the UI file.")
|
||||
|
||||
if widget.attrib.get("class") == "QMainWindow":
|
||||
raise ValueError(
|
||||
"Loading a QMainWindow from a UI file is currently not supported. "
|
||||
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
|
||||
)
|
||||
# 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)
|
||||
|
||||
window = UILaunchWindow(object_name=filename)
|
||||
QApplication.processEvents()
|
||||
result_widget = UILoader(window).loader(ui_file)
|
||||
window.setCentralWidget(result_widget)
|
||||
window.setWindowTitle(f"BEC - {window.object_name}")
|
||||
window.setWindowTitle(f"BEC - {filename}")
|
||||
window.show()
|
||||
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
|
||||
logger.info(f"Launched custom UI: {filename}, type: {type(window).__name__}")
|
||||
return window
|
||||
|
||||
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
|
||||
@@ -311,11 +453,28 @@ class LaunchWindow(BECMainWindow):
|
||||
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:
|
||||
for tile in self.tiles.values():
|
||||
tile.apply_theme(theme)
|
||||
|
||||
super().apply_theme(theme)
|
||||
@@ -324,14 +483,25 @@ class LaunchWindow(BECMainWindow):
|
||||
"""
|
||||
Open the auto update window.
|
||||
"""
|
||||
if self.tile_auto_update.selector is None:
|
||||
if self.tiles["auto_update"].selector is None:
|
||||
auto_update = None
|
||||
else:
|
||||
auto_update = self.tile_auto_update.selector.currentText()
|
||||
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):
|
||||
"""
|
||||
@@ -371,6 +541,45 @@ class LaunchWindow(BECMainWindow):
|
||||
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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.7 MiB |
BIN
bec_widgets/assets/app_icons/widget_launch_tile.png
Normal file
BIN
bec_widgets/assets/app_icons/widget_launch_tile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
File diff suppressed because it is too large
Load Diff
@@ -14,24 +14,29 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils 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.rpc.rpc_base import RPCBase, RPCReference
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
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")
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
IGNORE_WIDGETS = ["LaunchWindow"]
|
||||
|
||||
RegistryState: TypeAlias = dict[
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict
|
||||
Literal["gui_id", "name", "widget_class", "config", "__rpc__", "container_proxy"],
|
||||
str | bool | dict,
|
||||
]
|
||||
|
||||
# pylint: disable=redefined-outer-scope
|
||||
@@ -49,7 +54,7 @@ def _filter_output(output: str) -> str:
|
||||
|
||||
|
||||
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)
|
||||
@@ -149,8 +154,10 @@ def wait_for_server(client: BECGuiClient):
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
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:
|
||||
@@ -214,6 +221,7 @@ class BECGuiClient(RPCBase):
|
||||
self._server_registry: dict[str, RegistryState] = {}
|
||||
self._ipython_registry: dict[str, RPCReference] = {}
|
||||
self.available_widgets = AvailableWidgetsNamespace()
|
||||
register_serializer_extension()
|
||||
|
||||
####################
|
||||
#### Client API ####
|
||||
@@ -258,23 +266,44 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
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 show(self):
|
||||
"""Show the GUI window."""
|
||||
def show(self, wait=True) -> None:
|
||||
"""
|
||||
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=True)
|
||||
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.
|
||||
|
||||
@@ -290,11 +319,11 @@ class BECGuiClient(RPCBase):
|
||||
if wait:
|
||||
with wait_for_server(self):
|
||||
widget = self.launcher._run_rpc(
|
||||
"launch", "dock_area", name, geometry
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
widget = self.launcher._run_rpc(
|
||||
"new_dock_area", name, geometry
|
||||
"launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs
|
||||
) # pylint: disable=protected-access
|
||||
return widget
|
||||
|
||||
@@ -377,6 +406,9 @@ class BECGuiClient(RPCBase):
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
"""
|
||||
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
|
||||
@@ -423,8 +455,8 @@ class BECGuiClient(RPCBase):
|
||||
self._update_dynamic_namespace(self._server_registry)
|
||||
|
||||
def _do_show_all(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("show") # pylint: disable=protected-access
|
||||
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()
|
||||
|
||||
@@ -434,11 +466,24 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def _hide_all(self):
|
||||
with wait_for_server(self):
|
||||
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
|
||||
rpc_client._run_rpc("hide") # pylint: disable=protected-access
|
||||
if not self._killed:
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
if self._killed:
|
||||
return
|
||||
self.launcher._run_rpc("hide")
|
||||
for window in self._top_level.values():
|
||||
window.hide()
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -519,7 +564,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
|
||||
gui.start(wait=True)
|
||||
gui.show(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
|
||||
@@ -40,6 +40,8 @@ class ClientGenerator:
|
||||
"""import enum
|
||||
import inspect
|
||||
import traceback
|
||||
from functools import reduce
|
||||
from operator import add
|
||||
from typing import Literal, Optional
|
||||
"""
|
||||
if self._base
|
||||
@@ -51,7 +53,7 @@ from __future__ import annotations
|
||||
{base_imports}
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
|
||||
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
|
||||
@@ -109,7 +111,7 @@ _Widgets = {
|
||||
self.content += """
|
||||
|
||||
try:
|
||||
_plugin_widgets = get_all_plugin_widgets()
|
||||
_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)
|
||||
|
||||
@@ -178,7 +180,10 @@ class {class_name}(RPCBase):"""
|
||||
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:
|
||||
@@ -203,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.
|
||||
@@ -221,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)
|
||||
@@ -315,4 +332,7 @@ def main():
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
sys.argv = ["bw-generate-cli", "--target", "bec_widgets"]
|
||||
main()
|
||||
|
||||
@@ -7,6 +7,7 @@ from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.device import DeviceBaseWithConfig
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
|
||||
@@ -24,6 +25,43 @@ else:
|
||||
# pylint: disable=protected-access
|
||||
|
||||
|
||||
def _name_arg(arg):
|
||||
if isinstance(arg, DeviceBaseWithConfig):
|
||||
# if dev.<device> is passed to GUI, it passes full_name
|
||||
if hasattr(arg, "full_name"):
|
||||
return arg.full_name
|
||||
elif hasattr(arg, "name"):
|
||||
return arg.name
|
||||
return arg
|
||||
|
||||
|
||||
def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
|
||||
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
|
||||
|
||||
|
||||
def rpc_timeout(timeout):
|
||||
"""
|
||||
A decorator to set a timeout for an RPC call.
|
||||
|
||||
Args:
|
||||
timeout: The timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The decorated function.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if "timeout" not in kwargs:
|
||||
kwargs["timeout"] = timeout
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def rpc_call(func):
|
||||
"""
|
||||
A decorator for calling a function on the server.
|
||||
@@ -47,15 +85,7 @@ def rpc_call(func):
|
||||
return None # func(*args, **kwargs)
|
||||
caller_frame = caller_frame.f_back
|
||||
|
||||
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
|
||||
args, kwargs = _transform_args_kwargs(args, kwargs)
|
||||
if not self._root._gui_is_alive():
|
||||
raise RuntimeError("GUI is not alive")
|
||||
return self._run_rpc(func.__name__, *args, **kwargs)
|
||||
@@ -130,6 +160,7 @@ class RPCBase:
|
||||
config: dict | None = None,
|
||||
object_name: str | None = None,
|
||||
parent=None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
|
||||
self._config = config if config is not None else {}
|
||||
@@ -144,21 +175,21 @@ class RPCBase:
|
||||
def __repr__(self):
|
||||
type_ = type(self)
|
||||
qualname = type_.__qualname__
|
||||
return f"<{qualname} with name: {self.widget_name}>"
|
||||
return f"<{qualname} with name: {self.object_name}>"
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Remove the widget.
|
||||
"""
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
if proxy := obj.get("container_proxy"):
|
||||
assert isinstance(proxy, str)
|
||||
self._run_rpc("remove", gui_id=proxy)
|
||||
return
|
||||
self._run_rpc("remove")
|
||||
|
||||
@property
|
||||
def widget_name(self):
|
||||
"""
|
||||
Get the widget name.
|
||||
"""
|
||||
return self.object_name
|
||||
|
||||
@property
|
||||
def _root(self) -> BECGuiClient:
|
||||
"""
|
||||
@@ -171,7 +202,20 @@ class RPCBase:
|
||||
parent = parent._parent
|
||||
return parent # type: ignore
|
||||
|
||||
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=5, **kwargs) -> Any:
|
||||
def raise_window(self):
|
||||
"""Bring this widget (or its container) to the front."""
|
||||
# Use explicit call to ensure action name is 'raise' (not 'raise_')
|
||||
return self._run_rpc("raise")
|
||||
|
||||
def _run_rpc(
|
||||
self,
|
||||
method,
|
||||
*args,
|
||||
wait_for_rpc_response=True,
|
||||
timeout=5,
|
||||
gui_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""
|
||||
Run the RPC call.
|
||||
|
||||
@@ -179,15 +223,23 @@ class RPCBase:
|
||||
method: The method to call.
|
||||
args: The arguments to pass to the method.
|
||||
wait_for_rpc_response: Whether to wait for the RPC response.
|
||||
timeout: The timeout for the RPC response.
|
||||
gui_id: The GUI ID to use for the RPC call. If None, the default GUI ID is used.
|
||||
kwargs: The keyword arguments to pass to the method.
|
||||
|
||||
Returns:
|
||||
The result of the RPC call.
|
||||
"""
|
||||
if method in ["show", "hide", "raise"] and gui_id is None:
|
||||
obj = self._root._server_registry.get(self._gui_id)
|
||||
if obj is None:
|
||||
raise ValueError(f"Widget {self._gui_id} not found.")
|
||||
gui_id = obj.get("container_proxy") # type: ignore
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
rpc_msg = messages.GUIInstructionMessage(
|
||||
action=method,
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
|
||||
parameter={"args": args, "kwargs": kwargs, "gui_id": gui_id or self._gui_id},
|
||||
metadata={"request_id": request_id},
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
|
||||
@@ -31,10 +31,9 @@ class RPCWidgetHandler:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
clss = get_custom_classes("bec_widgets")
|
||||
self._widget_classes = get_all_plugin_widgets() | {
|
||||
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
|
||||
}
|
||||
self._widget_classes = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict(IGNORE_WIDGETS)
|
||||
|
||||
def create_widget(self, widget_type, **kwargs) -> BECWidget:
|
||||
"""
|
||||
|
||||
@@ -6,10 +6,10 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import cast
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtmonaco.pylsp_provider import pylsp_server
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QApplication
|
||||
@@ -38,6 +38,10 @@ class SimpleFileLikeFromLogOutputFunc:
|
||||
self._log_func(lines)
|
||||
self._buffer = [remaining]
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return "utf-8"
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
@@ -83,29 +87,6 @@ class GUIServer:
|
||||
service_config = ServiceConfig()
|
||||
return service_config
|
||||
|
||||
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.
|
||||
"""
|
||||
self.launcher_window = cast(LaunchWindow, self.launcher_window)
|
||||
|
||||
remaining_connections = [
|
||||
connection
|
||||
for connection in connections.values()
|
||||
if connection.parent_id != self.launcher_window.gui_id
|
||||
]
|
||||
if len(remaining_connections) <= 1:
|
||||
self.launcher_window.show()
|
||||
self.launcher_window.activateWindow()
|
||||
self.launcher_window.raise_()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(True)
|
||||
else:
|
||||
self.launcher_window.hide()
|
||||
if self.app:
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
def _run(self):
|
||||
"""
|
||||
Run the GUI server.
|
||||
@@ -125,10 +106,6 @@ class GUIServer:
|
||||
self.app.aboutToQuit.connect(self.shutdown)
|
||||
self.app.setQuitOnLastWindowClosed(False)
|
||||
|
||||
register = RPCRegister()
|
||||
register.callbacks.append(self._turn_off_the_lights)
|
||||
register.broadcast()
|
||||
|
||||
if self.gui_class:
|
||||
# If the server is started with a specific gui class, we launch it.
|
||||
# This will automatically hide the launcher.
|
||||
@@ -166,6 +143,8 @@ class GUIServer:
|
||||
"""
|
||||
Shutdown the GUI server.
|
||||
"""
|
||||
if pylsp_server.is_running():
|
||||
pylsp_server.stop()
|
||||
if self.dispatcher:
|
||||
self.dispatcher.stop_cli_server()
|
||||
self.dispatcher.disconnect_all()
|
||||
|
||||
@@ -44,21 +44,21 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
"im": self.im,
|
||||
"mi": self.mi,
|
||||
"mm": self.mm,
|
||||
"lm": self.lm,
|
||||
"btn1": self.btn1,
|
||||
"btn2": self.btn2,
|
||||
"btn3": self.btn3,
|
||||
"btn4": self.btn4,
|
||||
"btn5": self.btn5,
|
||||
"btn6": self.btn6,
|
||||
"pb": self.pb,
|
||||
"pi": self.pi,
|
||||
# "mi": self.mi,
|
||||
# "mm": self.mm,
|
||||
# "lm": self.lm,
|
||||
# "btn1": self.btn1,
|
||||
# "btn2": self.btn2,
|
||||
# "btn3": self.btn3,
|
||||
# "btn4": self.btn4,
|
||||
# "btn5": self.btn5,
|
||||
# "btn6": self.btn6,
|
||||
# "pb": self.pb,
|
||||
# "pi": self.pi,
|
||||
"wf": self.wf,
|
||||
"scatter": self.scatter,
|
||||
"scatter_mi": self.scatter,
|
||||
"mwf": self.mwf,
|
||||
# "scatter": self.scatter,
|
||||
# "scatter_mi": self.scatter,
|
||||
# "mwf": self.mwf,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -77,76 +77,75 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
first_tab_layout.addWidget(self.dock)
|
||||
tab_widget.addTab(first_tab, "Dock Area")
|
||||
|
||||
third_tab = QWidget()
|
||||
third_tab_layout = QVBoxLayout(third_tab)
|
||||
self.lm = LayoutManagerWidget()
|
||||
third_tab_layout.addWidget(self.lm)
|
||||
tab_widget.addTab(third_tab, "Layout Manager Widget")
|
||||
|
||||
fourth_tab = QWidget()
|
||||
fourth_tab_layout = QVBoxLayout(fourth_tab)
|
||||
self.pb = PlotBase()
|
||||
self.pi = self.pb.plot_item
|
||||
fourth_tab_layout.addWidget(self.pb)
|
||||
tab_widget.addTab(fourth_tab, "PlotBase")
|
||||
|
||||
tab_widget.setCurrentIndex(3)
|
||||
|
||||
# third_tab = QWidget()
|
||||
# third_tab_layout = QVBoxLayout(third_tab)
|
||||
# self.lm = LayoutManagerWidget()
|
||||
# third_tab_layout.addWidget(self.lm)
|
||||
# tab_widget.addTab(third_tab, "Layout Manager Widget")
|
||||
#
|
||||
# fourth_tab = QWidget()
|
||||
# fourth_tab_layout = QVBoxLayout(fourth_tab)
|
||||
# self.pb = PlotBase()
|
||||
# self.pi = self.pb.plot_item
|
||||
# fourth_tab_layout.addWidget(self.pb)
|
||||
# tab_widget.addTab(fourth_tab, "PlotBase")
|
||||
#
|
||||
# tab_widget.setCurrentIndex(3)
|
||||
#
|
||||
group_box = QGroupBox("Jupyter Console", splitter)
|
||||
group_box_layout = QVBoxLayout(group_box)
|
||||
self.console = BECJupyterConsole(inprocess=True)
|
||||
group_box_layout.addWidget(self.console)
|
||||
|
||||
# Some buttons for layout testing
|
||||
self.btn1 = QPushButton("Button 1")
|
||||
self.btn2 = QPushButton("Button 2")
|
||||
self.btn3 = QPushButton("Button 3")
|
||||
self.btn4 = QPushButton("Button 4")
|
||||
self.btn5 = QPushButton("Button 5")
|
||||
self.btn6 = QPushButton("Button 6")
|
||||
|
||||
#
|
||||
# # Some buttons for layout testing
|
||||
# self.btn1 = QPushButton("Button 1")
|
||||
# self.btn2 = QPushButton("Button 2")
|
||||
# self.btn3 = QPushButton("Button 3")
|
||||
# self.btn4 = QPushButton("Button 4")
|
||||
# self.btn5 = QPushButton("Button 5")
|
||||
# self.btn6 = QPushButton("Button 6")
|
||||
#
|
||||
fifth_tab = QWidget()
|
||||
fifth_tab_layout = QVBoxLayout(fifth_tab)
|
||||
self.wf = Waveform()
|
||||
fifth_tab_layout.addWidget(self.wf)
|
||||
tab_widget.addTab(fifth_tab, "Waveform Next Gen")
|
||||
tab_widget.setCurrentIndex(4)
|
||||
|
||||
#
|
||||
sixth_tab = QWidget()
|
||||
sixth_tab_layout = QVBoxLayout(sixth_tab)
|
||||
self.im = Image()
|
||||
self.im = Image(popups=True)
|
||||
self.mi = self.im.main_image
|
||||
sixth_tab_layout.addWidget(self.im)
|
||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
tab_widget.setCurrentIndex(5)
|
||||
|
||||
seventh_tab = QWidget()
|
||||
seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
self.scatter = ScatterWaveform()
|
||||
self.scatter_mi = self.scatter.main_curve
|
||||
self.scatter.plot("samx", "samy", "bpm4i")
|
||||
seventh_tab_layout.addWidget(self.scatter)
|
||||
tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
tab_widget.setCurrentIndex(6)
|
||||
|
||||
eighth_tab = QWidget()
|
||||
eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||
self.mm = MotorMap()
|
||||
eighth_tab_layout.addWidget(self.mm)
|
||||
tab_widget.addTab(eighth_tab, "Motor Map")
|
||||
tab_widget.setCurrentIndex(7)
|
||||
|
||||
ninth_tab = QWidget()
|
||||
ninth_tab_layout = QVBoxLayout(ninth_tab)
|
||||
self.mwf = MultiWaveform()
|
||||
ninth_tab_layout.addWidget(self.mwf)
|
||||
tab_widget.addTab(ninth_tab, "MultiWaveform")
|
||||
tab_widget.setCurrentIndex(8)
|
||||
|
||||
# add stuff to the new Waveform widget
|
||||
self._init_waveform()
|
||||
|
||||
self.setWindowTitle("Jupyter Console Window")
|
||||
tab_widget.setCurrentIndex(1)
|
||||
#
|
||||
# seventh_tab = QWidget()
|
||||
# seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
# self.scatter = ScatterWaveform()
|
||||
# self.scatter_mi = self.scatter.main_curve
|
||||
# self.scatter.plot("samx", "samy", "bpm4i")
|
||||
# seventh_tab_layout.addWidget(self.scatter)
|
||||
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
# tab_widget.setCurrentIndex(6)
|
||||
#
|
||||
# eighth_tab = QWidget()
|
||||
# eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||
# self.mm = MotorMap()
|
||||
# eighth_tab_layout.addWidget(self.mm)
|
||||
# tab_widget.addTab(eighth_tab, "Motor Map")
|
||||
# tab_widget.setCurrentIndex(7)
|
||||
#
|
||||
# ninth_tab = QWidget()
|
||||
# ninth_tab_layout = QVBoxLayout(ninth_tab)
|
||||
# self.mwf = MultiWaveform()
|
||||
# ninth_tab_layout.addWidget(self.mwf)
|
||||
# tab_widget.addTab(ninth_tab, "MultiWaveform")
|
||||
# tab_widget.setCurrentIndex(8)
|
||||
#
|
||||
# # add stuff to the new Waveform widget
|
||||
# self._init_waveform()
|
||||
#
|
||||
# self.setWindowTitle("Jupyter Console Window")
|
||||
|
||||
def _init_waveform(self):
|
||||
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
@@ -174,7 +173,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||
app.setWindowIcon(icon)
|
||||
|
||||
bec_dispatcher = BECDispatcher()
|
||||
bec_dispatcher = BECDispatcher(gui_id="jupyter_console")
|
||||
client = bec_dispatcher.client
|
||||
client.start()
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class FakeDevice(BECDevice):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd.Device",
|
||||
"deviceConfig": {},
|
||||
"deviceTags": ["user device"],
|
||||
"deviceTags": {"user device"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
@@ -89,16 +89,28 @@ class FakePositioner(BECPositioner):
|
||||
"readoutPriority": "baseline",
|
||||
"deviceClass": "ophyd_devices.SimPositioner",
|
||||
"deviceConfig": {"delay": 1, "tolerance": 0.01, "update_frequency": 400},
|
||||
"deviceTags": ["user motors"],
|
||||
"deviceTags": {"user motors"},
|
||||
"enabled": enabled,
|
||||
"readOnly": False,
|
||||
"name": self.name,
|
||||
}
|
||||
self._info = {
|
||||
"signals": {
|
||||
"readback": {"kind_str": "5"}, # hinted
|
||||
"setpoint": {"kind_str": "1"}, # normal
|
||||
"velocity": {"kind_str": "2"}, # config
|
||||
"readback": {
|
||||
"kind_str": "hinted",
|
||||
"component_name": "readback",
|
||||
"obj_name": self.name,
|
||||
}, # hinted
|
||||
"setpoint": {
|
||||
"kind_str": "normal",
|
||||
"component_name": "setpoint",
|
||||
"obj_name": f"{self.name}_setpoint",
|
||||
}, # normal
|
||||
"velocity": {
|
||||
"kind_str": "config",
|
||||
"component_name": "velocity",
|
||||
"obj_name": f"{self.name}_velocity",
|
||||
}, # config
|
||||
}
|
||||
}
|
||||
self.signals = {
|
||||
@@ -161,7 +173,7 @@ class FakePositioner(BECPositioner):
|
||||
def set_read_value(self, value):
|
||||
self.read_value = value
|
||||
|
||||
def read(self):
|
||||
def read(self, cached=False):
|
||||
return self.signals
|
||||
|
||||
def set_limits(self, limits):
|
||||
@@ -184,8 +196,8 @@ class FakePositioner(BECPositioner):
|
||||
class Positioner(FakePositioner):
|
||||
"""just placeholder for testing embedded isinstance check in DeviceCombobox"""
|
||||
|
||||
def __init__(self, name="test", limits=None, read_value=1.0):
|
||||
super().__init__(name, limits, read_value)
|
||||
def __init__(self, name="test", limits=None, read_value=1.0, enabled=True):
|
||||
super().__init__(name, limits=limits, read_value=read_value, enabled=enabled)
|
||||
|
||||
|
||||
class Device(FakeDevice):
|
||||
@@ -200,10 +212,49 @@ class DMMock:
|
||||
self.devices = DeviceContainer()
|
||||
self.enabled_devices = [device for device in self.devices if device.enabled]
|
||||
|
||||
def add_devives(self, devices: list):
|
||||
def add_devices(self, devices: list):
|
||||
"""
|
||||
Add devices to the DeviceContainer.
|
||||
|
||||
Args:
|
||||
devices (list): List of device instances to add.
|
||||
"""
|
||||
for device in devices:
|
||||
self.devices[device.name] = device
|
||||
|
||||
def get_bec_signals(self, signal_class_name: str):
|
||||
"""
|
||||
Emulate DeviceManager.get_bec_signals for unit-tests.
|
||||
|
||||
For “AsyncSignal” we list every device whose readout_priority is
|
||||
ReadoutPriority.ASYNC and build a minimal tuple
|
||||
(device_name, signal_name, signal_info_dict) that matches the real
|
||||
API shape used by Waveform._check_async_signal_found.
|
||||
"""
|
||||
signals: list[tuple[str, str, dict]] = []
|
||||
if signal_class_name != "AsyncSignal":
|
||||
return signals
|
||||
|
||||
for device in self.devices.values():
|
||||
if getattr(device, "readout_priority", None) == ReadoutPriority.ASYNC:
|
||||
device_name = device.name
|
||||
signal_name = device.name # primary signal in our mocks
|
||||
signal_info = {
|
||||
"component_name": signal_name,
|
||||
"obj_name": signal_name,
|
||||
"kind_str": "hinted",
|
||||
"signal_class": signal_class_name,
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"precision": None,
|
||||
"read_access": True,
|
||||
"timestamp": 0.0,
|
||||
"write_access": True,
|
||||
},
|
||||
}
|
||||
signals.append((device_name, signal_name, signal_info))
|
||||
return signals
|
||||
|
||||
|
||||
DEVICES = [
|
||||
FakePositioner("samx", limits=[-10, 10], read_value=2.0),
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
@@ -14,8 +15,7 @@ from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
|
||||
|
||||
@@ -85,9 +85,21 @@ class BECConnector:
|
||||
gui_id: str | None = None,
|
||||
object_name: str | None = None,
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
parent_id: str | None = None,
|
||||
root_widget: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
BECConnector mixin class to handle BEC client and device manager.
|
||||
|
||||
Args:
|
||||
client(BECClient, optional): The BEC client.
|
||||
config(ConnectionConfig, optional): The connection configuration with specific gui id.
|
||||
gui_id(str, optional): The GUI ID.
|
||||
object_name(str, optional): The object name.
|
||||
parent_dock(BECDock, optional): The parent dock.# TODO should go away -> issue created #473
|
||||
root_widget(bool, optional): If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
**kwargs:
|
||||
"""
|
||||
# Extract object_name from kwargs to not pass it to Qt class
|
||||
object_name = object_name or kwargs.pop("objectName", None)
|
||||
# Ensure the parent is always the first argument for QObject
|
||||
@@ -99,6 +111,9 @@ class BECConnector:
|
||||
self, QObject
|
||||
), "BECConnector must be used with a QObject or any qt related class."
|
||||
|
||||
# flag to check if the object was destroyed and its cleanup was called
|
||||
self._destroyed = False
|
||||
|
||||
# BEC related connections
|
||||
self.bec_dispatcher = BECDispatcher(client=client)
|
||||
self.client = self.bec_dispatcher.client if client is None else client
|
||||
@@ -108,7 +123,7 @@ class BECConnector:
|
||||
if not self.client in BECConnector.EXIT_HANDLERS:
|
||||
# register function to clean connections at exit;
|
||||
# the function depends on BECClient, and BECDispatcher
|
||||
@pyqtSlot()
|
||||
@SafeSlot()
|
||||
def terminate(client=self.client, dispatcher=self.bec_dispatcher):
|
||||
logger.info("Disconnecting", repr(dispatcher))
|
||||
dispatcher.disconnect_all()
|
||||
@@ -128,7 +143,6 @@ class BECConnector:
|
||||
)
|
||||
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
|
||||
|
||||
self.parent_id = parent_id
|
||||
# If the gui_id is passed, it should be respected. However, this should be revisted since
|
||||
# the gui_id has to be unique, and may no longer be.
|
||||
if gui_id:
|
||||
@@ -147,10 +161,6 @@ class BECConnector:
|
||||
|
||||
# 2) Enforce unique objectName among siblings with the same BECConnector parent
|
||||
self.setParent(parent)
|
||||
if parent_id is None:
|
||||
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
if connector_parent is not None:
|
||||
self.parent_id = connector_parent.gui_id
|
||||
|
||||
# Error popups
|
||||
self.error_utility = ErrorPopupUtility()
|
||||
@@ -159,6 +169,30 @@ class BECConnector:
|
||||
# Store references to running workers so they're not garbage collected prematurely.
|
||||
self._workers = []
|
||||
|
||||
# If set to True, the parent_id will be always set to None, thus enforcing that the widget is accessible as a root widget of the BECGuiClient object.
|
||||
self.root_widget = root_widget
|
||||
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
|
||||
@property
|
||||
def parent_id(self) -> str | None:
|
||||
try:
|
||||
if self.root_widget:
|
||||
return None
|
||||
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
return connector_parent.gui_id if connector_parent else None
|
||||
except:
|
||||
logger.error(f"Error getting parent_id for {self.__class__.__name__}")
|
||||
|
||||
def change_object_name(self, name: str) -> None:
|
||||
"""
|
||||
Change the object name of the widget. Unregister old name and register the new one.
|
||||
|
||||
Args:
|
||||
name (str): The new object name.
|
||||
"""
|
||||
self.rpc_register.remove_rpc(self)
|
||||
self.setObjectName(name.replace("-", "_").replace(" ", "_"))
|
||||
QTimer.singleShot(0, self._update_object_name)
|
||||
|
||||
def _update_object_name(self) -> None:
|
||||
@@ -179,7 +213,7 @@ class BECConnector:
|
||||
- If there's a nearest BECConnector parent, only compare with children of that parent.
|
||||
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
|
||||
"""
|
||||
QApplication.processEvents()
|
||||
QApplication.sendPostedEvents()
|
||||
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
|
||||
|
||||
if parent_bec:
|
||||
@@ -228,7 +262,7 @@ class BECConnector:
|
||||
if self.rpc_register.object_is_registered(self):
|
||||
self.rpc_register.broadcast()
|
||||
|
||||
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
|
||||
def submit_task(self, fn, *args, on_complete: SafeSlot = None, **kwargs) -> Worker:
|
||||
"""
|
||||
Submit a task to run in a separate thread. The task will run the specified
|
||||
function with the provided arguments and emit the completed signal when done.
|
||||
@@ -356,7 +390,7 @@ class BECConnector:
|
||||
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
|
||||
save_yaml(file_path, self._config_dict)
|
||||
|
||||
# @pyqtSlot(str)
|
||||
# @SafeSlot(str)
|
||||
def _set_gui_id(self, gui_id: str) -> None:
|
||||
"""
|
||||
Set the GUI ID for the widget.
|
||||
@@ -388,7 +422,7 @@ class BECConnector:
|
||||
self.client = client
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
@pyqtSlot(ConnectionConfig) # TODO can be also dict
|
||||
@SafeSlot(ConnectionConfig) # TODO can be also dict
|
||||
def on_config_update(self, config: ConnectionConfig | dict) -> None:
|
||||
"""
|
||||
Update the configuration for the widget.
|
||||
|
||||
@@ -16,9 +16,9 @@ if PYSIDE6:
|
||||
from PySide6.scripts.pyside_tool import (
|
||||
_extend_path_var,
|
||||
init_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
is_pyenv_python,
|
||||
is_virtual_env,
|
||||
qt_tool_wrapper,
|
||||
ui_tool_binary,
|
||||
)
|
||||
|
||||
@@ -78,7 +78,7 @@ def list_editable_packages() -> set[str]:
|
||||
return editable_packages
|
||||
|
||||
|
||||
def patch_designer(): # pragma: no cover
|
||||
def patch_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
@@ -119,7 +119,7 @@ def patch_designer(): # pragma: no cover
|
||||
editable_packages = list_editable_packages()
|
||||
for pckg in editable_packages:
|
||||
_extend_path_var("PYTHONPATH", pckg, True)
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), sys.argv[1:])
|
||||
qt_tool_wrapper(ui_tool_binary("designer"), cmd_args)
|
||||
|
||||
|
||||
def find_plugin_paths(base_path: Path):
|
||||
@@ -147,7 +147,7 @@ def set_plugin_environment_variable(plugin_paths):
|
||||
|
||||
|
||||
# Patch the designer function
|
||||
def main(): # pragma: no cover
|
||||
def open_designer(cmd_args: list[str] = []): # pragma: no cover
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Exiting...")
|
||||
return
|
||||
@@ -160,7 +160,11 @@ def main(): # pragma: no cover
|
||||
|
||||
set_plugin_environment_variable(plugin_paths)
|
||||
|
||||
patch_designer()
|
||||
patch_designer(cmd_args)
|
||||
|
||||
|
||||
def main():
|
||||
open_designer(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -4,8 +4,9 @@ import collections
|
||||
import random
|
||||
import string
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Union
|
||||
from typing import TYPE_CHECKING, DefaultDict, Hashable, Union
|
||||
|
||||
import louie
|
||||
import redis
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.logger import bec_logger
|
||||
@@ -14,30 +15,52 @@ from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtCore import Signal as pyqtSignal
|
||||
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.endpoints import EndpointInfo
|
||||
|
||||
from bec_widgets.utils.rpc_server import RPCServer
|
||||
|
||||
|
||||
class QtThreadSafeCallback(QObject):
|
||||
"""QtThreadSafeCallback is a wrapper around a callback function to make it thread-safe for Qt."""
|
||||
|
||||
cb_signal = pyqtSignal(dict, dict)
|
||||
|
||||
def __init__(self, cb):
|
||||
def __init__(self, cb: Callable, cb_info: dict | None = None):
|
||||
"""
|
||||
Initialize the QtThreadSafeCallback.
|
||||
|
||||
Args:
|
||||
cb (Callable): The callback function to be wrapped.
|
||||
cb_info (dict, optional): Additional information about the callback. Defaults to None.
|
||||
"""
|
||||
super().__init__()
|
||||
self.cb_info = cb_info
|
||||
|
||||
self.cb = cb
|
||||
self.cb_ref = louie.saferef.safe_ref(cb)
|
||||
self.cb_signal.connect(self.cb)
|
||||
self.topics = set()
|
||||
|
||||
def __hash__(self):
|
||||
# make 2 differents QtThreadSafeCallback to look
|
||||
# identical when used as dictionary keys, if the
|
||||
# callback is the same
|
||||
return id(self.cb)
|
||||
return f"{id(self.cb_ref)}{self.cb_info}".__hash__()
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, QtThreadSafeCallback):
|
||||
return False
|
||||
return self.cb_ref == other.cb_ref and self.cb_info == other.cb_info
|
||||
|
||||
def __call__(self, msg_content, metadata):
|
||||
if self.cb_ref() is None:
|
||||
# callback has been deleted
|
||||
return
|
||||
self.cb_signal.emit(msg_content, metadata)
|
||||
|
||||
|
||||
@@ -84,7 +107,7 @@ class BECDispatcher:
|
||||
cls,
|
||||
client=None,
|
||||
config: str | ServiceConfig | None = None,
|
||||
gui_id: str = None,
|
||||
gui_id: str | None = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -97,7 +120,9 @@ class BECDispatcher:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._slots = collections.defaultdict(set)
|
||||
self._registered_slots: DefaultDict[Hashable, QtThreadSafeCallback] = (
|
||||
collections.defaultdict()
|
||||
)
|
||||
self.client = client
|
||||
|
||||
if self.client is None:
|
||||
@@ -120,6 +145,8 @@ class BECDispatcher:
|
||||
except redis.exceptions.ConnectionError:
|
||||
logger.warning("Could not connect to Redis, skipping start of BECClient.")
|
||||
|
||||
register_serializer_extension()
|
||||
|
||||
logger.success("Initialized BECDispatcher")
|
||||
|
||||
self.start_cli_server(gui_id=gui_id)
|
||||
@@ -136,7 +163,8 @@ class BECDispatcher:
|
||||
def connect_slot(
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
|
||||
cb_info: dict | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Connect widget's qt slot, so that it is called on new pub/sub topic message.
|
||||
@@ -144,34 +172,40 @@ class BECDispatcher:
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||
"""
|
||||
slot = QtThreadSafeCallback(slot)
|
||||
self.client.connector.register(topics, cb=slot, **kwargs)
|
||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||
if qt_slot not in self._registered_slots:
|
||||
self._registered_slots[qt_slot] = qt_slot
|
||||
qt_slot = self._registered_slots[qt_slot]
|
||||
self.client.connector.register(topics, cb=qt_slot, **kwargs)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[slot].update(set(topics_str))
|
||||
qt_slot.topics.update(set(topics_str))
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
def disconnect_slot(
|
||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||
):
|
||||
"""
|
||||
Disconnect a slot from a topic.
|
||||
|
||||
Args:
|
||||
slot(Callable): The slot to disconnect
|
||||
topics(Union[str, list]): The topic(s) to disconnect from
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub from.
|
||||
"""
|
||||
# find the right slot to disconnect from ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
# but the slot we receive here is the original callable
|
||||
for connected_slot in self._slots:
|
||||
for connected_slot in self._registered_slots.values():
|
||||
if connected_slot.cb == slot:
|
||||
break
|
||||
else:
|
||||
return
|
||||
self.client.connector.unregister(topics, cb=connected_slot)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
self._slots[connected_slot].difference_update(set(topics_str))
|
||||
if not self._slots[connected_slot]:
|
||||
del self._slots[connected_slot]
|
||||
self._registered_slots[connected_slot].topics.difference_update(set(topics_str))
|
||||
if not self._registered_slots[connected_slot].topics:
|
||||
del self._registered_slots[connected_slot]
|
||||
|
||||
def disconnect_topics(self, topics: Union[str, list]):
|
||||
"""
|
||||
@@ -182,11 +216,16 @@ class BECDispatcher:
|
||||
"""
|
||||
self.client.connector.unregister(topics)
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
for slot in list(self._slots.keys()):
|
||||
slot_topics = self._slots[slot]
|
||||
slot_topics.difference_update(set(topics_str))
|
||||
if not slot_topics:
|
||||
del self._slots[slot]
|
||||
|
||||
remove_slots = []
|
||||
for connected_slot in self._registered_slots.values():
|
||||
connected_slot.topics.difference_update(set(topics_str))
|
||||
|
||||
if not connected_slot.topics:
|
||||
remove_slots.append(connected_slot)
|
||||
|
||||
for connected_slot in remove_slots:
|
||||
self._registered_slots.pop(connected_slot, None)
|
||||
|
||||
def disconnect_all(self, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -3,12 +3,17 @@ from __future__ import annotations
|
||||
import importlib.metadata
|
||||
import inspect
|
||||
import pkgutil
|
||||
import traceback
|
||||
from importlib import util as importlib_util
|
||||
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
|
||||
from types import ModuleType
|
||||
from typing import Generator
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_lib.logger import bec_logger
|
||||
|
||||
from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
|
||||
@@ -21,7 +26,7 @@ def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
|
||||
|
||||
|
||||
def _loaded_submodules_from_specs(
|
||||
submodule_specs: tuple[ModuleSpec | None, ...]
|
||||
submodule_specs: tuple[ModuleSpec | None, ...],
|
||||
) -> Generator[ModuleType, None, None]:
|
||||
"""Load all submodules from the given specs."""
|
||||
for submodule in (
|
||||
@@ -30,7 +35,14 @@ def _loaded_submodules_from_specs(
|
||||
assert isinstance(
|
||||
submodule.__loader__, SourceFileLoader
|
||||
), "Module found from FileFinder should have SourceFileLoader!"
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
try:
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
except Exception as e:
|
||||
exception_text = "".join(traceback.format_exception(e))
|
||||
if "(most likely due to a circular import)" in exception_text:
|
||||
logger.warning(f"Circular import encountered while loading {submodule}")
|
||||
else:
|
||||
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
|
||||
yield submodule
|
||||
|
||||
|
||||
@@ -41,27 +53,30 @@ def _submodule_by_name(module: ModuleType, name: str):
|
||||
return None
|
||||
|
||||
|
||||
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
|
||||
"""Find any BECWidget subclasses in the given module and return them with their names."""
|
||||
def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
|
||||
"""Find any BECWidget subclasses in the given module and return them with their info."""
|
||||
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
|
||||
|
||||
return dict(
|
||||
inspect.getmembers(
|
||||
module,
|
||||
predicate=lambda item: inspect.isclass(item)
|
||||
and issubclass(item, BECWidget)
|
||||
and item is not BECWidget,
|
||||
)
|
||||
classes = inspect.getmembers(
|
||||
module,
|
||||
predicate=lambda item: inspect.isclass(item)
|
||||
and issubclass(item, BECWidget)
|
||||
and item is not BECWidget
|
||||
and not item.__module__.startswith("bec_widgets"),
|
||||
)
|
||||
return BECClassContainer(
|
||||
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
|
||||
for k, v in classes
|
||||
)
|
||||
|
||||
|
||||
def _all_widgets_from_all_submods(module):
|
||||
def _all_widgets_from_all_submods(module) -> BECClassContainer:
|
||||
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
|
||||
widgets = _get_widgets_from_module(module)
|
||||
if not hasattr(module, "__path__"):
|
||||
return widgets
|
||||
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
|
||||
widgets.update(_all_widgets_from_all_submods(submod))
|
||||
widgets += _all_widgets_from_all_submods(submod)
|
||||
return widgets
|
||||
|
||||
|
||||
@@ -75,15 +90,16 @@ def get_plugin_client_module() -> ModuleType | None:
|
||||
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
|
||||
|
||||
|
||||
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
|
||||
def get_all_plugin_widgets() -> BECClassContainer:
|
||||
"""If there is a plugin repository installed, load all widgets from it."""
|
||||
if plugin := user_widget_plugin():
|
||||
return _all_widgets_from_all_submods(plugin)
|
||||
else:
|
||||
return {}
|
||||
return BECClassContainer()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
# print(get_all_plugin_widgets())
|
||||
|
||||
client = get_plugin_client_module()
|
||||
print(get_all_plugin_widgets())
|
||||
...
|
||||
|
||||
86
bec_widgets/utils/bec_plugin_manager/create/widget.py
Normal file
86
bec_widgets/utils/bec_plugin_manager/create/widget.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import copier
|
||||
import typer
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_repo_path
|
||||
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
|
||||
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
|
||||
|
||||
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
|
||||
|
||||
logger = bec_logger.logger
|
||||
_app = typer.Typer(rich_markup_mode="rich")
|
||||
|
||||
|
||||
def _commit_added_widget(repo: Path, name: str):
|
||||
git_stage_files(repo, [".copier-answers.yml"])
|
||||
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
|
||||
make_commit(repo, f"plugin-manager added new widget: {name}")
|
||||
logger.info(f"Committing new widget {name}")
|
||||
|
||||
|
||||
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
|
||||
return name in [w["name"] for w in widget_list]
|
||||
|
||||
|
||||
def _editor_cb(ctx: typer.Context, value: bool):
|
||||
if value and not ctx.params["use_ui"]:
|
||||
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
|
||||
return value
|
||||
|
||||
|
||||
_bold_blue = "\033[34m\033[1m"
|
||||
_off = "\033[0m"
|
||||
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
|
||||
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
|
||||
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
|
||||
Open the created widget .ui file in bec-designer now?"""
|
||||
|
||||
|
||||
@_app.command()
|
||||
def widget(
|
||||
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
|
||||
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
|
||||
open_editor: Annotated[
|
||||
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
|
||||
] = True,
|
||||
):
|
||||
"""Create a new widget plugin with the given name.
|
||||
|
||||
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
|
||||
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
|
||||
bec-designer and the compiled python version will be updated when changes are made and saved."""
|
||||
if (formatted_name := name.lower().replace("-", "_")) != name:
|
||||
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
|
||||
if not formatted_name.isidentifier():
|
||||
logger.error(
|
||||
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
|
||||
)
|
||||
exit(-1)
|
||||
logger.info(f"Adding new widget {formatted_name} to the template...")
|
||||
try:
|
||||
repo = Path(plugin_repo_path())
|
||||
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
|
||||
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
|
||||
logger.error(f"Widget {formatted_name} already exists!")
|
||||
exit(-1)
|
||||
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
|
||||
copier.run_update(
|
||||
repo,
|
||||
data=plugin_data,
|
||||
defaults=True,
|
||||
unsafe=True,
|
||||
overwrite=True,
|
||||
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
|
||||
)
|
||||
_commit_added_widget(repo, formatted_name)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error("exiting...")
|
||||
exit(-1)
|
||||
logger.success(f"Added widget {formatted_name}!")
|
||||
if open_editor:
|
||||
open_and_watch_ui_editor(formatted_name)
|
||||
136
bec_widgets/utils/bec_plugin_manager/edit_ui.py
Normal file
136
bec_widgets/utils/bec_plugin_manager/edit_ui.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from watchdog.events import (
|
||||
DirCreatedEvent,
|
||||
DirModifiedEvent,
|
||||
DirMovedEvent,
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
FileSystemEvent,
|
||||
FileSystemEventHandler,
|
||||
)
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from bec_widgets.utils.bec_designer import open_designer
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class RecompileHandler(FileSystemEventHandler):
|
||||
def __init__(self, in_file: Path, out_file: Path) -> None:
|
||||
super().__init__()
|
||||
self.in_file = str(in_file)
|
||||
self.out_file = str(out_file)
|
||||
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
|
||||
self._widget_import_re = re.compile(
|
||||
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
|
||||
)
|
||||
self._widget_modules = {
|
||||
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
|
||||
}
|
||||
|
||||
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def recompile(self, event: FileSystemEvent) -> None:
|
||||
if event.src_path == self.in_file or event.dest_path == self.in_file:
|
||||
self._recompile()
|
||||
|
||||
def _recompile(self):
|
||||
logger.success(".ui file modified, recompiling...")
|
||||
code = subprocess.call(
|
||||
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
|
||||
)
|
||||
logger.success(f"compilation exited with code {code}")
|
||||
if code != 0:
|
||||
return
|
||||
self._add_comment_to_file()
|
||||
logger.success("updating imports...")
|
||||
self._update_imports()
|
||||
logger.success("formatting...")
|
||||
code = subprocess.call(
|
||||
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
|
||||
)
|
||||
if code != 0:
|
||||
logger.error(f"Error while running black on {self.out_file}, code: {code}")
|
||||
return
|
||||
code = subprocess.call(
|
||||
[
|
||||
"isort",
|
||||
"--line-length=100",
|
||||
"--profile=black",
|
||||
"--multi-line=3",
|
||||
"--trailing-comma",
|
||||
self.out_file,
|
||||
]
|
||||
)
|
||||
if code != 0:
|
||||
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
|
||||
return
|
||||
logger.success("done!")
|
||||
|
||||
def _add_comment_to_file(self):
|
||||
with open(self.out_file, "r+") as f:
|
||||
initial = f.read()
|
||||
f.seek(0)
|
||||
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
|
||||
f.write(
|
||||
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
|
||||
)
|
||||
f.write(initial)
|
||||
|
||||
def _update_imports(self):
|
||||
with open(self.out_file, "r+") as f:
|
||||
initial = f.read()
|
||||
f.seek(0)
|
||||
qtpy_imports = re.sub(
|
||||
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
|
||||
)
|
||||
print(self._widget_modules)
|
||||
print(re.findall(self._widget_import_re, qtpy_imports))
|
||||
widget_imports = re.sub(
|
||||
self._widget_import_re,
|
||||
lambda ob: (
|
||||
f"from {module} import {ob.group(2)}"
|
||||
if (module := self._widget_modules.get(ob.group(2))) is not None
|
||||
else ob.group(1)
|
||||
),
|
||||
qtpy_imports,
|
||||
)
|
||||
f.write(widget_imports)
|
||||
f.truncate()
|
||||
|
||||
|
||||
def open_and_watch_ui_editor(widget_name: str):
|
||||
logger.info(f"Opening the editor for {widget_name}, and watching")
|
||||
repo = Path(plugin_repo_path())
|
||||
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
|
||||
ui_file = widget_dir / f"{widget_name}.ui"
|
||||
ui_outfile = widget_dir / f"{widget_name}_ui.py"
|
||||
|
||||
logger.info(
|
||||
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
|
||||
)
|
||||
recompile_handler = RecompileHandler(ui_file, ui_outfile)
|
||||
observer = Observer()
|
||||
observer.schedule(recompile_handler, str(ui_file.parent))
|
||||
observer.start()
|
||||
try:
|
||||
open_designer([str(ui_file)])
|
||||
finally:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
logger.info("Editing session ended, exiting...")
|
||||
@@ -1,6 +1,6 @@
|
||||
""" This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
"""This custom class is a thin wrapper around the SignalProxy class to allow signal calls to be blocked.
|
||||
Unblocking the proxy needs to be done through the slot unblock_proxy. The most likely use case for this class is
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
when the callback function is potentially initiating a slower progress, i.e. requesting a data analysis routine to
|
||||
analyse data. Requesting a new fit may lead to request piling up and an overall slow done of performance. This proxy
|
||||
will allow you to decide by yourself when to unblock and execute the callback again."""
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject, Slot
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from qtpy.QtCore import QObject
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
@@ -33,7 +37,6 @@ class BECWidget(BECConnector):
|
||||
gui_id: str | None = None,
|
||||
theme_update: bool = False,
|
||||
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
|
||||
parent_id: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
@@ -55,12 +58,7 @@ class BECWidget(BECConnector):
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
client=client,
|
||||
config=config,
|
||||
gui_id=gui_id,
|
||||
parent_dock=parent_dock,
|
||||
parent_id=parent_id,
|
||||
**kwargs,
|
||||
client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs
|
||||
)
|
||||
if not isinstance(self, QObject):
|
||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||
@@ -93,7 +91,7 @@ class BECWidget(BECConnector):
|
||||
theme = "dark"
|
||||
self.apply_theme(theme)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def apply_theme(self, theme: str):
|
||||
"""
|
||||
Apply the theme to the widget.
|
||||
@@ -102,16 +100,49 @@ class BECWidget(BECConnector):
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@rpc_timeout(None)
|
||||
def screenshot(self, file_name: str | None = None):
|
||||
"""
|
||||
Take a screenshot of the dock area and save it to a file.
|
||||
"""
|
||||
if not isinstance(self, QWidget):
|
||||
logger.error("Cannot take screenshot of non-QWidget instance")
|
||||
return
|
||||
|
||||
screenshot = self.grab()
|
||||
if file_name is None:
|
||||
file_name, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save Screenshot",
|
||||
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
|
||||
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
|
||||
)
|
||||
if not file_name:
|
||||
return
|
||||
screenshot.save(file_name)
|
||||
logger.info(f"Screenshot saved to {file_name}")
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
# All widgets need to call super().cleanup() in their cleanup method
|
||||
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
|
||||
self.rpc_register.remove_rpc(self)
|
||||
children = self.findChildren(BECWidget)
|
||||
for child in children:
|
||||
if not shiboken6.isValid(child):
|
||||
# If the child is not valid, it means it has already been deleted
|
||||
continue
|
||||
child.close()
|
||||
child.deleteLater()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Wrap the close even to ensure the rpc_register is cleaned up."""
|
||||
try:
|
||||
self.cleanup()
|
||||
if not self._destroyed:
|
||||
self.cleanup()
|
||||
self._destroyed = True
|
||||
finally:
|
||||
super().closeEvent(event) # pylint: disable=no-member
|
||||
|
||||
13
bec_widgets/utils/clickable_label.py
Normal file
13
bec_widgets/utils/clickable_label.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
from qtpy.QtWidgets import QLabel
|
||||
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
def mouseReleaseEvent(self, ev: QMouseEvent) -> None:
|
||||
self.clicked.emit()
|
||||
return super().mouseReleaseEvent(ev)
|
||||
@@ -11,16 +11,19 @@ from pydantic_core import PydanticCustomError
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_qthemes._main import AccentColors
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
def get_theme_name():
|
||||
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
|
||||
theme = "dark"
|
||||
return "dark"
|
||||
else:
|
||||
theme = QApplication.instance().theme.theme
|
||||
return bec_qthemes.load_palette(theme)
|
||||
return QApplication.instance().theme.theme
|
||||
|
||||
|
||||
def get_theme_palette():
|
||||
return bec_qthemes.load_palette(get_theme_name())
|
||||
|
||||
|
||||
def get_accent_colors() -> AccentColors | None:
|
||||
|
||||
@@ -259,10 +259,3 @@ class CompactPopupWidget(QWidget):
|
||||
@expand_popup.setter
|
||||
def expand_popup(self, popup: bool):
|
||||
self._expand_popup = popup
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Called by Qt, on closing - since the children widgets can be
|
||||
# BECWidgets, it is good to explicitely call 'close' on them,
|
||||
# to ensure proper resources cleanup
|
||||
for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly):
|
||||
child.close()
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import Literal, Type
|
||||
from typing import Any, Type
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
|
||||
|
||||
class WidgetContainerUtils:
|
||||
@@ -73,3 +72,36 @@ class WidgetContainerUtils:
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f"No widget of class {widget_class} found.")
|
||||
|
||||
@staticmethod
|
||||
def name_is_protected(name: str, container: Any = None) -> bool:
|
||||
"""
|
||||
Check if the name is not protected.
|
||||
|
||||
Args:
|
||||
name(str): The name to be checked.
|
||||
|
||||
Returns:
|
||||
bool: True if the name is not protected, False otherwise.
|
||||
"""
|
||||
if container is None:
|
||||
container = BECGuiClient
|
||||
gui_client_methods = set(filter(lambda x: not x.startswith("_"), dir(container)))
|
||||
return name in gui_client_methods
|
||||
|
||||
@staticmethod
|
||||
def raise_for_invalid_name(name: str, container: Any = None) -> None:
|
||||
"""
|
||||
Check if the name is valid. If not, raise a ValueError.
|
||||
|
||||
Args:
|
||||
name(str): The name to be checked.
|
||||
Raises:
|
||||
ValueError: If the name is not valid.
|
||||
"""
|
||||
if not WidgetContainerUtils.has_name_valid_chars(name):
|
||||
raise ValueError(
|
||||
f"Name '{name}' contains invalid characters. Only alphanumeric characters, underscores, and dashes are allowed."
|
||||
)
|
||||
if WidgetContainerUtils.name_is_protected(name, container):
|
||||
raise ValueError(f"Name '{name}' is protected. Please choose another name.")
|
||||
|
||||
@@ -5,9 +5,13 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Qt, Signal, Slot
|
||||
from qtpy.QtCore import QObject, QPointF, Qt, Signal
|
||||
from qtpy.QtGui import QCursor, QTransform
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.plots.image.image_item import ImageItem
|
||||
|
||||
|
||||
class CrosshairScatterItem(pg.ScatterPlotItem):
|
||||
def setDownsampling(self, ds=None, auto=None, method=None):
|
||||
@@ -34,13 +38,21 @@ class Crosshair(QObject):
|
||||
coordinatesChanged2D = Signal(tuple)
|
||||
coordinatesClicked2D = Signal(tuple)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
plot_item: pg.PlotItem,
|
||||
precision: int | None = None,
|
||||
*,
|
||||
min_precision: int = 2,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
Crosshair for 1D and 2D plots.
|
||||
|
||||
Args:
|
||||
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
|
||||
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
|
||||
precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range.
|
||||
min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2.
|
||||
parent (QObject, optional): Parent object for the QObject. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
@@ -48,7 +60,9 @@ class Crosshair(QObject):
|
||||
self.is_log_x = None
|
||||
self.is_derivative = None
|
||||
self.plot_item = plot_item
|
||||
self.precision = precision
|
||||
self._precision = precision
|
||||
self._min_precision = max(0, int(min_precision)) # ensure non‑negative
|
||||
|
||||
self.v_line = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.v_line.skip_auto_range = True
|
||||
self.h_line = pg.InfiniteLine(angle=0, movable=False)
|
||||
@@ -85,13 +99,64 @@ class Crosshair(QObject):
|
||||
self.items = []
|
||||
self.marker_moved_1d = {}
|
||||
self.marker_clicked_1d = {}
|
||||
self.marker_2d = None
|
||||
self.marker_2d_row = None
|
||||
self.marker_2d_col = None
|
||||
self.update_markers()
|
||||
self.check_log()
|
||||
self.check_derivatives()
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@property
|
||||
def precision(self) -> int | None:
|
||||
"""Fixed number of decimals; ``None`` enables dynamic mode."""
|
||||
return self._precision
|
||||
|
||||
@precision.setter
|
||||
def precision(self, value: int | None):
|
||||
"""
|
||||
Set the fixed number of decimals to display.
|
||||
|
||||
Args:
|
||||
value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range.
|
||||
"""
|
||||
self._precision = value
|
||||
|
||||
@property
|
||||
def min_precision(self) -> int:
|
||||
"""Lower bound on decimals when dynamic precision is used."""
|
||||
return self._min_precision
|
||||
|
||||
@min_precision.setter
|
||||
def min_precision(self, value: int):
|
||||
"""
|
||||
Set the lower bound on decimals when dynamic precision is used.
|
||||
|
||||
Args:
|
||||
value(int): The minimum number of decimals to display. Must be non-negative.
|
||||
"""
|
||||
self._min_precision = max(0, int(value))
|
||||
|
||||
def _current_precision(self) -> int:
|
||||
"""
|
||||
Get the current precision based on the view range or fixed precision.
|
||||
"""
|
||||
if self._precision is not None:
|
||||
return self._precision
|
||||
|
||||
# Dynamically choose precision from the smaller visible span
|
||||
view_range = self.plot_item.vb.viewRange()
|
||||
x_span = abs(view_range[0][1] - view_range[0][0])
|
||||
y_span = abs(view_range[1][1] - view_range[1][0])
|
||||
|
||||
# Ignore zero spans that can appear during initialisation
|
||||
spans = [s for s in (x_span, y_span) if s > 0]
|
||||
span = min(spans) if spans else 1.0
|
||||
|
||||
exponent = np.floor(np.log10(span)) # order of magnitude
|
||||
decimals = max(0, int(-exponent) + 1)
|
||||
return max(self._min_precision, decimals)
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
@@ -99,7 +164,7 @@ class Crosshair(QObject):
|
||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||
self._update_theme()
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def _update_theme(self, theme: str | None = None):
|
||||
"""Update the theme."""
|
||||
if theme is None:
|
||||
@@ -126,7 +191,7 @@ class Crosshair(QObject):
|
||||
self.coord_label.fill = pg.mkBrush(label_bg_color)
|
||||
self.coord_label.border = pg.mkPen(None)
|
||||
|
||||
@Slot(int)
|
||||
@SafeSlot(int)
|
||||
def update_highlighted_curve(self, curve_index: int):
|
||||
"""
|
||||
Update the highlighted curve in the case of multiple curves in a plot item.
|
||||
@@ -144,8 +209,11 @@ class Crosshair(QObject):
|
||||
if self.highlighted_curve_index is not None and hasattr(self.plot_item, "visible_curves"):
|
||||
# Focus on the highlighted curve only
|
||||
self.items = [self.plot_item.visible_curves[self.highlighted_curve_index]]
|
||||
else:
|
||||
# Handle all curves
|
||||
elif hasattr(self.plot_item, "visible_items"): # PlotBase general case
|
||||
# Handle visible items in the plot item
|
||||
self.items = self.plot_item.visible_items()
|
||||
else: # Non PlotBase case
|
||||
# Handle all items
|
||||
self.items = self.plot_item.items
|
||||
|
||||
# Create or update markers
|
||||
@@ -195,13 +263,55 @@ class Crosshair(QObject):
|
||||
marker_clicked_list.append(marker_clicked)
|
||||
self.marker_clicked_1d[name] = marker_clicked_list
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
if self.marker_2d is not None:
|
||||
if self.marker_2d_row is not None and self.marker_2d_col is not None:
|
||||
continue
|
||||
self.marker_2d = pg.ROI(
|
||||
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
# Create horizontal ROI for row highlighting
|
||||
if item.image is None:
|
||||
continue
|
||||
self.marker_2d_row = pg.ROI(
|
||||
[0, 0], size=[item.image.shape[0], 1], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
self.marker_2d.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d)
|
||||
self.marker_2d_row.skip_auto_range = True
|
||||
if item.image_transform is not None:
|
||||
self.marker_2d_row.setTransform(item.image_transform)
|
||||
self.plot_item.addItem(self.marker_2d_row)
|
||||
|
||||
# Create vertical ROI for column highlighting
|
||||
self.marker_2d_col = pg.ROI(
|
||||
[0, 0], size=[1, item.image.shape[1]], pen=pg.mkPen("r", width=2), movable=False
|
||||
)
|
||||
if item.image_transform is not None:
|
||||
self.marker_2d_col.setTransform(item.image_transform)
|
||||
self.marker_2d_col.skip_auto_range = True
|
||||
self.plot_item.addItem(self.marker_2d_col)
|
||||
|
||||
@SafeSlot()
|
||||
def update_markers_on_image_change(self):
|
||||
"""
|
||||
Update markers when the image changes, e.g. when the
|
||||
image shape or transformation changes.
|
||||
"""
|
||||
for item in self.items:
|
||||
if not isinstance(item, pg.ImageItem):
|
||||
continue
|
||||
if self.marker_2d_row is not None:
|
||||
self.marker_2d_row.setSize([item.image.shape[0], 1])
|
||||
self.marker_2d_row.setTransform(item.image_transform)
|
||||
if self.marker_2d_col is not None:
|
||||
self.marker_2d_col.setSize([1, item.image.shape[1]])
|
||||
self.marker_2d_col.setTransform(item.image_transform)
|
||||
# Get the current mouse position
|
||||
views = self.plot_item.vb.scene().views()
|
||||
if not views:
|
||||
return
|
||||
view = views[0]
|
||||
global_pos = QCursor.pos()
|
||||
view_pos = view.mapFromGlobal(global_pos)
|
||||
scene_pos = view.mapToScene(view_pos)
|
||||
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
plot_pt = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
self.mouse_moved(manual_pos=(plot_pt.x(), plot_pt.y()))
|
||||
|
||||
def snap_to_data(
|
||||
self, x: float, y: float
|
||||
@@ -241,11 +351,29 @@ class Crosshair(QObject):
|
||||
y_values[name] = closest_y
|
||||
x_values[name] = closest_x
|
||||
elif isinstance(item, pg.ImageItem): # 2D plot
|
||||
name = item.config.monitor or str(id(item))
|
||||
name = item.objectName() or str(id(item))
|
||||
image_2d = item.image
|
||||
# Clip the x and y values to the image dimensions to avoid out of bounds errors
|
||||
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
|
||||
x_values[name] = int(np.clip(x, 0, image_2d.shape[0] - 1))
|
||||
if image_2d is None:
|
||||
continue
|
||||
# Map scene coordinates (plot units) back to image pixel coordinates
|
||||
if item.image_transform is not None:
|
||||
inv_transform, _ = item.image_transform.inverted()
|
||||
xy_trans = inv_transform.map(QPointF(x, y))
|
||||
else:
|
||||
xy_trans = QPointF(x, y)
|
||||
|
||||
# Define valid pixel coordinate bounds
|
||||
min_x_px, min_y_px = 0, 0
|
||||
max_x_px = image_2d.shape[0] - 1 # columns
|
||||
max_y_px = image_2d.shape[1] - 1 # rows
|
||||
|
||||
# Clip the mapped coordinates to the image bounds
|
||||
px = int(np.clip(xy_trans.x(), min_x_px, max_x_px))
|
||||
py = int(np.clip(xy_trans.y(), min_y_px, max_y_px))
|
||||
|
||||
# Store snapped pixel positions
|
||||
x_values[name] = px
|
||||
y_values[name] = py
|
||||
|
||||
if x_values and y_values:
|
||||
if all(v is None for v in x_values.values()) or all(
|
||||
@@ -285,56 +413,74 @@ class Crosshair(QObject):
|
||||
|
||||
return list_x[original_index], list_y[original_index]
|
||||
|
||||
def mouse_moved(self, event):
|
||||
"""Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
@SafeSlot(object, tuple)
|
||||
def mouse_moved(self, event=None, manual_pos=None):
|
||||
"""
|
||||
Handles the mouse moved event, updating the crosshair position and emitting signals.
|
||||
|
||||
Args:
|
||||
event: The mouse moved event
|
||||
event(object): The mouse moved event, which contains the scene position.
|
||||
manual_pos(tuple, optional): A tuple containing the (x, y) coordinates to manually set the crosshair position.
|
||||
"""
|
||||
pos = event[0]
|
||||
# Determine target (x, y) in *plot* coordinates
|
||||
if manual_pos is not None:
|
||||
x, y = manual_pos
|
||||
else:
|
||||
if event is None:
|
||||
return # nothing to do
|
||||
scene_pos = event[0] # SignalProxy bundle
|
||||
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
|
||||
return
|
||||
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
|
||||
x, y = view_pos.x(), view_pos.y()
|
||||
|
||||
# Update cross‑hair visuals
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
|
||||
self.update_markers()
|
||||
if self.plot_item.vb.sceneBoundingRect().contains(pos):
|
||||
mouse_point = self.plot_item.vb.mapSceneToView(pos)
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y())
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
scaled_x, scaled_y = self.scale_emitted_coordinates(x, y)
|
||||
self.crosshairChanged.emit((scaled_x, scaled_y))
|
||||
self.positionChanged.emit((x, y))
|
||||
|
||||
x_snap_values, y_snap_values = self.snap_to_data(x, y)
|
||||
if x_snap_values is None or y_snap_values is None:
|
||||
return
|
||||
if all(v is None for v in x_snap_values.values()) or all(
|
||||
v is None for v in y_snap_values.values()
|
||||
):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
snap_x_vals, snap_y_vals = self.snap_to_data(x, y)
|
||||
if snap_x_vals is None or snap_y_vals is None:
|
||||
return
|
||||
if all(v is None for v in snap_x_vals.values()) or all(
|
||||
v is None for v in snap_y_vals.values()
|
||||
):
|
||||
return
|
||||
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([x], [y])
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_2d.setPos([x, y])
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesChanged2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
precision = self._current_precision()
|
||||
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
sx, sy = snap_x_vals[name], snap_y_vals[name]
|
||||
if sx is None or sy is None:
|
||||
continue
|
||||
self.marker_moved_1d[name].setData([sx], [sy])
|
||||
sx_s, sy_s = self.scale_emitted_coordinates(sx, sy)
|
||||
self.coordinatesChanged1D.emit(
|
||||
(name, round(sx_s, precision), round(sy_s, precision))
|
||||
)
|
||||
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.objectName() or str(id(item))
|
||||
px, py = snap_x_vals[name], snap_y_vals[name]
|
||||
if px is None or py is None:
|
||||
continue
|
||||
|
||||
# Respect image transforms
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
row, col = self._get_transformed_position(px, py, item.image_transform)
|
||||
self.marker_2d_row.setPos(row)
|
||||
self.marker_2d_col.setPos(col)
|
||||
else:
|
||||
self.marker_2d_row.setPos([0, py])
|
||||
self.marker_2d_col.setPos([px, 0])
|
||||
|
||||
self.coordinatesChanged2D.emit((name, px, py))
|
||||
|
||||
def mouse_clicked(self, event):
|
||||
"""Handles the mouse clicked event, updating the crosshair position and emitting signals.
|
||||
@@ -364,6 +510,7 @@ class Crosshair(QObject):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
@@ -375,21 +522,44 @@ class Crosshair(QObject):
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
round(x_snapped_scaled, precision),
|
||||
round(y_snapped_scaled, precision),
|
||||
)
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
name = item.config.monitor or str(id(item))
|
||||
name = item.objectName() or str(id(item))
|
||||
x, y = x_snap_values[name], y_snap_values[name]
|
||||
if x is None or y is None:
|
||||
continue
|
||||
self.marker_2d.setPos([x, y])
|
||||
|
||||
if isinstance(item, ImageItem) and item.image_transform is not None:
|
||||
row, col = self._get_transformed_position(x, y, item.image_transform)
|
||||
self.marker_2d_row.setPos(row)
|
||||
self.marker_2d_col.setPos(col)
|
||||
else:
|
||||
self.marker_2d_row.setPos([0, y])
|
||||
self.marker_2d_col.setPos([x, 0])
|
||||
|
||||
coordinate_to_emit = (name, x, y)
|
||||
self.coordinatesClicked2D.emit(coordinate_to_emit)
|
||||
else:
|
||||
continue
|
||||
|
||||
def _get_transformed_position(
|
||||
self, x: float, y: float, transform: QTransform
|
||||
) -> tuple[QPointF, QPointF]:
|
||||
"""
|
||||
Maps the given x and y coordinates to the transformed position using the provided transform.
|
||||
Args:
|
||||
x (float): The x-coordinate to transform.
|
||||
y (float): The y-coordinate to transform.
|
||||
transform (QTransform): The transformation to apply.
|
||||
"""
|
||||
origin = transform.map(QPointF(0, 0))
|
||||
row = transform.map(QPointF(0, y)) - origin
|
||||
col = transform.map(QPointF(x, 0)) - origin
|
||||
return row, col
|
||||
|
||||
def clear_markers(self):
|
||||
"""Clears the markers from the plot."""
|
||||
for marker in self.marker_moved_1d.values():
|
||||
@@ -424,14 +594,27 @@ class Crosshair(QObject):
|
||||
"""
|
||||
x, y = pos
|
||||
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
|
||||
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
|
||||
precision = self._current_precision()
|
||||
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.ImageItem):
|
||||
image = item.image
|
||||
ix = int(np.clip(x, 0, image.shape[0] - 1))
|
||||
iy = int(np.clip(y, 0, image.shape[1] - 1))
|
||||
if image is None:
|
||||
continue
|
||||
|
||||
if item.image_transform is not None:
|
||||
inv_transform, _ = item.image_transform.inverted()
|
||||
pt = inv_transform.map(QPointF(x, y))
|
||||
px, py = pt.x(), pt.y()
|
||||
else:
|
||||
px, py = x, y
|
||||
|
||||
# Clip to valid pixel indices
|
||||
ix = int(np.clip(px, 0, image.shape[0] - 1)) # column
|
||||
iy = int(np.clip(py, 0, image.shape[1] - 1)) # row
|
||||
|
||||
intensity = image[ix, iy]
|
||||
text += f"\nIntensity: {intensity:.{self.precision}g}"
|
||||
text += f"\nIntensity: {intensity:.{precision}f}"
|
||||
break
|
||||
# Update coordinate label
|
||||
self.coord_label.setText(text)
|
||||
@@ -449,12 +632,19 @@ class Crosshair(QObject):
|
||||
self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked()
|
||||
self.clear_markers()
|
||||
|
||||
@SafeSlot()
|
||||
def reset(self):
|
||||
"""Resets the crosshair to its initial state."""
|
||||
if self.marker_2d_row is not None:
|
||||
self.plot_item.removeItem(self.marker_2d_row)
|
||||
self.marker_2d_row = None
|
||||
if self.marker_2d_col is not None:
|
||||
self.plot_item.removeItem(self.marker_2d_col)
|
||||
self.marker_2d_col = None
|
||||
self.clear_markers()
|
||||
|
||||
def cleanup(self):
|
||||
if self.marker_2d is not None:
|
||||
self.plot_item.removeItem(self.marker_2d)
|
||||
self.marker_2d = None
|
||||
self.reset()
|
||||
self.plot_item.removeItem(self.v_line)
|
||||
self.plot_item.removeItem(self.h_line)
|
||||
self.plot_item.removeItem(self.coord_label)
|
||||
|
||||
self.clear_markers()
|
||||
|
||||
@@ -17,13 +17,27 @@ class EntryValidator:
|
||||
raise ValueError(f"Device '{name}' not found in current BEC session")
|
||||
|
||||
device = self.devices[name]
|
||||
description = device.describe()
|
||||
|
||||
# Build list of available signal entries from device._info['signals']
|
||||
signals_dict = getattr(device, "_info", {}).get("signals", {})
|
||||
available_entries = [
|
||||
sig.get("obj_name") for sig in signals_dict.values() if sig.get("obj_name")
|
||||
]
|
||||
|
||||
# If no signals are found, means device is a signal, use the device name as the entry
|
||||
if not available_entries:
|
||||
available_entries = [name]
|
||||
|
||||
# edge case for if name is passed instead of full_name, should not happen
|
||||
if entry in signals_dict:
|
||||
entry = signals_dict[entry].get("obj_name", entry)
|
||||
|
||||
if entry is None or entry == "":
|
||||
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
|
||||
if entry not in description:
|
||||
if entry not in available_entries:
|
||||
raise ValueError(
|
||||
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
|
||||
f"Entry '{entry}' not found in device '{name}' signals. "
|
||||
f"Available signals: '{available_entries}'"
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
@@ -96,23 +96,55 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||
|
||||
'popup_error' keyword argument can be passed with boolean value if a dialog should pop up,
|
||||
otherwise error display is left to the original exception hook
|
||||
'verify_sender' keyword argument can be passed with boolean value if the sender should be verified
|
||||
before executing the slot. If True, the slot will only execute if the sender is a QObject. This is
|
||||
useful to prevent function calls from already deleted objects.
|
||||
'raise_error' keyword argument can be passed with boolean value if the error should be raised
|
||||
after the error is displayed. This is useful to propagate the error to the caller but should be used
|
||||
with great care to avoid segfaults.
|
||||
|
||||
The keywords above are stored in a container which can be overridden by passing
|
||||
'_override_slot_params' keyword argument with a dictionary containing the keywords to override.
|
||||
This is useful to override the default behavior of the decorator for a specific function call.
|
||||
|
||||
"""
|
||||
popup_error = bool(slot_kwargs.pop("popup_error", False))
|
||||
_slot_params = {
|
||||
"popup_error": bool(slot_kwargs.pop("popup_error", False)),
|
||||
"verify_sender": bool(slot_kwargs.pop("verify_sender", False)),
|
||||
"raise_error": bool(slot_kwargs.pop("raise_error", False)),
|
||||
}
|
||||
|
||||
def error_managed(method):
|
||||
@Slot(*slot_args, **slot_kwargs)
|
||||
@functools.wraps(method)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
||||
_override_slot_params = kwargs.pop("_override_slot_params", {})
|
||||
_slot_params.update(_override_slot_params)
|
||||
try:
|
||||
if not _slot_params["verify_sender"] or len(args) == 0:
|
||||
return method(*args, **kwargs)
|
||||
|
||||
_instance = args[0]
|
||||
if not isinstance(_instance, QObject):
|
||||
return method(*args, **kwargs)
|
||||
sender = _instance.sender()
|
||||
if sender is None:
|
||||
logger.info(
|
||||
f"Sender is None for {method.__module__}.{method.__qualname__}, "
|
||||
"skipping method call."
|
||||
)
|
||||
return
|
||||
return method(*args, **kwargs)
|
||||
|
||||
except Exception:
|
||||
slot_name = f"{method.__module__}.{method.__qualname__}"
|
||||
error_msg = traceback.format_exc()
|
||||
if popup_error:
|
||||
ErrorPopupUtility().custom_exception_hook(
|
||||
*sys.exc_info(), popup_error=popup_error
|
||||
)
|
||||
if _slot_params["popup_error"]:
|
||||
ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True)
|
||||
logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}")
|
||||
if _slot_params["raise_error"]:
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
@@ -12,15 +14,20 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.clickable_label import ClickableLabel
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ExpandableGroupFrame(QFrame):
|
||||
|
||||
expansion_state_changed = Signal()
|
||||
|
||||
EXPANDED_ICON_NAME: str = "collapse_all"
|
||||
COLLAPSED_ICON_NAME: str = "expand_all"
|
||||
|
||||
def __init__(self, title: str, parent: QWidget | None = None, expanded: bool = True) -> None:
|
||||
def __init__(
|
||||
self, parent: QWidget | None = None, title: str = "", expanded: bool = True, icon: str = ""
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
@@ -29,19 +36,33 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_icon()
|
||||
self._title = QLabel(f"<b>{title}</b>")
|
||||
self._title_layout.addWidget(self._expansion_button)
|
||||
self._title_layout.addWidget(self._title)
|
||||
|
||||
self._contents = QWidget()
|
||||
self._create_title_layout(title, icon)
|
||||
|
||||
self._contents = QWidget(self)
|
||||
self._layout.addWidget(self._contents)
|
||||
|
||||
self._expansion_button.clicked.connect(self.switch_expanded_state)
|
||||
self.expanded = self._expanded # type: ignore
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
|
||||
self._title = ClickableLabel(f"<b>{title}</b>")
|
||||
self._title_icon = ClickableLabel()
|
||||
self._title_layout.addWidget(self._title_icon)
|
||||
self._title_layout.addWidget(self._title)
|
||||
self.icon_name = icon
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
self._title_layout.addStretch(1)
|
||||
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_expansion_icon()
|
||||
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
@@ -50,7 +71,8 @@ class ExpandableGroupFrame(QFrame):
|
||||
@SafeSlot()
|
||||
def switch_expanded_state(self):
|
||||
self.expanded = not self.expanded # type: ignore
|
||||
self._update_icon()
|
||||
self._update_expansion_icon()
|
||||
self.expansion_state_changed.emit()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self): # type: ignore
|
||||
@@ -61,8 +83,9 @@ class ExpandableGroupFrame(QFrame):
|
||||
self._expanded = expanded
|
||||
self._contents.setVisible(expanded)
|
||||
self.updateGeometry()
|
||||
self.adjustSize()
|
||||
|
||||
def _update_icon(self):
|
||||
def _update_expansion_icon(self):
|
||||
self._expansion_button.setIcon(
|
||||
material_icon(icon_name=self.EXPANDED_ICON_NAME, size=(10, 10), convert_to_pixmap=False)
|
||||
if self.expanded
|
||||
@@ -70,3 +93,36 @@ class ExpandableGroupFrame(QFrame):
|
||||
icon_name=self.COLLAPSED_ICON_NAME, size=(10, 10), convert_to_pixmap=False
|
||||
)
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
def icon_name(self): # type: ignore
|
||||
return self._title_icon_name
|
||||
|
||||
@icon_name.setter
|
||||
def icon_name(self, icon_name: str):
|
||||
self._title_icon_name = icon_name
|
||||
self._set_title_icon(self._title_icon_name)
|
||||
|
||||
def _set_title_icon(self, icon_name: str):
|
||||
if icon_name:
|
||||
self._title_icon.setVisible(True)
|
||||
self._title_icon.setPixmap(
|
||||
material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=True)
|
||||
)
|
||||
else:
|
||||
self._title_icon.setVisible(False)
|
||||
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
app = QApplication([])
|
||||
frame = ExpandableGroupFrame()
|
||||
layout = QVBoxLayout()
|
||||
frame.set_layout(layout)
|
||||
layout.addWidget(QLabel("test1"))
|
||||
layout.addWidget(QLabel("test2"))
|
||||
layout.addWidget(QLabel("test3"))
|
||||
|
||||
frame.show()
|
||||
app.exec()
|
||||
|
||||
@@ -8,6 +8,8 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QStringListModel
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
|
||||
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -15,11 +17,13 @@ class WidgetFilterHandler(ABC):
|
||||
"""Abstract base class for widget filter handlers"""
|
||||
|
||||
@abstractmethod
|
||||
def set_selection(self, widget, selection: list) -> None:
|
||||
def set_selection(self, widget, selection: list[str | tuple]) -> None:
|
||||
"""Set the filtered_selection for the widget
|
||||
|
||||
Args:
|
||||
selection (list): Filtered selection of items
|
||||
widget: Widget instance
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
@@ -34,17 +38,37 @@ class WidgetFilterHandler(ABC):
|
||||
bool: True if the input text is in the filtered selection
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
# This method should be implemented in subclasses or extended as needed
|
||||
|
||||
|
||||
class LineEditFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QLineEdit widget"""
|
||||
|
||||
def set_selection(self, widget: QLineEdit, selection: list) -> None:
|
||||
def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QLineEdit): The QLineEdit widget
|
||||
selection (list): Filtered selection of items
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
if isinstance(selection, tuple):
|
||||
# If selection is a tuple, it contains (text, data) pairs
|
||||
selection = [text for text, _ in selection]
|
||||
if not isinstance(widget.completer, QCompleter):
|
||||
completer = QCompleter(widget)
|
||||
widget.setCompleter(completer)
|
||||
@@ -64,19 +88,47 @@ class LineEditFilterHandler(WidgetFilterHandler):
|
||||
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
|
||||
return text in model_data
|
||||
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
|
||||
return [
|
||||
signal
|
||||
for signal, signal_info in device_info.items()
|
||||
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
|
||||
]
|
||||
|
||||
|
||||
class ComboBoxFilterHandler(WidgetFilterHandler):
|
||||
"""Handler for QComboBox widget"""
|
||||
|
||||
def set_selection(self, widget: QComboBox, selection: list) -> None:
|
||||
def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None:
|
||||
"""Set the selection for the widget to the completer model
|
||||
|
||||
Args:
|
||||
widget (QComboBox): The QComboBox widget
|
||||
selection (list): Filtered selection of items
|
||||
selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs.
|
||||
"""
|
||||
widget.clear()
|
||||
widget.addItems(selection)
|
||||
if len(selection) == 0:
|
||||
return
|
||||
for element in selection:
|
||||
if isinstance(element, str):
|
||||
widget.addItem(element)
|
||||
elif isinstance(element, tuple):
|
||||
# If element is a tuple, it contains (text, data) pairs
|
||||
widget.addItem(*element)
|
||||
|
||||
def check_input(self, widget: QComboBox, text: str) -> bool:
|
||||
"""Check if the input text is in the filtered selection
|
||||
@@ -90,6 +142,40 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
|
||||
"""
|
||||
return text in [widget.itemText(i) for i in range(widget.count())]
|
||||
|
||||
def update_with_kind(
|
||||
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
out = []
|
||||
for signal, signal_info in device_info.items():
|
||||
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
|
||||
continue
|
||||
obj_name = signal_info.get("obj_name", "")
|
||||
component_name = signal_info.get("component_name", "")
|
||||
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
|
||||
if not signal_wo_device:
|
||||
signal_wo_device = obj_name
|
||||
|
||||
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
|
||||
# If the object name is not the same as the signal name, we use the object name
|
||||
# to display in the combobox.
|
||||
out.append((f"{signal_wo_device} ({signal})", signal_info))
|
||||
else:
|
||||
# If the object name is the same as the signal name, we do not change it.
|
||||
out.append((signal, signal_info))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class FilterIO:
|
||||
"""Public interface to set filters for input widgets.
|
||||
@@ -99,13 +185,14 @@ class FilterIO:
|
||||
_handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler}
|
||||
|
||||
@staticmethod
|
||||
def set_selection(widget, selection: list, ignore_errors=True):
|
||||
def set_selection(widget, selection: list[str | tuple], ignore_errors=True):
|
||||
"""
|
||||
Retrieve value from the widget instance.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
selection(list): List of filtered selection items.
|
||||
selection (list[str | tuple]): Filtered selection of items.
|
||||
If tuple, it contains (text, data) pairs.
|
||||
ignore_errors(bool, optional): Whether to ignore if no handler is found.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
@@ -139,6 +226,35 @@ class FilterIO:
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def update_with_kind(
|
||||
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
|
||||
) -> list[str | tuple]:
|
||||
"""
|
||||
Update the selection based on the kind of signal.
|
||||
|
||||
Args:
|
||||
widget: Widget instance.
|
||||
kind (Kind): The kind of signal to filter.
|
||||
signal_filter (set): Set of signal kinds to filter.
|
||||
device_info (dict): Dictionary containing device information.
|
||||
device_name (str): Name of the device.
|
||||
|
||||
Returns:
|
||||
list[str | tuple]: A list of filtered signals based on the kind.
|
||||
"""
|
||||
handler_class = FilterIO._find_handler(widget)
|
||||
if handler_class:
|
||||
return handler_class().update_with_kind(
|
||||
kind=kind,
|
||||
signal_filter=signal_filter,
|
||||
device_info=device_info,
|
||||
device_name=device_name,
|
||||
)
|
||||
raise ValueError(
|
||||
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_handler(widget):
|
||||
"""
|
||||
|
||||
282
bec_widgets/utils/forms_from_types/forms.py
Normal file
282
bec_widgets/utils/forms_from_types/forms.py
Normal file
@@ -0,0 +1,282 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import NoneType
|
||||
from typing import NamedTuple
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.forms_from_types import styles
|
||||
from bec_widgets.utils.forms_from_types.items import (
|
||||
DynamicFormItem,
|
||||
DynamicFormItemType,
|
||||
FormItemSpec,
|
||||
widget_from_type,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class GridRow(NamedTuple):
|
||||
i: int
|
||||
label: QLabel
|
||||
widget: DynamicFormItem
|
||||
|
||||
|
||||
class TypedForm(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "list_alt"
|
||||
|
||||
value_changed = Signal()
|
||||
|
||||
RPC = True
|
||||
USER_ACCESS = ["enabled", "enabled.setter"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
items: list[tuple[str, type]] | None = None,
|
||||
form_item_specs: list[FormItemSpec] | None = None,
|
||||
enabled: bool = True,
|
||||
pretty_display: bool = False,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Widget with a list of form items based on a list of types.
|
||||
|
||||
Args:
|
||||
items (list[tuple[str, type]]): list of tuples of a name for the field and its type.
|
||||
Should be a type supported by the logic in items.py
|
||||
form_item_specs (list[FormItemSpec]): list of form item specs, equivalent to items.
|
||||
only one of items or form_item_specs should be
|
||||
supplied.
|
||||
enabled (bool, optional): whether fields are enabled for editing.
|
||||
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
|
||||
"""
|
||||
if items is not None and form_item_specs is not None:
|
||||
logger.error(
|
||||
"Must specify one and only one of items and form_item_specs! Ignoring `items`."
|
||||
)
|
||||
items = None
|
||||
if items is None and form_item_specs is None:
|
||||
logger.error("Must specify one and only one of items and form_item_specs!")
|
||||
items = []
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
self._items = form_item_specs or [
|
||||
FormItemSpec(name=name, item_type=item_type, pretty_display=pretty_display)
|
||||
for name, item_type in items # type: ignore
|
||||
]
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._enabled: bool = enabled
|
||||
|
||||
self._form_grid_container = QWidget(parent=self)
|
||||
self._form_grid_container.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid = QWidget(parent=self._form_grid_container)
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._layout.addWidget(self._form_grid_container)
|
||||
self._form_grid_container.setLayout(QVBoxLayout())
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
|
||||
self._widget_types: dict | None = None
|
||||
self._widget_from_type = widget_from_type
|
||||
self._post_init()
|
||||
|
||||
def _post_init(self):
|
||||
"""Override this if a subclass should do things after super().__init__ and before populate()"""
|
||||
self.populate()
|
||||
self.enabled = self._enabled # type: ignore # QProperty
|
||||
|
||||
def populate(self):
|
||||
self._clear_grid()
|
||||
for r, item in enumerate(self._items):
|
||||
self._add_griditem(item, r)
|
||||
gl: QGridLayout = self._form_grid.layout()
|
||||
gl.setRowStretch(gl.rowCount(), 1)
|
||||
|
||||
def _add_griditem(self, item: FormItemSpec, row: int):
|
||||
grid = self._form_grid.layout()
|
||||
label = QLabel(item.name)
|
||||
label.setProperty("_model_field_name", item.name)
|
||||
label.setToolTip(item.info.description or item.name)
|
||||
grid.addWidget(label, row, 0)
|
||||
widget = self._widget_from_type(item, self._widget_types)(parent=self, spec=item)
|
||||
widget.valueChanged.connect(self.value_changed)
|
||||
widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
grid.addWidget(widget, row, 1)
|
||||
|
||||
def enumerate_form_widgets(self):
|
||||
"""Return a generator over the rows of the form, with the row number, the label widget (to
|
||||
which the field name is attached as a property "_model_field_name"), and the entry widget"""
|
||||
grid: QGridLayout = self._form_grid.layout() # type: ignore
|
||||
for i in range(grid.rowCount() - 1): # One extra row for stretch
|
||||
yield GridRow(i, grid.itemAtPosition(i, 0).widget(), grid.itemAtPosition(i, 1).widget())
|
||||
|
||||
def _dict_from_grid(self) -> dict[str, DynamicFormItemType]:
|
||||
return {
|
||||
row.label.property("_model_field_name"): row.widget.getValue()
|
||||
for row in self.enumerate_form_widgets()
|
||||
}
|
||||
|
||||
def _clear_grid(self):
|
||||
if (old_layout := self._form_grid.layout()) is not None:
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
old_layout.deleteLater()
|
||||
self._form_grid.deleteLater()
|
||||
self._form_grid = QWidget()
|
||||
self._form_grid.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)
|
||||
self._form_grid.setLayout(self._new_grid_layout())
|
||||
self._form_grid_container.layout().addWidget(self._form_grid)
|
||||
|
||||
self.update_size()
|
||||
|
||||
def update_size(self):
|
||||
self._form_grid.adjustSize()
|
||||
self._form_grid_container.adjustSize()
|
||||
self.adjustSize()
|
||||
|
||||
def _new_grid_layout(self):
|
||||
new_grid = QGridLayout()
|
||||
new_grid.setContentsMargins(0, 0, 0, 0)
|
||||
return new_grid
|
||||
|
||||
@property
|
||||
def widget_dict(self):
|
||||
return {
|
||||
row.label.property("_model_field_name"): row.widget
|
||||
for row in self.enumerate_form_widgets()
|
||||
}
|
||||
|
||||
@SafeProperty(bool)
|
||||
def enabled(self):
|
||||
return self._enabled
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self._enabled = value
|
||||
self.setEnabled(value)
|
||||
|
||||
|
||||
class PydanticModelForm(TypedForm):
|
||||
form_data_updated = Signal(dict)
|
||||
form_data_cleared = Signal(NoneType)
|
||||
validity_proc = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
data_model: type[BaseModel] | None = None,
|
||||
enabled: bool = True,
|
||||
pretty_display: bool = False,
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
A form generated from a pydantic model.
|
||||
|
||||
Args:
|
||||
data_model (type[BaseModel]): the model class for which to generate a form.
|
||||
enabled (bool, optional): whether fields are enabled for editing.
|
||||
pretty_display (bool, optional): Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.
|
||||
|
||||
"""
|
||||
self._pretty_display = pretty_display
|
||||
self._md_schema = data_model
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
form_item_specs=self._form_item_specs(),
|
||||
enabled=enabled,
|
||||
client=client,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._validity = CompactPopupWidget()
|
||||
self._validity.compact_view = True # type: ignore
|
||||
self._validity.label = "Validity" # type: ignore
|
||||
self._validity.compact_show_popup.setIcon(
|
||||
material_icon(icon_name="info", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._validity_message = QLabel("Not yet validated")
|
||||
self._validity.addWidget(self._validity_message)
|
||||
self._layout.addWidget(self._validity)
|
||||
self.value_changed.connect(self.validate_form)
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
def set_pretty_display_theme(self, theme: str = "dark"):
|
||||
if self._pretty_display:
|
||||
self.setStyleSheet(styles.pretty_display_theme(theme))
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
if hasattr(qapp, "theme_signal"):
|
||||
qapp.theme_signal.theme_updated.connect(self.set_pretty_display_theme) # type: ignore
|
||||
|
||||
def set_schema(self, schema: type[BaseModel]):
|
||||
self._md_schema = schema
|
||||
self.populate()
|
||||
|
||||
def set_data(self, data: BaseModel):
|
||||
"""Fill the data for the form.
|
||||
|
||||
Args:
|
||||
data (BaseModel): the data to enter into the form. Must be the same type as the
|
||||
currently set schema, raises TypeError otherwise."""
|
||||
if not self._md_schema:
|
||||
raise ValueError("Schema not set - can't set data")
|
||||
if not isinstance(data, self._md_schema):
|
||||
raise TypeError(f"Supplied data {data} not of type {self._md_schema}")
|
||||
for form_item in self.enumerate_form_widgets():
|
||||
form_item.widget.setValue(getattr(data, form_item.label.property("_model_field_name")))
|
||||
|
||||
def _form_item_specs(self):
|
||||
return [
|
||||
FormItemSpec(
|
||||
name=name, info=info, item_type=info.annotation, pretty_display=self._pretty_display
|
||||
)
|
||||
for name, info in self._md_schema.model_fields.items()
|
||||
]
|
||||
|
||||
def update_items_from_schema(self):
|
||||
self._items = self._form_item_specs()
|
||||
|
||||
def populate(self):
|
||||
self.update_items_from_schema()
|
||||
super().populate()
|
||||
|
||||
def get_form_data(self):
|
||||
"""Get the entered metadata as a dict."""
|
||||
return self._dict_from_grid()
|
||||
|
||||
def validate_form(self, *_) -> bool:
|
||||
"""validate the currently entered metadata against the pydantic schema.
|
||||
If successful, returns on metadata_emitted and returns true.
|
||||
Otherwise, emits on form_data_cleared and returns false."""
|
||||
try:
|
||||
metadata_dict = self.get_form_data()
|
||||
self._md_schema.model_validate(metadata_dict)
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
self.form_data_updated.emit(metadata_dict)
|
||||
self.validity_proc.emit(True)
|
||||
return True
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.form_data_cleared.emit(None)
|
||||
self.validity_proc.emit(False)
|
||||
return False
|
||||
636
bec_widgets/utils/forms_from_types/items.py
Normal file
636
bec_widgets/utils/forms_from_types/items.py
Normal file
@@ -0,0 +1,636 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from abc import abstractmethod
|
||||
from decimal import Decimal
|
||||
from types import GenericAlias, UnionType
|
||||
from typing import (
|
||||
Callable,
|
||||
Final,
|
||||
Generic,
|
||||
Iterable,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
OrderedDict,
|
||||
TypeVar,
|
||||
get_args,
|
||||
)
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic.fields import FieldInfo
|
||||
from pydantic_core import PydanticUndefined
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtCore import QSize, Qt, Signal # type: ignore
|
||||
from qtpy.QtGui import QFontMetrics
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.editors.dict_backed_table import DictBackedTable
|
||||
from bec_widgets.widgets.editors.scan_metadata._util import (
|
||||
clearable_required,
|
||||
field_default,
|
||||
field_limits,
|
||||
field_maxlen,
|
||||
field_minlen,
|
||||
field_precision,
|
||||
)
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FormItemSpec(BaseModel):
|
||||
"""
|
||||
The specification for an item in a dynamically generated form. Uses a pydantic FieldInfo
|
||||
to store most annotation info, since one of the main purposes is to store data for
|
||||
forms genrated from pydantic models, but can also be composed from other sources or by hand.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
item_type: type | UnionType | GenericAlias
|
||||
name: str
|
||||
info: FieldInfo = FieldInfo()
|
||||
pretty_display: bool = Field(
|
||||
default=False,
|
||||
description="Whether to use a pretty display for the widget. Defaults to False. If True, disables the widget, doesn't add a clear button, and adapts the stylesheet for non-editable display.",
|
||||
)
|
||||
|
||||
@field_validator("item_type", mode="before")
|
||||
@classmethod
|
||||
def _validate_type(cls, v):
|
||||
allowed_primitives = [str, int, float, bool]
|
||||
if isinstance(v, (type, UnionType)):
|
||||
return v
|
||||
if isinstance(v, GenericAlias):
|
||||
if v.__origin__ in [list, dict, set] and all(
|
||||
arg in allowed_primitives for arg in v.__args__
|
||||
):
|
||||
return v
|
||||
raise ValueError(
|
||||
f"Generics of type {v} are not supported - only lists, dicts and sets of primitive types {allowed_primitives}"
|
||||
)
|
||||
if type(v) is type(Literal[""]): # _LiteralGenericAlias is not exported from typing
|
||||
arg_types = set(type(arg) for arg in v.__args__)
|
||||
if len(arg_types) != 1:
|
||||
raise ValueError("Mixtures of literal types are not supported!")
|
||||
if (t := arg_types.pop()) in allowed_primitives:
|
||||
return t
|
||||
raise ValueError(f"Literals of type {t} are not supported")
|
||||
|
||||
|
||||
class ClearableBoolEntry(QWidget):
|
||||
stateChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
self._entry = QButtonGroup()
|
||||
self._true = QRadioButton("true", parent=self)
|
||||
self._false = QRadioButton("false", parent=self)
|
||||
for button in [self._true, self._false]:
|
||||
self._layout.addWidget(button)
|
||||
self._entry.addButton(button)
|
||||
button.toggled.connect(self.stateChanged)
|
||||
|
||||
def clear(self):
|
||||
self._entry.setExclusive(False)
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(False)
|
||||
self._entry.setExclusive(True)
|
||||
|
||||
def isChecked(self) -> bool | None:
|
||||
if not self._true.isChecked() and not self._false.isChecked():
|
||||
return None
|
||||
return self._true.isChecked()
|
||||
|
||||
def setChecked(self, value: bool | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
elif value:
|
||||
self._true.setChecked(True)
|
||||
self._false.setChecked(False)
|
||||
else:
|
||||
self._true.setChecked(False)
|
||||
self._false.setChecked(True)
|
||||
|
||||
def setToolTip(self, tooltip: str):
|
||||
self._true.setToolTip(tooltip)
|
||||
self._false.setToolTip(tooltip)
|
||||
|
||||
|
||||
DynamicFormItemType = str | int | float | Decimal | bool | dict | list | None
|
||||
|
||||
|
||||
class DynamicFormItem(QWidget):
|
||||
valueChanged = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
"""
|
||||
Initializes the form item widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None, optional): The parent widget. Defaults to None.
|
||||
spec (FormItemSpec): The specification for the form item.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._spec = spec
|
||||
self._layout = QHBoxLayout()
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetMaximumSize)
|
||||
self._default = field_default(self._spec.info)
|
||||
self._desc = self._spec.info.description
|
||||
self.setLayout(self._layout)
|
||||
self._add_main_widget()
|
||||
assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore
|
||||
self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
if not spec.pretty_display:
|
||||
if clearable_required(spec.info):
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._set_pretty_display()
|
||||
|
||||
@abstractmethod
|
||||
def getValue(self) -> DynamicFormItemType: ...
|
||||
|
||||
@abstractmethod
|
||||
def setValue(self, value): ...
|
||||
|
||||
@abstractmethod
|
||||
def _add_main_widget(self) -> None:
|
||||
"""Add the main data entry widget to self._main_widget and appply any
|
||||
constraints from the field info"""
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self.setEnabled(False)
|
||||
if button := getattr(self, "_clear_button", None):
|
||||
button.setVisible(False)
|
||||
|
||||
def _describe(self, pad=" "):
|
||||
return pad + (self._desc if self._desc else "")
|
||||
|
||||
def _add_clear_button(self):
|
||||
self._clear_button = QToolButton()
|
||||
self._clear_button.setIcon(
|
||||
material_icon(icon_name="close", size=(10, 10), convert_to_pixmap=False)
|
||||
)
|
||||
self._layout.addWidget(self._clear_button)
|
||||
# the widget added in _add_main_widget must implement .clear() if value is not required
|
||||
self._clear_button.setToolTip("Clear value or reset to default.")
|
||||
self._clear_button.clicked.connect(self._main_widget.clear) # type: ignore
|
||||
|
||||
def _value_changed(self, *_, **__):
|
||||
self.valueChanged.emit()
|
||||
|
||||
|
||||
class StrFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QLineEdit()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_length, max_length = (field_minlen(self._spec.info), field_maxlen(self._spec.info))
|
||||
if max_length:
|
||||
self._main_widget.setMaxLength(max_length)
|
||||
self._main_widget.setToolTip(
|
||||
f"(length min: {min_length} max: {max_length}){self._describe()}"
|
||||
)
|
||||
if self._default:
|
||||
self._main_widget.setText(self._default)
|
||||
self._add_clear_button()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.text()
|
||||
|
||||
def setValue(self, value: str):
|
||||
if value is None:
|
||||
return self._main_widget.setText("")
|
||||
self._main_widget.setText(str(value))
|
||||
|
||||
|
||||
class IntFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, int)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
self._main_widget.setToolTip(f"(range {min_} to {max_}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: int):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(value)
|
||||
|
||||
|
||||
class FloatDecimalFormItem(DynamicFormItem):
|
||||
def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.textChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
precision = field_precision(self._spec.info)
|
||||
self._main_widget = QDoubleSpinBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
min_, max_ = field_limits(self._spec.info, float, precision)
|
||||
self._main_widget.setMinimum(min_)
|
||||
self._main_widget.setMaximum(max_)
|
||||
if precision:
|
||||
self._main_widget.setDecimals(precision)
|
||||
minstr = f"{float(min_):.3f}" if abs(min_) <= 1000 else f"{float(min_):.3e}"
|
||||
maxstr = f"{float(max_):.3f}" if abs(max_) <= 1000 else f"{float(max_):.3e}"
|
||||
self._main_widget.setToolTip(f"(range {minstr} to {maxstr}){self._describe()}")
|
||||
if self._default is not None:
|
||||
self._main_widget.setValue(self._default)
|
||||
self._add_clear_button()
|
||||
else:
|
||||
self._main_widget.clear()
|
||||
|
||||
def getValue(self):
|
||||
if self._main_widget.text() == "":
|
||||
return self._default
|
||||
return self._main_widget.value()
|
||||
|
||||
def setValue(self, value: float | Decimal):
|
||||
if value is None:
|
||||
self._main_widget.clear()
|
||||
self._main_widget.setValue(float(value))
|
||||
|
||||
|
||||
class BoolFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.stateChanged.connect(self._value_changed)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
if clearable_required(self._spec.info):
|
||||
self._main_widget = ClearableBoolEntry()
|
||||
else:
|
||||
self._main_widget = QCheckBox()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
self._main_widget.setChecked(self._default) # type: ignore # if there is no default then it will be ClearableBoolEntry and can be set with None
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.isChecked()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.setChecked(value)
|
||||
|
||||
|
||||
class BoolToggleFormItem(BoolFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.default is PydanticUndefined:
|
||||
spec.info.default = False
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = ToggleSwitch()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
if self._default is not None:
|
||||
self._main_widget.setChecked(self._default)
|
||||
|
||||
|
||||
class DictFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self._main_widget.data_changed.connect(self._value_changed)
|
||||
if spec.info.default is not PydanticUndefined:
|
||||
self._main_widget.set_default(spec.info.default)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
self._main_widget.set_button_visibility(False)
|
||||
super()._set_pretty_display()
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = DictBackedTable(self, [])
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._main_widget.setToolTip(self._describe(""))
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.dump_dict()
|
||||
|
||||
def setValue(self, value):
|
||||
self._main_widget.replace_data(value)
|
||||
|
||||
|
||||
_IW = TypeVar("_IW", bound=int | float | str)
|
||||
|
||||
|
||||
class _ItemAndWidgetType(NamedTuple, Generic[_IW]):
|
||||
item: type[_IW]
|
||||
widget: type[QWidget]
|
||||
default: _IW
|
||||
|
||||
|
||||
class ListFormItem(DynamicFormItem):
|
||||
def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None:
|
||||
if spec.info.annotation is list:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
elif isinstance(spec.info.annotation, GenericAlias):
|
||||
args = set(typing.get_args(spec.info.annotation))
|
||||
if args == {str}:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
if args == {int}:
|
||||
self._types = _ItemAndWidgetType(int, QSpinBox, 0)
|
||||
if args == {float} or args == {int, float}:
|
||||
self._types = _ItemAndWidgetType(float, QDoubleSpinBox, 0.0)
|
||||
else:
|
||||
self._types = _ItemAndWidgetType(str, QLineEdit, "")
|
||||
super().__init__(parent=parent, spec=spec)
|
||||
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._main_widget: QListWidget
|
||||
self._data = []
|
||||
self._min_lines = 2 if spec.pretty_display else 4
|
||||
self._repop(self._data)
|
||||
|
||||
def sizeHint(self):
|
||||
default = super().sizeHint()
|
||||
return QSize(default.width(), QFontMetrics(self.font()).height() * 6)
|
||||
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QListWidget()
|
||||
self._layout.addWidget(self._main_widget)
|
||||
self._layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self._add_buttons()
|
||||
|
||||
def _add_buttons(self):
|
||||
self._button_holder = QWidget()
|
||||
self._button_holder.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
|
||||
self._buttons = QVBoxLayout()
|
||||
self._buttons.setContentsMargins(0, 0, 0, 0)
|
||||
self._button_holder.setLayout(self._buttons)
|
||||
self._layout.addWidget(self._button_holder)
|
||||
|
||||
self._add_remove_button_holder = QWidget()
|
||||
self._add_remove_button_layout = QHBoxLayout()
|
||||
self._add_remove_button_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._add_remove_button_holder.setLayout(self._add_remove_button_layout)
|
||||
|
||||
self._add_button = QPushButton("+")
|
||||
self._add_button.setMinimumHeight(15)
|
||||
self._add_button.setToolTip("add a new row")
|
||||
self._remove_button = QPushButton("-")
|
||||
self._remove_button.setMinimumHeight(15)
|
||||
self._remove_button.setToolTip("delete the focused row (if any)")
|
||||
self._add_button.clicked.connect(self._add_row)
|
||||
self._remove_button.clicked.connect(self._delete_row)
|
||||
|
||||
self._buttons.addWidget(self._add_remove_button_holder)
|
||||
self._add_remove_button_layout.addWidget(self._add_button)
|
||||
self._add_remove_button_layout.addWidget(self._remove_button)
|
||||
|
||||
def _set_pretty_display(self):
|
||||
super()._set_pretty_display()
|
||||
self._button_holder.setHidden(True)
|
||||
|
||||
def _repop(self, data):
|
||||
self._main_widget.clear()
|
||||
for val in data:
|
||||
self._add_list_item(val)
|
||||
self.scale_to_data()
|
||||
|
||||
def _add_data_item(self, val=None):
|
||||
val = val or self._types.default
|
||||
self._data.append(val)
|
||||
self._add_list_item(val)
|
||||
self._repop(self._data)
|
||||
|
||||
def _add_list_item(self, val):
|
||||
item = QListWidgetItem(self._main_widget)
|
||||
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable)
|
||||
item_widget = self._types.widget(parent=self)
|
||||
WidgetIO.set_value(item_widget, val)
|
||||
self._main_widget.setItemWidget(item, item_widget)
|
||||
self._main_widget.addItem(item)
|
||||
WidgetIO.connect_widget_change_signal(item_widget, self._update)
|
||||
return item_widget
|
||||
|
||||
def _update(self, _, value, *args):
|
||||
self._data[self._main_widget.currentRow()] = value
|
||||
|
||||
@SafeSlot()
|
||||
def _add_row(self):
|
||||
self._add_data_item(self._types.default)
|
||||
self._repop(self._data)
|
||||
|
||||
@SafeSlot()
|
||||
def _delete_row(self):
|
||||
if selected := self._main_widget.currentItem():
|
||||
self._main_widget.removeItemWidget(selected)
|
||||
row = self._main_widget.currentRow()
|
||||
self._main_widget.takeItem(row)
|
||||
self._data.pop(row)
|
||||
self._repop(self._data)
|
||||
|
||||
@SafeSlot()
|
||||
def clear(self):
|
||||
self._repop([])
|
||||
|
||||
def getValue(self):
|
||||
return self._data
|
||||
|
||||
def setValue(self, value: Iterable):
|
||||
if set(map(type, value)) | {self._types.item} != {self._types.item}:
|
||||
raise ValueError(f"This widget only accepts items of type {self._types.item}")
|
||||
self._data = list(value)
|
||||
self._repop(self._data)
|
||||
|
||||
def _line_height(self):
|
||||
return QFontMetrics(self._main_widget.font()).height()
|
||||
|
||||
def set_max_height_in_lines(self, lines: int):
|
||||
outer_inc = 1 if self._spec.pretty_display else 3
|
||||
self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines))
|
||||
self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1))
|
||||
self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc))
|
||||
|
||||
def scale_to_data(self, *_):
|
||||
self.set_max_height_in_lines(self._main_widget.count() + 1)
|
||||
|
||||
|
||||
class SetFormItem(ListFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
super()._add_main_widget()
|
||||
self._add_item_field = self._types.widget()
|
||||
self._buttons.addWidget(QLabel("Add new:"))
|
||||
self._buttons.addWidget(self._add_item_field)
|
||||
self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Minimum)
|
||||
|
||||
@SafeSlot()
|
||||
def _add_row(self):
|
||||
self._add_data_item(WidgetIO.get_value(self._add_item_field))
|
||||
self._repop(self._data)
|
||||
|
||||
def _update(self, _, value, *args):
|
||||
if value in self._data:
|
||||
return
|
||||
return super()._update(_, value, *args)
|
||||
|
||||
def _add_data_item(self, val=None):
|
||||
val = val or self._types.default
|
||||
if val == self._types.default or val in self._data:
|
||||
return
|
||||
self._data.append(val)
|
||||
self._add_list_item(val)
|
||||
|
||||
def _add_list_item(self, val):
|
||||
item_widget = super()._add_list_item(val)
|
||||
if isinstance(item_widget, QLineEdit):
|
||||
item_widget.setReadOnly(True)
|
||||
return item_widget
|
||||
|
||||
def getValue(self):
|
||||
return set(self._data)
|
||||
|
||||
def setValue(self, value: set):
|
||||
return super().setValue(set(value))
|
||||
|
||||
|
||||
class StrLiteralFormItem(DynamicFormItem):
|
||||
def _add_main_widget(self) -> None:
|
||||
self._main_widget = QComboBox()
|
||||
self._options = get_args(self._spec.info.annotation)
|
||||
for opt in self._options:
|
||||
self._main_widget.addItem(opt)
|
||||
self._layout.addWidget(self._main_widget)
|
||||
|
||||
def getValue(self):
|
||||
return self._main_widget.currentText()
|
||||
|
||||
def setValue(self, value: str | None):
|
||||
if value is None:
|
||||
self.clear()
|
||||
for i in range(self._main_widget.count()):
|
||||
if self._main_widget.itemText(i) == value:
|
||||
self._main_widget.setCurrentIndex(i)
|
||||
return
|
||||
raise ValueError(f"Cannot set value: {value}, options are: {self._options}")
|
||||
|
||||
def clear(self):
|
||||
self._main_widget.setCurrentIndex(-1)
|
||||
|
||||
|
||||
WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]]
|
||||
|
||||
DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | {
|
||||
# dict literals are ordered already but TypedForm subclasses may modify coppies of this dict
|
||||
# and delete/insert keys or change the order
|
||||
"literal_str": (
|
||||
lambda spec: type(spec.info.annotation) is type(Literal[""])
|
||||
and set(type(arg) for arg in get_args(spec.info.annotation)) == {str},
|
||||
StrLiteralFormItem,
|
||||
),
|
||||
"str": (lambda spec: spec.item_type in [str, str | None, None], StrFormItem),
|
||||
"int": (lambda spec: spec.item_type in [int, int | None], IntFormItem),
|
||||
"float_decimal": (
|
||||
lambda spec: spec.item_type in [float, float | None, Decimal, Decimal | None],
|
||||
FloatDecimalFormItem,
|
||||
),
|
||||
"bool": (lambda spec: spec.item_type in [bool, bool | None], BoolFormItem),
|
||||
"dict": (
|
||||
lambda spec: spec.item_type in [dict, dict | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is dict),
|
||||
DictFormItem,
|
||||
),
|
||||
"list": (
|
||||
lambda spec: spec.item_type in [list, list | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is list),
|
||||
ListFormItem,
|
||||
),
|
||||
"set": (
|
||||
lambda spec: spec.item_type in [set, set | None]
|
||||
or (isinstance(spec.item_type, GenericAlias) and spec.item_type.__origin__ is set),
|
||||
SetFormItem,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def widget_from_type(
|
||||
spec: FormItemSpec, widget_types: WidgetTypeRegistry | None = None
|
||||
) -> type[DynamicFormItem]:
|
||||
widget_types = widget_types or DEFAULT_WIDGET_TYPES
|
||||
for predicate, widget_type in widget_types.values():
|
||||
if predicate(spec):
|
||||
return widget_type
|
||||
logger.warning(
|
||||
f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation."
|
||||
)
|
||||
return StrFormItem
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value0: set = Field(set(["a", "b"]))
|
||||
value1: str | None = Field(None)
|
||||
value2: bool | None = Field(None)
|
||||
value3: bool = Field(True)
|
||||
value4: int = Field(123)
|
||||
value5: int | None = Field()
|
||||
value6: list[int] = Field()
|
||||
value7: list = Field()
|
||||
|
||||
app = QApplication([])
|
||||
w = QWidget()
|
||||
layout = QGridLayout()
|
||||
w.setLayout(layout)
|
||||
items = []
|
||||
for i, (field_name, info) in enumerate(TestModel.model_fields.items()):
|
||||
spec = spec = FormItemSpec(item_type=info.annotation, name=field_name, info=info)
|
||||
layout.addWidget(QLabel(field_name), i, 0)
|
||||
widg = widget_from_type(spec)(spec=spec)
|
||||
items.append(widg)
|
||||
layout.addWidget(widg, i, 1)
|
||||
|
||||
items[6].setValue([1, 2, 3, 4])
|
||||
items[7].setValue(["1", "2", "asdfg", "qwerty"])
|
||||
|
||||
w.show()
|
||||
app.exec()
|
||||
21
bec_widgets/utils/forms_from_types/styles.py
Normal file
21
bec_widgets/utils/forms_from_types/styles.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import bec_qthemes
|
||||
|
||||
|
||||
def pretty_display_theme(theme: str = "dark"):
|
||||
palette = bec_qthemes.load_palette(theme)
|
||||
foreground = palette.text().color().name()
|
||||
background = palette.base().color().name()
|
||||
border = palette.shadow().color().name()
|
||||
accent = palette.accent().color().name()
|
||||
return f"""
|
||||
QWidget {{color: {foreground}; background-color: {background}}}
|
||||
QLabel {{ font-weight: bold; }}
|
||||
QLineEdit,QLabel,QTreeView {{ border-style: solid; border-width: 2px; border-color: {border} }}
|
||||
QRadioButton {{ color: {foreground}; }}
|
||||
QRadioButton::indicator::checked {{ color: {accent}; }}
|
||||
QCheckBox {{ color: {accent}; }}
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(pretty_display_theme())
|
||||
@@ -5,7 +5,12 @@ from typing import NamedTuple
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDockArea", "BECDock", "BECFigure"]
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
|
||||
EXCLUDED_PLUGINS = ["BECConnector", "BECDock"]
|
||||
_PARENT_ARG_REGEX = r".__init__\(\s*(?:parent\)|parent=parent,?|parent,?)"
|
||||
_SELF_PARENT_ARG_REGEX = r".__init__\(\s*self,\s*(?:parent\)|parent=parent,?|parent,?)"
|
||||
SUPER_INIT_REGEX = re.compile(r"super\(\)" + _PARENT_ARG_REGEX, re.MULTILINE)
|
||||
|
||||
|
||||
class PluginFilenames(NamedTuple):
|
||||
@@ -22,7 +27,7 @@ class DesignerPluginInfo:
|
||||
def __init__(self, plugin_class):
|
||||
self.plugin_class = plugin_class
|
||||
self.plugin_name_pascal = plugin_class.__name__
|
||||
self.plugin_name_snake = self.pascal_to_snake(self.plugin_name_pascal)
|
||||
self.plugin_name_snake = pascal_to_snake(self.plugin_name_pascal)
|
||||
self.widget_import = f"from {plugin_class.__module__} import {self.plugin_name_pascal}"
|
||||
plugin_module = (
|
||||
".".join(plugin_class.__module__.split(".")[:-1]) + f".{self.plugin_name_snake}_plugin"
|
||||
@@ -38,21 +43,6 @@ class DesignerPluginInfo:
|
||||
|
||||
self.base_path = os.path.dirname(inspect.getfile(plugin_class))
|
||||
|
||||
@staticmethod
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
class DesignerPluginGenerator:
|
||||
def __init__(self, widget: type):
|
||||
@@ -103,24 +93,20 @@ class DesignerPluginGenerator:
|
||||
|
||||
# Check if the widget class calls the super constructor with parent argument
|
||||
init_source = inspect.getsource(self.widget.__init__)
|
||||
cls_init_found = (
|
||||
bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent=parent") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent)") > 0)
|
||||
or bool(init_source.find(f"{base_cls[0].__name__}.__init__(self, parent,") > 0)
|
||||
)
|
||||
super_init_found = (
|
||||
bool(
|
||||
init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent=parent") > 0
|
||||
)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent,") > 0)
|
||||
or bool(init_source.find(f"super({base_cls[0].__name__}, self).__init__(parent)") > 0)
|
||||
class_re = re.compile(base_cls[0].__name__ + _SELF_PARENT_ARG_REGEX, re.MULTILINE)
|
||||
cls_init_found = class_re.search(init_source) is not None
|
||||
super_self_re = re.compile(
|
||||
rf"super\({base_cls[0].__name__}, self\)" + _PARENT_ARG_REGEX, re.MULTILINE
|
||||
)
|
||||
super_init_found = super_self_re.search(init_source) is not None
|
||||
if issubclass(self.widget.__bases__[0], QObject) and not super_init_found:
|
||||
super_init_found = (
|
||||
bool(init_source.find("super().__init__(parent=parent") > 0)
|
||||
or bool(init_source.find("super().__init__(parent,") > 0)
|
||||
or bool(init_source.find("super().__init__(parent)") > 0)
|
||||
)
|
||||
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
|
||||
# for the new style classes, we only have one super call. We can therefore check if the
|
||||
# number of __init__ calls is 2 (the class itself and the super class)
|
||||
num_inits = re.findall(r"__init__", init_source)
|
||||
if len(num_inits) == 2 and not super_init_found:
|
||||
super_init_found = SUPER_INIT_REGEX.search(init_source) is not None
|
||||
|
||||
if not cls_init_found and not super_init_found:
|
||||
raise ValueError(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
|
||||
The class is mainly designed for usage with the BECWaveform and 1D plots. """
|
||||
"""Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
|
||||
The class is mainly designed for usage with the BECWaveform and 1D plots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Signal, Slot
|
||||
|
||||
16
bec_widgets/utils/name_utils.py
Normal file
16
bec_widgets/utils/name_utils.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import re
|
||||
|
||||
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""
|
||||
Convert PascalCase to snake_case.
|
||||
|
||||
Args:
|
||||
name (str): The name to be converted.
|
||||
|
||||
Returns:
|
||||
str: The converted name.
|
||||
"""
|
||||
s1 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
||||
s2 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s1)
|
||||
return s2.lower()
|
||||
@@ -2,6 +2,7 @@
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
{widget_import}
|
||||
@@ -20,6 +21,8 @@ class {plugin_name_pascal}Plugin(QDesignerCustomWidgetInterface): # pragma: no
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = {plugin_name_pascal}(parent)
|
||||
return t
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import importlib
|
||||
import inspect
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Iterable
|
||||
|
||||
from bec_lib.plugin_helper import _get_available_plugins
|
||||
from qtpy.QtWidgets import QGraphicsWidget, QWidget
|
||||
@@ -90,15 +90,15 @@ class BECClassInfo:
|
||||
name: str
|
||||
module: str
|
||||
file: str
|
||||
obj: type
|
||||
obj: type[BECWidget]
|
||||
is_connector: bool = False
|
||||
is_widget: bool = False
|
||||
is_plugin: bool = False
|
||||
|
||||
|
||||
class BECClassContainer:
|
||||
def __init__(self):
|
||||
self._collection: list[BECClassInfo] = []
|
||||
def __init__(self, initial: Iterable[BECClassInfo] = []):
|
||||
self._collection: list[BECClassInfo] = list(initial)
|
||||
|
||||
def __repr__(self):
|
||||
return str(list(cl.name for cl in self.collection))
|
||||
@@ -106,6 +106,16 @@ class BECClassContainer:
|
||||
def __iter__(self):
|
||||
return self._collection.__iter__()
|
||||
|
||||
def __add__(self, other: BECClassContainer):
|
||||
return BECClassContainer((*self, *(c for c in other if c.name not in self.names)))
|
||||
|
||||
def as_dict(self, ignores: list[str] = []) -> dict[str, type[BECWidget]]:
|
||||
"""get a dict of {name: Type} for all the entries in the collection.
|
||||
|
||||
Args:
|
||||
ignores(list[str]): a list of class names to exclude from the dictionary."""
|
||||
return {c.name: c.obj for c in self if c.name not in ignores}
|
||||
|
||||
def add_class(self, class_info: BECClassInfo):
|
||||
"""
|
||||
Add a class to the collection.
|
||||
@@ -115,53 +125,44 @@ class BECClassContainer:
|
||||
"""
|
||||
self.collection.append(class_info)
|
||||
|
||||
@property
|
||||
def names(self):
|
||||
"""Return a list of class names"""
|
||||
return [c.name for c in self]
|
||||
|
||||
@property
|
||||
def collection(self):
|
||||
"""
|
||||
Get the collection of classes.
|
||||
"""
|
||||
"""Get the collection of classes."""
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def connector_classes(self):
|
||||
"""
|
||||
Get all connector classes.
|
||||
"""
|
||||
"""Get all connector classes."""
|
||||
return [info.obj for info in self.collection if info.is_connector]
|
||||
|
||||
@property
|
||||
def top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes.
|
||||
"""
|
||||
"""Get all top-level classes."""
|
||||
return [info.obj for info in self.collection if info.is_plugin]
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""
|
||||
Get all plugins. These are all classes that are on the top level and are widgets.
|
||||
"""
|
||||
"""Get all plugins. These are all classes that are on the top level and are widgets."""
|
||||
return [info.obj for info in self.collection if info.is_widget and info.is_plugin]
|
||||
|
||||
@property
|
||||
def widgets(self):
|
||||
"""
|
||||
Get all widgets. These are all classes inheriting from BECWidget.
|
||||
"""
|
||||
"""Get all widgets. These are all classes inheriting from BECWidget."""
|
||||
return [info.obj for info in self.collection if info.is_widget]
|
||||
|
||||
@property
|
||||
def rpc_top_level_classes(self):
|
||||
"""
|
||||
Get all top-level classes that are RPC-enabled. These are all classes that users can choose from.
|
||||
"""
|
||||
"""Get all top-level classes that are RPC-enabled. These are all classes that users can choose from."""
|
||||
return [info.obj for info in self.collection if info.is_plugin and info.is_connector]
|
||||
|
||||
@property
|
||||
def classes(self):
|
||||
"""
|
||||
Get all classes.
|
||||
"""
|
||||
"""Get all classes."""
|
||||
return [info.obj for info in self.collection]
|
||||
|
||||
|
||||
@@ -197,10 +198,10 @@ def get_custom_classes(repo_name: str) -> BECClassContainer:
|
||||
if not hasattr(obj, "__module__") or obj.__module__ != module.__name__:
|
||||
continue
|
||||
if isinstance(obj, type):
|
||||
class_info = BECClassInfo(name=name, module=module_name, file=path, obj=obj)
|
||||
class_info = BECClassInfo(name=name, module=module.__name__, file=path, obj=obj)
|
||||
if issubclass(obj, BECConnector):
|
||||
class_info.is_connector = True
|
||||
if issubclass(obj, BECWidget):
|
||||
if issubclass(obj, QWidget) or issubclass(obj, BECWidget):
|
||||
class_info.is_widget = True
|
||||
if len(subs) == 1 and (
|
||||
issubclass(obj, QWidget) or issubclass(obj, QGraphicsWidget)
|
||||
|
||||
694
bec_widgets/utils/property_editor.py
Normal file
694
bec_widgets/utils/property_editor.py
Normal file
@@ -0,0 +1,694 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFontDialog,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class PropertyEditor(QWidget):
|
||||
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
|
||||
super().__init__(parent)
|
||||
self._target = target
|
||||
self._bec_only = show_only_bec
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Name row
|
||||
name_row = QHBoxLayout()
|
||||
name_row.addWidget(QLabel("Name:"))
|
||||
self.name_edit = QLineEdit(target.objectName())
|
||||
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
|
||||
name_row.addWidget(self.name_edit)
|
||||
layout.addLayout(name_row)
|
||||
|
||||
# BEC only checkbox
|
||||
filter_row = QHBoxLayout()
|
||||
self.chk_show_qt = QCheckBox("Show Qt properties")
|
||||
self.chk_show_qt.setChecked(False)
|
||||
filter_row.addWidget(self.chk_show_qt)
|
||||
filter_row.addStretch(1)
|
||||
layout.addLayout(filter_row)
|
||||
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
|
||||
|
||||
# Main tree widget
|
||||
self.tree = QTreeWidget(self)
|
||||
self.tree.setColumnCount(2)
|
||||
self.tree.setHeaderLabels(["Property", "Value"])
|
||||
self.tree.setAlternatingRowColors(True)
|
||||
self.tree.setRootIsDecorated(False)
|
||||
layout.addWidget(self.tree)
|
||||
self._build()
|
||||
|
||||
def _class_chain(self):
|
||||
chain = []
|
||||
mo = self._target.metaObject()
|
||||
while mo is not None:
|
||||
chain.append(mo)
|
||||
mo = mo.superClass()
|
||||
return chain
|
||||
|
||||
def set_show_only_bec(self, flag: bool):
|
||||
self._bec_only = flag
|
||||
self._build()
|
||||
|
||||
def _set_equal_columns(self):
|
||||
header = self.tree.header()
|
||||
header.setSectionResizeMode(0, QHeaderView.Interactive)
|
||||
header.setSectionResizeMode(1, QHeaderView.Interactive)
|
||||
w = self.tree.viewport().width() or self.tree.width()
|
||||
if w > 0:
|
||||
half = max(1, w // 2)
|
||||
self.tree.setColumnWidth(0, half)
|
||||
self.tree.setColumnWidth(1, w - half)
|
||||
|
||||
def _build(self):
|
||||
self.tree.clear()
|
||||
for mo in self._class_chain():
|
||||
class_name = mo.className()
|
||||
if self._bec_only and not self._is_bec_metaobject(mo):
|
||||
continue
|
||||
group_item = QTreeWidgetItem(self.tree, [class_name])
|
||||
group_item.setFirstColumnSpanned(True)
|
||||
start = mo.propertyOffset()
|
||||
end = mo.propertyCount()
|
||||
for i in range(start, end):
|
||||
prop = mo.property(i)
|
||||
if (
|
||||
not prop.isReadable()
|
||||
or not prop.isWritable()
|
||||
or not prop.isStored()
|
||||
or not prop.isDesignable()
|
||||
):
|
||||
continue
|
||||
name = prop.name()
|
||||
if name == "objectName":
|
||||
continue
|
||||
value = self._target.property(name)
|
||||
self._add_property_row(group_item, name, value, prop)
|
||||
if group_item.childCount() == 0:
|
||||
idx = self.tree.indexOfTopLevelItem(group_item)
|
||||
self.tree.takeTopLevelItem(idx)
|
||||
self.tree.expandAll()
|
||||
QTimer.singleShot(0, self._set_equal_columns)
|
||||
|
||||
def _enum_int(self, obj) -> int:
|
||||
return int(getattr(obj, "value", obj))
|
||||
|
||||
def _make_sizepolicy_editor(self, name: str, sp):
|
||||
if not isinstance(sp, QSizePolicy):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
h_combo = QComboBox(wrap)
|
||||
v_combo = QComboBox(wrap)
|
||||
hs = QSpinBox(wrap)
|
||||
vs = QSpinBox(wrap)
|
||||
for b in (hs, vs):
|
||||
b.setRange(0, 16777215)
|
||||
policies = [
|
||||
(QSizePolicy.Fixed, "Fixed"),
|
||||
(QSizePolicy.Minimum, "Minimum"),
|
||||
(QSizePolicy.Maximum, "Maximum"),
|
||||
(QSizePolicy.Preferred, "Preferred"),
|
||||
(QSizePolicy.Expanding, "Expanding"),
|
||||
(QSizePolicy.MinimumExpanding, "MinExpanding"),
|
||||
(QSizePolicy.Ignored, "Ignored"),
|
||||
]
|
||||
for pol, text in policies:
|
||||
h_combo.addItem(text, self._enum_int(pol))
|
||||
v_combo.addItem(text, self._enum_int(pol))
|
||||
|
||||
def _set_current(combo, val):
|
||||
idx = combo.findData(self._enum_int(val))
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
_set_current(h_combo, sp.horizontalPolicy())
|
||||
_set_current(v_combo, sp.verticalPolicy())
|
||||
hs.setValue(sp.horizontalStretch())
|
||||
vs.setValue(sp.verticalStretch())
|
||||
|
||||
def apply_changes():
|
||||
hp = QSizePolicy.Policy(h_combo.currentData())
|
||||
vp = QSizePolicy.Policy(v_combo.currentData())
|
||||
nsp = QSizePolicy(hp, vp)
|
||||
nsp.setHorizontalStretch(hs.value())
|
||||
nsp.setVerticalStretch(vs.value())
|
||||
self._target.setProperty(name, nsp)
|
||||
|
||||
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
|
||||
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
|
||||
hs.valueChanged.connect(lambda _=None: apply_changes())
|
||||
vs.valueChanged.connect(lambda _=None: apply_changes())
|
||||
row.addWidget(h_combo)
|
||||
row.addWidget(v_combo)
|
||||
row.addWidget(hs)
|
||||
row.addWidget(vs)
|
||||
return wrap
|
||||
|
||||
def _make_locale_editor(self, name: str, loc):
|
||||
if not isinstance(loc, QLocale):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
lang_combo = QComboBox(wrap)
|
||||
country_combo = QComboBox(wrap)
|
||||
for lang in QLocale.Language:
|
||||
try:
|
||||
lang_int = self._enum_int(lang)
|
||||
except Exception:
|
||||
continue
|
||||
if lang_int < 0:
|
||||
continue
|
||||
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
|
||||
lang_combo.addItem(name_txt, lang_int)
|
||||
|
||||
def populate_countries():
|
||||
country_combo.blockSignals(True)
|
||||
country_combo.clear()
|
||||
for terr in QLocale.Country:
|
||||
try:
|
||||
terr_int = self._enum_int(terr)
|
||||
except Exception:
|
||||
continue
|
||||
if terr_int < 0:
|
||||
continue
|
||||
text = QLocale.countryToString(QLocale.Country(terr_int))
|
||||
country_combo.addItem(text, terr_int)
|
||||
cur_country = self._enum_int(loc.country())
|
||||
idx = country_combo.findData(cur_country)
|
||||
if idx >= 0:
|
||||
country_combo.setCurrentIndex(idx)
|
||||
country_combo.blockSignals(False)
|
||||
|
||||
cur_lang = self._enum_int(loc.language())
|
||||
idx = lang_combo.findData(cur_lang)
|
||||
if idx >= 0:
|
||||
lang_combo.setCurrentIndex(idx)
|
||||
populate_countries()
|
||||
|
||||
def apply_locale():
|
||||
lang = QLocale.Language(int(lang_combo.currentData()))
|
||||
country = QLocale.Country(int(country_combo.currentData()))
|
||||
self._target.setProperty(name, QLocale(lang, country))
|
||||
|
||||
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
|
||||
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
|
||||
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
|
||||
row.addWidget(lang_combo)
|
||||
row.addWidget(country_combo)
|
||||
return wrap
|
||||
|
||||
def _make_icon_editor(self, name: str, icon):
|
||||
btn = QPushButton(self)
|
||||
btn.setText("Choose…")
|
||||
if isinstance(icon, QIcon) and not icon.isNull():
|
||||
btn.setIcon(icon)
|
||||
|
||||
def pick():
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
|
||||
)
|
||||
if path:
|
||||
ic = QIcon(path)
|
||||
self._target.setProperty(name, ic)
|
||||
btn.setIcon(ic)
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _spin_pair(self, ints: bool = True):
|
||||
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
|
||||
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
|
||||
if ints:
|
||||
box1.setRange(-10_000_000, 10_000_000)
|
||||
box2.setRange(-10_000_000, 10_000_000)
|
||||
else:
|
||||
for b in (box1, box2):
|
||||
b.setDecimals(6)
|
||||
b.setRange(-1e12, 1e12)
|
||||
b.setSingleStep(0.1)
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
wrap = QWidget(self)
|
||||
wrap.setLayout(row)
|
||||
row.addWidget(box1)
|
||||
row.addWidget(box2)
|
||||
return wrap, box1, box2
|
||||
|
||||
def _spin_quad(self, ints: bool = True):
|
||||
s = QSpinBox if ints else QDoubleSpinBox
|
||||
boxes = [s(self) for _ in range(4)]
|
||||
if ints:
|
||||
for b in boxes:
|
||||
b.setRange(-10_000_000, 10_000_000)
|
||||
else:
|
||||
for b in boxes:
|
||||
b.setDecimals(6)
|
||||
b.setRange(-1e12, 1e12)
|
||||
b.setSingleStep(0.1)
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
wrap = QWidget(self)
|
||||
wrap.setLayout(row)
|
||||
for b in boxes:
|
||||
row.addWidget(b)
|
||||
return wrap, boxes
|
||||
|
||||
def _make_font_editor(self, name: str, value):
|
||||
btn = QPushButton(self)
|
||||
if isinstance(value, QFont):
|
||||
btn.setText(f"{value.family()}, {value.pointSize()}pt")
|
||||
else:
|
||||
btn.setText("Select font…")
|
||||
|
||||
def pick():
|
||||
ok, font = QFontDialog.getFont(
|
||||
value if isinstance(value, QFont) else QFont(), self, "Select Font"
|
||||
)
|
||||
if ok:
|
||||
self._target.setProperty(name, font)
|
||||
btn.setText(f"{font.family()}, {font.pointSize()}pt")
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _make_color_editor(self, initial: QColor, apply_cb):
|
||||
btn = QPushButton(self)
|
||||
if isinstance(initial, QColor):
|
||||
btn.setText(initial.name())
|
||||
btn.setStyleSheet(f"background:{initial.name()};")
|
||||
else:
|
||||
btn.setText("Select color…")
|
||||
|
||||
def pick():
|
||||
col = QColorDialog.getColor(
|
||||
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
|
||||
)
|
||||
if col.isValid():
|
||||
apply_cb(col)
|
||||
btn.setText(col.name())
|
||||
btn.setStyleSheet(f"background:{col.name()};")
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _apply_palette_color(
|
||||
self,
|
||||
name: str,
|
||||
pal: QPalette,
|
||||
group: QPalette.ColorGroup,
|
||||
role: QPalette.ColorRole,
|
||||
col: QColor,
|
||||
):
|
||||
pal.setColor(group, role, col)
|
||||
self._target.setProperty(name, pal)
|
||||
|
||||
def _make_palette_editor(self, name: str, pal: QPalette):
|
||||
if not isinstance(pal, QPalette):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
group_combo = QComboBox(wrap)
|
||||
role_combo = QComboBox(wrap)
|
||||
pick_btn = self._make_color_editor(
|
||||
pal.color(QPalette.Active, QPalette.WindowText),
|
||||
lambda col: self._apply_palette_color(
|
||||
name, pal, QPalette.Active, QPalette.WindowText, col
|
||||
),
|
||||
)
|
||||
groups = [
|
||||
(QPalette.Active, "Active"),
|
||||
(QPalette.Inactive, "Inactive"),
|
||||
(QPalette.Disabled, "Disabled"),
|
||||
]
|
||||
for g, label in groups:
|
||||
group_combo.addItem(label, int(getattr(g, "value", g)))
|
||||
roles = [
|
||||
(QPalette.WindowText, "WindowText"),
|
||||
(QPalette.Window, "Window"),
|
||||
(QPalette.Base, "Base"),
|
||||
(QPalette.AlternateBase, "AlternateBase"),
|
||||
(QPalette.ToolTipBase, "ToolTipBase"),
|
||||
(QPalette.ToolTipText, "ToolTipText"),
|
||||
(QPalette.Text, "Text"),
|
||||
(QPalette.Button, "Button"),
|
||||
(QPalette.ButtonText, "ButtonText"),
|
||||
(QPalette.BrightText, "BrightText"),
|
||||
(QPalette.Highlight, "Highlight"),
|
||||
(QPalette.HighlightedText, "HighlightedText"),
|
||||
]
|
||||
for r, label in roles:
|
||||
role_combo.addItem(label, int(getattr(r, "value", r)))
|
||||
|
||||
def rewire_button():
|
||||
g = QPalette.ColorGroup(int(group_combo.currentData()))
|
||||
r = QPalette.ColorRole(int(role_combo.currentData()))
|
||||
col = pal.color(g, r)
|
||||
while row.count() > 2:
|
||||
w = row.takeAt(2).widget()
|
||||
if w:
|
||||
w.deleteLater()
|
||||
btn = self._make_color_editor(
|
||||
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
|
||||
)
|
||||
row.addWidget(btn)
|
||||
|
||||
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
|
||||
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
|
||||
row.addWidget(group_combo)
|
||||
row.addWidget(role_combo)
|
||||
row.addWidget(pick_btn)
|
||||
return wrap
|
||||
|
||||
def _make_cursor_editor(self, name: str, value):
|
||||
combo = QComboBox(self)
|
||||
shapes = [
|
||||
(Qt.ArrowCursor, "Arrow"),
|
||||
(Qt.IBeamCursor, "IBeam"),
|
||||
(Qt.WaitCursor, "Wait"),
|
||||
(Qt.CrossCursor, "Cross"),
|
||||
(Qt.UpArrowCursor, "UpArrow"),
|
||||
(Qt.SizeAllCursor, "SizeAll"),
|
||||
(Qt.PointingHandCursor, "PointingHand"),
|
||||
(Qt.ForbiddenCursor, "Forbidden"),
|
||||
(Qt.WhatsThisCursor, "WhatsThis"),
|
||||
(Qt.BusyCursor, "Busy"),
|
||||
]
|
||||
current_shape = None
|
||||
if isinstance(value, QCursor):
|
||||
try:
|
||||
enum_val = value.shape()
|
||||
current_shape = int(getattr(enum_val, "value", enum_val))
|
||||
except Exception:
|
||||
current_shape = None
|
||||
for shape, text in shapes:
|
||||
combo.addItem(text, int(getattr(shape, "value", shape)))
|
||||
if current_shape is not None:
|
||||
idx = combo.findData(current_shape)
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
def apply_index(i):
|
||||
shape_val = int(combo.itemData(i))
|
||||
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
|
||||
|
||||
combo.currentIndexChanged.connect(apply_index)
|
||||
return combo
|
||||
|
||||
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
|
||||
item = QTreeWidgetItem(parent, [name, ""])
|
||||
editor = self._make_editor(name, value, prop)
|
||||
if editor is not None:
|
||||
self.tree.setItemWidget(item, 1, editor)
|
||||
else:
|
||||
item.setText(1, repr(value))
|
||||
|
||||
def _is_bec_metaobject(self, mo) -> bool:
|
||||
cname = mo.className()
|
||||
for cls in type(self._target).mro():
|
||||
if getattr(cls, "__name__", None) == cname:
|
||||
mod = getattr(cls, "__module__", "")
|
||||
return mod.startswith("bec_widgets")
|
||||
return False
|
||||
|
||||
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
|
||||
if not meta_enum.isFlag():
|
||||
key = meta_enum.valueToKey(value_int)
|
||||
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
|
||||
parts = []
|
||||
for i in range(meta_enum.keyCount()):
|
||||
k = meta_enum.key(i)
|
||||
v = meta_enum.value(i)
|
||||
if value_int & v:
|
||||
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
|
||||
parts.append(k)
|
||||
return " | ".join(parts) if parts else "0"
|
||||
|
||||
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
pass
|
||||
v = getattr(value, "value", None)
|
||||
if isinstance(v, (int,)):
|
||||
return int(v)
|
||||
n = getattr(value, "name", None)
|
||||
if isinstance(n, str):
|
||||
res = meta_enum.keyToValue(n)
|
||||
if res != -1:
|
||||
return int(res)
|
||||
s = str(value)
|
||||
parts = [p.strip() for p in s.replace(",", "|").split("|")]
|
||||
keys = []
|
||||
for p in parts:
|
||||
if "." in p:
|
||||
p = p.split(".")[-1]
|
||||
keys.append(p)
|
||||
keystr = "|".join(keys)
|
||||
try:
|
||||
res = meta_enum.keysToValue(keystr)
|
||||
if res != -1:
|
||||
return int(res)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def _make_enum_editor(self, name: str, value, prop):
|
||||
meta_enum = prop.enumerator()
|
||||
current = self._enum_value_to_int(meta_enum, value)
|
||||
|
||||
if not meta_enum.isFlag():
|
||||
combo = QComboBox(self)
|
||||
for i in range(meta_enum.keyCount()):
|
||||
key = meta_enum.key(i)
|
||||
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
|
||||
combo.addItem(key, meta_enum.value(i))
|
||||
idx = combo.findData(current)
|
||||
if idx < 0:
|
||||
txt = self._enum_text(meta_enum, current)
|
||||
idx = combo.findText(txt)
|
||||
combo.setCurrentIndex(max(idx, 0))
|
||||
|
||||
def apply_index(i):
|
||||
v = combo.itemData(i)
|
||||
self._target.setProperty(name, int(v))
|
||||
|
||||
combo.currentIndexChanged.connect(apply_index)
|
||||
return combo
|
||||
|
||||
btn = QToolButton(self)
|
||||
btn.setText(self._enum_text(meta_enum, current))
|
||||
btn.setPopupMode(QToolButton.InstantPopup)
|
||||
menu = QMenu(btn)
|
||||
actions = []
|
||||
for i in range(meta_enum.keyCount()):
|
||||
key = meta_enum.key(i)
|
||||
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
|
||||
act = menu.addAction(key)
|
||||
act.setCheckable(True)
|
||||
act.setChecked(bool(current & meta_enum.value(i)))
|
||||
actions.append(act)
|
||||
btn.setMenu(menu)
|
||||
|
||||
def apply_flags():
|
||||
flags = 0
|
||||
for i, act in enumerate(actions):
|
||||
if act.isChecked():
|
||||
flags |= meta_enum.value(i)
|
||||
self._target.setProperty(name, int(flags))
|
||||
btn.setText(self._enum_text(meta_enum, flags))
|
||||
|
||||
menu.triggered.connect(lambda _a: apply_flags())
|
||||
return btn
|
||||
|
||||
def _make_editor(self, name: str, value, prop):
|
||||
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
|
||||
|
||||
if prop.isEnumType():
|
||||
return self._make_enum_editor(name, value, prop)
|
||||
if isinstance(value, QColor):
|
||||
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
|
||||
if isinstance(value, QFont):
|
||||
return self._make_font_editor(name, value)
|
||||
if isinstance(value, QPalette):
|
||||
return self._make_palette_editor(name, value)
|
||||
if isinstance(value, QCursor):
|
||||
return self._make_cursor_editor(name, value)
|
||||
if isinstance(value, QSizePolicy):
|
||||
ed = self._make_sizepolicy_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QLocale):
|
||||
ed = self._make_locale_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QIcon):
|
||||
ed = self._make_icon_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QSize):
|
||||
wrap, w, h = self._spin_pair(ints=True)
|
||||
w.setValue(value.width())
|
||||
h.setValue(value.height())
|
||||
w.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
|
||||
)
|
||||
h.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QSizeF):
|
||||
wrap, w, h = self._spin_pair(ints=False)
|
||||
w.setValue(value.width())
|
||||
h.setValue(value.height())
|
||||
w.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
|
||||
)
|
||||
h.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QPoint):
|
||||
wrap, x, y = self._spin_pair(ints=True)
|
||||
x.setValue(value.x())
|
||||
y.setValue(value.y())
|
||||
x.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
|
||||
)
|
||||
y.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QPointF):
|
||||
wrap, x, y = self._spin_pair(ints=False)
|
||||
x.setValue(value.x())
|
||||
y.setValue(value.y())
|
||||
x.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
|
||||
)
|
||||
y.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QRect):
|
||||
wrap, boxes = self._spin_quad(ints=True)
|
||||
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
|
||||
b.setValue(v)
|
||||
|
||||
def apply_rect():
|
||||
self._target.setProperty(
|
||||
name,
|
||||
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
|
||||
)
|
||||
|
||||
for b in boxes:
|
||||
b.valueChanged.connect(lambda _=None: apply_rect())
|
||||
return wrap
|
||||
if isinstance(value, QRectF):
|
||||
wrap, boxes = self._spin_quad(ints=False)
|
||||
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
|
||||
b.setValue(v)
|
||||
|
||||
def apply_rectf():
|
||||
self._target.setProperty(
|
||||
name,
|
||||
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
|
||||
)
|
||||
|
||||
for b in boxes:
|
||||
b.valueChanged.connect(lambda _=None: apply_rectf())
|
||||
return wrap
|
||||
if isinstance(value, bool):
|
||||
w = QCheckBox(self)
|
||||
w.setChecked(bool(value))
|
||||
w.toggled.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
w = QSpinBox(self)
|
||||
w.setRange(-10_000_000, 10_000_000)
|
||||
w.setValue(int(value))
|
||||
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, float):
|
||||
w = QDoubleSpinBox(self)
|
||||
w.setDecimals(6)
|
||||
w.setRange(-1e12, 1e12)
|
||||
w.setSingleStep(0.1)
|
||||
w.setValue(float(value))
|
||||
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, str):
|
||||
w = QLineEdit(self)
|
||||
w.setText(value)
|
||||
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
class DemoApp(QWidget): # pragma: no cover:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
|
||||
# Create a BECWidget instance example
|
||||
waveform = self.create_waveform()
|
||||
|
||||
# property editor for the BECWidget
|
||||
property_editor = PropertyEditor(waveform, show_only_bec=True)
|
||||
|
||||
layout.addWidget(waveform)
|
||||
layout.addWidget(property_editor)
|
||||
|
||||
def create_waveform(self):
|
||||
"""Create a new waveform widget."""
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
waveform = Waveform(parent=self)
|
||||
waveform.title = "New Waveform"
|
||||
waveform.x_label = "X Axis"
|
||||
waveform.y_label = "Y Axis"
|
||||
return waveform
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover:
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
demo = DemoApp()
|
||||
demo.setWindowTitle("Property Editor Demo")
|
||||
demo.resize(1200, 800)
|
||||
demo.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -13,3 +13,17 @@ def register_rpc_methods(cls):
|
||||
if getattr(method, "rpc_public", False):
|
||||
cls.USER_ACCESS.add(name)
|
||||
return cls
|
||||
|
||||
|
||||
def rpc_timeout(timeout: float | None):
|
||||
"""
|
||||
Decorator to set a timeout for RPC methods.
|
||||
The actual implementation of timeout handling is within the cli module. This decorator
|
||||
is solely to inform the generate-cli command about the timeout value.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
func.__rpc_timeout__ = timeout # Store the timeout value in the function
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import time
|
||||
import traceback
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
@@ -10,7 +11,7 @@ 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 lazy_import
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
@@ -18,8 +19,9 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib import messages
|
||||
from qtpy.QtCore import QObject
|
||||
else:
|
||||
@@ -83,6 +85,7 @@ class RPCServer:
|
||||
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
|
||||
self._heartbeat_timer.start(200)
|
||||
self._registry_update_callbacks = []
|
||||
self._broadcasted_data = {}
|
||||
|
||||
self.status = messages.BECStatus.RUNNING
|
||||
logger.success(f"Server started with gui_id: {self.gui_id}")
|
||||
@@ -126,16 +129,44 @@ class RPCServer:
|
||||
# Run with rpc registry broadcast, but only once
|
||||
with RPCRegister.delayed_broadcast():
|
||||
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
if method == "raise" and hasattr(
|
||||
obj, "setWindowState"
|
||||
): # special case for raising windows, should work even if minimized
|
||||
# this is a special case for raising windows for gnome on rethat 9 systems where changing focus is supressed by default
|
||||
# The procedure is as follows:
|
||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||
# and call raise_() and activateWindow()
|
||||
# This forces gnome to raise the window even if focus stealing is prevented
|
||||
# 3. Flag for stay on top is removed again to restore the original window state
|
||||
# 4. Finally, we call show() to ensure the window is visible
|
||||
|
||||
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
|
||||
target_state = state | Qt.WindowActive
|
||||
if state & Qt.WindowMinimized:
|
||||
target_state &= ~Qt.WindowMinimized
|
||||
obj.setWindowState(target_state)
|
||||
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
|
||||
obj.showNormal()
|
||||
if hasattr(obj, "raise_"):
|
||||
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
|
||||
obj.raise_()
|
||||
if hasattr(obj, "activateWindow"):
|
||||
obj.activateWindow()
|
||||
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
|
||||
obj.show()
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
method_obj = getattr(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
if not args:
|
||||
res = method_obj
|
||||
else:
|
||||
setattr(obj, method, args[0])
|
||||
res = None
|
||||
else:
|
||||
res = method_obj(*args, **kwargs)
|
||||
|
||||
if isinstance(res, list):
|
||||
res = [self.serialize_object(obj) for obj in res]
|
||||
@@ -189,8 +220,11 @@ class RPCServer:
|
||||
if not getattr(val, "RPC", True):
|
||||
continue
|
||||
data[key] = self._serialize_bec_connector(val)
|
||||
if self._broadcasted_data == data:
|
||||
return
|
||||
self._broadcasted_data = data
|
||||
|
||||
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
@@ -212,9 +246,20 @@ class RPCServer:
|
||||
config_dict = connector.config.model_dump()
|
||||
config_dict["parent_id"] = getattr(connector, "parent_id", None)
|
||||
|
||||
try:
|
||||
parent = connector.parent()
|
||||
if isinstance(parent, BECMainWindow):
|
||||
container_proxy = parent.gui_id
|
||||
else:
|
||||
container_proxy = None
|
||||
except Exception:
|
||||
container_proxy = None
|
||||
|
||||
if wait:
|
||||
while not self.rpc_register.object_is_registered(connector):
|
||||
QApplication.processEvents()
|
||||
logger.info(f"Waiting for {connector} to be registered...")
|
||||
time.sleep(0.1)
|
||||
|
||||
widget_class = getattr(connector, "rpc_widget_class", None)
|
||||
if not widget_class:
|
||||
@@ -225,6 +270,7 @@ class RPCServer:
|
||||
"object_name": connector.object_name or connector.__class__.__name__,
|
||||
"widget_class": widget_class,
|
||||
"config": config_dict,
|
||||
"container_proxy": container_proxy,
|
||||
"__rpc__": True,
|
||||
}
|
||||
|
||||
|
||||
25
bec_widgets/utils/serialization.py
Normal file
25
bec_widgets/utils/serialization.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from bec_lib.codecs import BECCodec
|
||||
from bec_lib.serialization import msgpack
|
||||
from qtpy.QtCore import QPointF
|
||||
|
||||
|
||||
class QPointFEncoder(BECCodec):
|
||||
obj_type = QPointF
|
||||
|
||||
@staticmethod
|
||||
def encode(obj: QPointF) -> list[float]:
|
||||
"""Encode a QPointF object to a list of floats."""
|
||||
return [obj.x(), obj.y()]
|
||||
|
||||
@staticmethod
|
||||
def decode(type_name: str, data: list[float]) -> list[float]:
|
||||
"""No-op function since QPointF is encoded as a list of floats."""
|
||||
return data
|
||||
|
||||
|
||||
def register_serializer_extension():
|
||||
"""
|
||||
Register the serializer extension for the BECConnector.
|
||||
"""
|
||||
if not msgpack.is_registered(QPointF):
|
||||
msgpack.register(QPointF, QPointFEncoder.encode, QPointFEncoder.decode)
|
||||
@@ -1,5 +1,5 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from qtpy.QtGui import QCloseEvent
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
@@ -16,7 +16,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction, ModularToolBar
|
||||
|
||||
|
||||
class SidePanel(QWidget):
|
||||
@@ -31,6 +32,7 @@ class SidePanel(QWidget):
|
||||
panel_max_width: int = 200,
|
||||
animation_duration: int = 200,
|
||||
animations_enabled: bool = True,
|
||||
show_toolbar: bool = True,
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
@@ -40,6 +42,7 @@ class SidePanel(QWidget):
|
||||
self._panel_max_width = panel_max_width
|
||||
self._animation_duration = animation_duration
|
||||
self._animations_enabled = animations_enabled
|
||||
self._show_toolbar = show_toolbar
|
||||
|
||||
self._panel_width = 0
|
||||
self._panel_height = 0
|
||||
@@ -59,7 +62,7 @@ class SidePanel(QWidget):
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(target_widget=self, orientation="vertical")
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="vertical")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
@@ -71,13 +74,14 @@ class SidePanel(QWidget):
|
||||
self.stack_widget.setMinimumWidth(5)
|
||||
self.stack_widget.setMaximumWidth(self._panel_max_width)
|
||||
|
||||
if self._orientation == "left":
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
self.main_layout.addWidget(self.container)
|
||||
else:
|
||||
self.main_layout.addWidget(self.container)
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
if self._orientation in ("left", "right"):
|
||||
if self._show_toolbar:
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
|
||||
if self._orientation == "left":
|
||||
self.main_layout.addWidget(self.container)
|
||||
else:
|
||||
self.main_layout.insertWidget(0, self.container)
|
||||
self.container.layout.addWidget(self.stack_widget)
|
||||
|
||||
self.menu_anim = QPropertyAnimation(self, b"panel_width")
|
||||
@@ -89,7 +93,7 @@ class SidePanel(QWidget):
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
|
||||
self.container = QWidget()
|
||||
self.container.layout = QVBoxLayout(self.container)
|
||||
@@ -102,11 +106,13 @@ class SidePanel(QWidget):
|
||||
self.stack_widget.setMaximumHeight(self._panel_max_width)
|
||||
|
||||
if self._orientation == "top":
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
if self._show_toolbar:
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
self.main_layout.addWidget(self.container)
|
||||
else:
|
||||
self.main_layout.addWidget(self.container)
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
if self._show_toolbar:
|
||||
self.main_layout.addWidget(self.toolbar)
|
||||
|
||||
self.container.layout.addWidget(self.stack_widget)
|
||||
|
||||
@@ -233,21 +239,24 @@ class SidePanel(QWidget):
|
||||
|
||||
def add_menu(
|
||||
self,
|
||||
action_id: str,
|
||||
icon_name: str,
|
||||
tooltip: str,
|
||||
widget: QWidget,
|
||||
action_id: str | None = None,
|
||||
icon_name: str | None = None,
|
||||
tooltip: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
) -> int:
|
||||
"""
|
||||
Add a menu to the side panel.
|
||||
|
||||
Args:
|
||||
action_id(str): The ID of the action.
|
||||
icon_name(str): The name of the icon.
|
||||
tooltip(str): The tooltip for the action.
|
||||
widget(QWidget): The widget to add to the panel.
|
||||
title(str): The title of the panel.
|
||||
action_id(str | None): The ID of the action. Optional if no toolbar action is needed.
|
||||
icon_name(str | None): The name of the icon. Optional if no toolbar action is needed.
|
||||
tooltip(str | None): The tooltip for the action. Optional if no toolbar action is needed.
|
||||
title(str | None): The title of the panel.
|
||||
|
||||
Returns:
|
||||
int: The index of the added panel, which can be used with show_panel() and switch_to().
|
||||
"""
|
||||
# container_widget: top-level container for the stacked page
|
||||
container_widget = QWidget()
|
||||
@@ -278,32 +287,43 @@ class SidePanel(QWidget):
|
||||
index = self.stack_widget.count()
|
||||
self.stack_widget.addWidget(container_widget)
|
||||
|
||||
# Add an action to the toolbar
|
||||
action = MaterialIconAction(icon_name=icon_name, tooltip=tooltip, checkable=True)
|
||||
self.toolbar.add_action(action_id, action, target_widget=self)
|
||||
# Add an action to the toolbar if action_id, icon_name, and tooltip are provided
|
||||
if action_id is not None and icon_name is not None and tooltip is not None:
|
||||
action = MaterialIconAction(
|
||||
icon_name=icon_name, tooltip=tooltip, checkable=True, parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe(action_id, action)
|
||||
bundle = ToolbarBundle(action_id, self.toolbar.components)
|
||||
bundle.add_action(action_id)
|
||||
self.toolbar.add_bundle(bundle)
|
||||
shown_bundles = self.toolbar.shown_bundles
|
||||
shown_bundles.append(action_id)
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
|
||||
def on_action_toggled(checked: bool):
|
||||
if self.switching_actions:
|
||||
return
|
||||
def on_action_toggled(checked: bool):
|
||||
if self.switching_actions:
|
||||
return
|
||||
|
||||
if checked:
|
||||
if self.current_action and self.current_action != action.action:
|
||||
self.switching_actions = True
|
||||
self.current_action.setChecked(False)
|
||||
self.switching_actions = False
|
||||
if checked:
|
||||
if self.current_action and self.current_action != action.action:
|
||||
self.switching_actions = True
|
||||
self.current_action.setChecked(False)
|
||||
self.switching_actions = False
|
||||
|
||||
self.current_action = action.action
|
||||
self.current_action = action.action
|
||||
|
||||
if not self.panel_visible:
|
||||
self.show_panel(index)
|
||||
if not self.panel_visible:
|
||||
self.show_panel(index)
|
||||
else:
|
||||
self.switch_to(index)
|
||||
else:
|
||||
self.switch_to(index)
|
||||
else:
|
||||
if self.current_action == action.action:
|
||||
self.current_action = None
|
||||
self.hide_panel()
|
||||
if self.current_action == action.action:
|
||||
self.current_action = None
|
||||
self.hide_panel()
|
||||
|
||||
action.action.toggled.connect(on_action_toggled)
|
||||
action.action.toggled.connect(on_action_toggled)
|
||||
|
||||
return index
|
||||
|
||||
|
||||
############################################
|
||||
@@ -332,41 +352,56 @@ class ExampleApp(QMainWindow): # pragma: no cover
|
||||
self.add_side_menus()
|
||||
|
||||
def add_side_menus(self):
|
||||
# Example 1: With action, icon, and tooltip
|
||||
widget1 = QWidget()
|
||||
layout1 = QVBoxLayout(widget1)
|
||||
for i in range(15):
|
||||
layout1.addWidget(QLabel(f"Widget 1 label row {i}"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget1,
|
||||
action_id="widget1",
|
||||
icon_name="counter_1",
|
||||
tooltip="Show Widget 1",
|
||||
widget=widget1,
|
||||
title="Widget 1 Panel",
|
||||
)
|
||||
|
||||
# Example 2: With action, icon, and tooltip
|
||||
widget2 = QWidget()
|
||||
layout2 = QVBoxLayout(widget2)
|
||||
layout2.addWidget(QLabel("Short widget 2 content"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget2,
|
||||
action_id="widget2",
|
||||
icon_name="counter_2",
|
||||
tooltip="Show Widget 2",
|
||||
widget=widget2,
|
||||
title="Widget 2 Panel",
|
||||
)
|
||||
|
||||
# Example 3: With action, icon, and tooltip
|
||||
widget3 = QWidget()
|
||||
layout3 = QVBoxLayout(widget3)
|
||||
for i in range(10):
|
||||
layout3.addWidget(QLabel(f"Line {i} for Widget 3"))
|
||||
self.side_panel.add_menu(
|
||||
widget=widget3,
|
||||
action_id="widget3",
|
||||
icon_name="counter_3",
|
||||
tooltip="Show Widget 3",
|
||||
widget=widget3,
|
||||
title="Widget 3 Panel",
|
||||
)
|
||||
|
||||
# Example 4: Without action, icon, and tooltip (can only be shown programmatically)
|
||||
widget4 = QWidget()
|
||||
layout4 = QVBoxLayout(widget4)
|
||||
layout4.addWidget(QLabel("This panel has no toolbar button"))
|
||||
layout4.addWidget(QLabel("It can only be shown programmatically"))
|
||||
self.hidden_panel_index = self.side_panel.add_menu(widget=widget4, title="Hidden Panel")
|
||||
|
||||
# Example of how to show the hidden panel programmatically after 3 seconds
|
||||
from qtpy.QtCore import QTimer
|
||||
|
||||
QTimer.singleShot(3000, lambda: self.side_panel.show_panel(self.hidden_panel_index))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
524
bec_widgets/utils/toolbars/actions.py
Normal file
524
bec_widgets/utils/toolbars/actions.py
Normal file
@@ -0,0 +1,524 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Dict, Literal
|
||||
|
||||
from bec_lib.device import ReadoutPriority
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes._icon.material_icons import material_icon
|
||||
from qtpy.QtCore import QSize, Qt, QTimer
|
||||
from qtpy.QtGui import QAction, QColor, QIcon
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QSizePolicy,
|
||||
QStyledItemDelegate,
|
||||
QToolBar,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class NoCheckDelegate(QStyledItemDelegate):
|
||||
"""To reduce space in combo boxes by removing the checkmark."""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
# Remove any check indicator
|
||||
option.checkState = Qt.Unchecked
|
||||
|
||||
|
||||
class LongPressToolButton(QToolButton):
|
||||
def __init__(self, *args, long_press_threshold=500, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.long_press_threshold = long_press_threshold
|
||||
self._long_press_timer = QTimer(self)
|
||||
self._long_press_timer.setSingleShot(True)
|
||||
self._long_press_timer.timeout.connect(self.handleLongPress)
|
||||
self._pressed = False
|
||||
self._longPressed = False
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self._pressed = True
|
||||
self._longPressed = False
|
||||
self._long_press_timer.start(self.long_press_threshold)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._pressed = False
|
||||
if self._longPressed:
|
||||
self._longPressed = False
|
||||
self._long_press_timer.stop()
|
||||
event.accept() # Prevent normal click action after a long press
|
||||
return
|
||||
self._long_press_timer.stop()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def handleLongPress(self):
|
||||
if self._pressed:
|
||||
self._longPressed = True
|
||||
self.showMenu()
|
||||
|
||||
|
||||
class ToolBarAction(ABC):
|
||||
"""
|
||||
Abstract base class for toolbar actions.
|
||||
|
||||
Args:
|
||||
icon_path (str, optional): The name of the icon file from `assets/toolbar_icons`. Defaults to None.
|
||||
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False):
|
||||
self.icon_path = (
|
||||
os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None
|
||||
)
|
||||
self.tooltip = tooltip
|
||||
self.checkable = checkable
|
||||
self.action = None
|
||||
|
||||
@abstractmethod
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""Adds an action or widget to a toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the action or widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleans up the action, if necessary."""
|
||||
pass
|
||||
|
||||
|
||||
class SeparatorAction(ToolBarAction):
|
||||
"""Separator action for the toolbar."""
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
toolbar.addSeparator()
|
||||
|
||||
|
||||
class QtIconAction(ToolBarAction):
|
||||
def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.standard_icon = standard_icon
|
||||
self.icon = QApplication.style().standardIcon(standard_icon)
|
||||
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
return self.icon
|
||||
|
||||
|
||||
class MaterialIconAction(ToolBarAction):
|
||||
"""
|
||||
Action with a Material icon for the toolbar.
|
||||
|
||||
Args:
|
||||
icon_name (str, optional): The name of the Material icon. Defaults to None.
|
||||
tooltip (str, optional): The tooltip for the action. Defaults to None.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to False.
|
||||
filled (bool, optional): Whether the icon is filled. Defaults to False.
|
||||
color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon.
|
||||
Defaults to None.
|
||||
parent (QWidget or None, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
icon_name: str = None,
|
||||
tooltip: str = None,
|
||||
checkable: bool = False,
|
||||
filled: bool = False,
|
||||
color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.icon_name = icon_name
|
||||
self.filled = filled
|
||||
self.color = color
|
||||
# Generate the icon using the material_icon helper
|
||||
self.icon = material_icon(
|
||||
self.icon_name,
|
||||
size=(20, 20),
|
||||
convert_to_pixmap=False,
|
||||
filled=self.filled,
|
||||
color=self.color,
|
||||
)
|
||||
if parent is None:
|
||||
logger.warning(
|
||||
"MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues."
|
||||
)
|
||||
self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent)
|
||||
self.action.setCheckable(self.checkable)
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar(QToolBar): The toolbar to add the action to.
|
||||
target(QWidget): The target widget for the action.
|
||||
"""
|
||||
toolbar.addAction(self.action)
|
||||
|
||||
def get_icon(self):
|
||||
"""
|
||||
Returns the icon for the action.
|
||||
|
||||
Returns:
|
||||
QIcon: The icon for the action.
|
||||
"""
|
||||
return self.icon
|
||||
|
||||
|
||||
class DeviceSelectionAction(ToolBarAction):
|
||||
"""
|
||||
Action for selecting a device in a combobox.
|
||||
|
||||
Args:
|
||||
label (str): The label for the combobox.
|
||||
device_combobox (DeviceComboBox): The combobox for selecting the device.
|
||||
"""
|
||||
|
||||
def __init__(self, label: str | None = None, device_combobox=None):
|
||||
super().__init__()
|
||||
self.label = label
|
||||
self.device_combobox = device_combobox
|
||||
self.device_combobox.currentIndexChanged.connect(lambda: self.set_combobox_style("#ffa700"))
|
||||
|
||||
def add_to_toolbar(self, toolbar, target):
|
||||
widget = QWidget(parent=target)
|
||||
layout = QHBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
if self.label is not None:
|
||||
label = QLabel(text=f"{self.label}", parent=target)
|
||||
layout.addWidget(label)
|
||||
if self.device_combobox is not None:
|
||||
layout.addWidget(self.device_combobox)
|
||||
toolbar.addWidget(widget)
|
||||
|
||||
def set_combobox_style(self, color: str):
|
||||
self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}")
|
||||
|
||||
|
||||
class SwitchableToolBarAction(ToolBarAction):
|
||||
"""
|
||||
A split toolbar action that combines a main action and a drop-down menu for additional actions.
|
||||
|
||||
The main button displays the currently selected action's icon and tooltip. Clicking on the main button
|
||||
triggers that action. Clicking on the drop-down arrow displays a menu with alternative actions. When an
|
||||
alternative action is selected, it becomes the new default and its callback is immediately executed.
|
||||
|
||||
This design mimics the behavior seen in Adobe Photoshop or Affinity Designer toolbars.
|
||||
|
||||
Args:
|
||||
actions (dict): A dictionary mapping a unique key to a ToolBarAction instance.
|
||||
initial_action (str, optional): The key of the initial default action. If not provided, the first action is used.
|
||||
tooltip (str, optional): An optional tooltip for the split action; if provided, it overrides the default action's tooltip.
|
||||
checkable (bool, optional): Whether the action is checkable. Defaults to True.
|
||||
parent (QWidget, optional): Parent widget for the underlying QAction.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
actions: Dict[str, ToolBarAction],
|
||||
initial_action: str = None,
|
||||
tooltip: str = None,
|
||||
checkable: bool = True,
|
||||
default_state_checked: bool = False,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable)
|
||||
self.actions = actions
|
||||
self.current_key = initial_action if initial_action is not None else next(iter(actions))
|
||||
self.parent = parent
|
||||
self.checkable = checkable
|
||||
self.default_state_checked = default_state_checked
|
||||
self.main_button = None
|
||||
self.menu_actions: Dict[str, QAction] = {}
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the split action to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the action to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
self.main_button = LongPressToolButton(toolbar)
|
||||
self.main_button.setPopupMode(QToolButton.MenuButtonPopup)
|
||||
self.main_button.setCheckable(self.checkable)
|
||||
default_action = self.actions[self.current_key]
|
||||
self.main_button.setIcon(default_action.get_icon())
|
||||
self.main_button.setToolTip(default_action.tooltip)
|
||||
self.main_button.clicked.connect(self._trigger_current_action)
|
||||
menu = QMenu(self.main_button)
|
||||
for key, action_obj in self.actions.items():
|
||||
menu_action = QAction(
|
||||
icon=action_obj.get_icon(), text=action_obj.tooltip, parent=self.main_button
|
||||
)
|
||||
menu_action.setIconVisibleInMenu(True)
|
||||
menu_action.setCheckable(self.checkable)
|
||||
menu_action.setChecked(key == self.current_key)
|
||||
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
|
||||
menu.addAction(menu_action)
|
||||
self.main_button.setMenu(menu)
|
||||
if self.default_state_checked:
|
||||
self.main_button.setChecked(True)
|
||||
self.action = toolbar.addWidget(self.main_button)
|
||||
|
||||
def _trigger_current_action(self):
|
||||
"""
|
||||
Triggers the current action associated with the main button.
|
||||
"""
|
||||
action_obj = self.actions[self.current_key]
|
||||
action_obj.action.trigger()
|
||||
|
||||
def set_default_action(self, key: str):
|
||||
"""
|
||||
Sets the default action for the split action.
|
||||
|
||||
Args:
|
||||
key(str): The key of the action to set as default.
|
||||
"""
|
||||
if self.main_button is None:
|
||||
return
|
||||
self.current_key = key
|
||||
new_action = self.actions[self.current_key]
|
||||
self.main_button.setIcon(new_action.get_icon())
|
||||
self.main_button.setToolTip(new_action.tooltip)
|
||||
# Update check state of menu items
|
||||
for k, menu_act in self.actions.items():
|
||||
menu_act.action.setChecked(False)
|
||||
new_action.action.trigger()
|
||||
# Active action chosen from menu is always checked, uncheck through main button
|
||||
if self.checkable:
|
||||
new_action.action.setChecked(True)
|
||||
self.main_button.setChecked(True)
|
||||
|
||||
def block_all_signals(self, block: bool = True):
|
||||
"""
|
||||
Blocks or unblocks all signals for the actions in the toolbar.
|
||||
|
||||
Args:
|
||||
block (bool): Whether to block signals. Defaults to True.
|
||||
"""
|
||||
if self.main_button is not None:
|
||||
self.main_button.blockSignals(block)
|
||||
|
||||
for action in self.actions.values():
|
||||
action.action.blockSignals(block)
|
||||
|
||||
@contextmanager
|
||||
def signal_blocker(self):
|
||||
"""
|
||||
Context manager to block signals for all actions in the toolbar.
|
||||
"""
|
||||
self.block_all_signals(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.block_all_signals(False)
|
||||
|
||||
def set_state_all(self, state: bool):
|
||||
"""
|
||||
Uncheck all actions in the toolbar.
|
||||
"""
|
||||
for action in self.actions.values():
|
||||
action.action.setChecked(state)
|
||||
if self.main_button is None:
|
||||
return
|
||||
self.main_button.setChecked(state)
|
||||
|
||||
def get_icon(self) -> QIcon:
|
||||
return self.actions[self.current_key].get_icon()
|
||||
|
||||
|
||||
class WidgetAction(ToolBarAction):
|
||||
"""
|
||||
Action for adding any widget to the toolbar.
|
||||
Please note that the injected widget must be life-cycled by the parent widget,
|
||||
i.e., the widget must be properly cleaned up outside of this action. The WidgetAction
|
||||
will not perform any cleanup on the widget itself, only on the container that holds it.
|
||||
|
||||
Args:
|
||||
label (str|None): The label for the widget.
|
||||
widget (QWidget): The widget to be added to the toolbar.
|
||||
adjust_size (bool): Whether to adjust the size of the widget based on its contents. Defaults to True.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
label: str | None = None,
|
||||
widget: QWidget = None,
|
||||
adjust_size: bool = True,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(icon_path=None, tooltip=label, checkable=False)
|
||||
self.label = label
|
||||
self.widget = widget
|
||||
self.container = None
|
||||
self.adjust_size = adjust_size
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
"""
|
||||
Adds the widget to the toolbar.
|
||||
|
||||
Args:
|
||||
toolbar (QToolBar): The toolbar to add the widget to.
|
||||
target (QWidget): The target widget for the action.
|
||||
"""
|
||||
self.container = QWidget(parent=target)
|
||||
layout = QHBoxLayout(self.container)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
if self.label is not None:
|
||||
label_widget = QLabel(text=f"{self.label}", parent=target)
|
||||
label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
|
||||
layout.addWidget(label_widget)
|
||||
|
||||
if isinstance(self.widget, QComboBox) and self.adjust_size:
|
||||
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
|
||||
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.widget.setSizePolicy(size_policy)
|
||||
|
||||
self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget))
|
||||
|
||||
layout.addWidget(self.widget)
|
||||
|
||||
toolbar.addWidget(self.container)
|
||||
# Store the container as the action to allow toggling visibility.
|
||||
self.action = self.container
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the action by closing and deleting the container widget.
|
||||
This method will be called automatically when the toolbar is cleaned up.
|
||||
"""
|
||||
if self.container is not None:
|
||||
self.container.close()
|
||||
self.container.deleteLater()
|
||||
return super().cleanup()
|
||||
|
||||
@staticmethod
|
||||
def calculate_minimum_width(combo_box: QComboBox) -> int:
|
||||
font_metrics = combo_box.fontMetrics()
|
||||
max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count()))
|
||||
return max_width + 60
|
||||
|
||||
|
||||
class ExpandableMenuAction(ToolBarAction):
|
||||
"""
|
||||
Action for an expandable menu in the toolbar.
|
||||
|
||||
Args:
|
||||
label (str): The label for the menu.
|
||||
actions (dict): A dictionary of actions to populate the menu.
|
||||
icon_path (str, optional): The path to the icon file. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, label: str, actions: dict, icon_path: str = None):
|
||||
super().__init__(icon_path, label)
|
||||
self.actions = actions
|
||||
|
||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||
button = QToolButton(toolbar)
|
||||
if self.icon_path:
|
||||
button.setIcon(QIcon(self.icon_path))
|
||||
button.setText(self.tooltip)
|
||||
button.setPopupMode(QToolButton.InstantPopup)
|
||||
button.setStyleSheet(
|
||||
"""
|
||||
QToolButton {
|
||||
font-size: 14px;
|
||||
}
|
||||
QMenu {
|
||||
font-size: 14px;
|
||||
}
|
||||
"""
|
||||
)
|
||||
menu = QMenu(button)
|
||||
for action_container in self.actions.values():
|
||||
action: QAction = action_container.action
|
||||
action.setIconVisibleInMenu(True)
|
||||
if action_container.icon_path:
|
||||
icon = QIcon()
|
||||
icon.addFile(action_container.icon_path, size=QSize(20, 20))
|
||||
action.setIcon(icon)
|
||||
elif hasattr(action, "get_icon") and callable(action_container.get_icon):
|
||||
sub_icon = action_container.get_icon()
|
||||
if sub_icon and not sub_icon.isNull():
|
||||
action.setIcon(sub_icon)
|
||||
action.setCheckable(action_container.checkable)
|
||||
menu.addAction(action)
|
||||
button.setMenu(menu)
|
||||
toolbar.addWidget(button)
|
||||
|
||||
|
||||
class DeviceComboBoxAction(WidgetAction):
|
||||
"""
|
||||
Action for a device selection combobox in the toolbar.
|
||||
|
||||
Args:
|
||||
label (str): The label for the combobox.
|
||||
device_combobox (QComboBox): The combobox for selecting the device.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_widget: QWidget,
|
||||
device_filter: list[BECDeviceFilter] | None = None,
|
||||
readout_priority_filter: (
|
||||
str | ReadoutPriority | list[str] | list[ReadoutPriority] | None
|
||||
) = None,
|
||||
tooltip: str | None = None,
|
||||
add_empty_item: bool = False,
|
||||
no_check_delegate: bool = False,
|
||||
):
|
||||
self.combobox = DeviceComboBox(
|
||||
parent=target_widget,
|
||||
device_filter=device_filter,
|
||||
readout_priority_filter=readout_priority_filter,
|
||||
)
|
||||
super().__init__(widget=self.combobox, adjust_size=False)
|
||||
|
||||
if add_empty_item:
|
||||
self.combobox.addItem("", None)
|
||||
self.combobox.setCurrentText("")
|
||||
if tooltip is not None:
|
||||
self.combobox.setToolTip(tooltip)
|
||||
if no_check_delegate:
|
||||
self.combobox.setItemDelegate(NoCheckDelegate(self.combobox))
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the action by closing and deleting the combobox widget.
|
||||
This method will be called automatically when the toolbar is cleaned up.
|
||||
"""
|
||||
if self.combobox is not None:
|
||||
self.combobox.close()
|
||||
self.combobox.deleteLater()
|
||||
return super().cleanup()
|
||||
244
bec_widgets/utils/toolbars/bundles.py
Normal file
244
bec_widgets/utils/toolbars/bundles.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, DefaultDict
|
||||
from weakref import ReferenceType
|
||||
|
||||
import louie
|
||||
from bec_lib.logger import bec_logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bec_widgets.utils.toolbars.actions import SeparatorAction, ToolBarAction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ActionInfo(BaseModel):
|
||||
action: ToolBarAction
|
||||
toolbar_bundle: ToolbarBundle | None = None
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
|
||||
class ToolbarComponents:
|
||||
def __init__(self, toolbar: ModularToolBar):
|
||||
"""
|
||||
Initializes the toolbar components.
|
||||
|
||||
Args:
|
||||
toolbar (ModularToolBar): The toolbar to which the components will be added.
|
||||
"""
|
||||
self.toolbar = toolbar
|
||||
|
||||
self._components: dict[str, ActionInfo] = {}
|
||||
self.add("separator", SeparatorAction())
|
||||
|
||||
def add(self, name: str, component: ToolBarAction):
|
||||
"""
|
||||
Adds a component to the toolbar.
|
||||
|
||||
Args:
|
||||
component (ToolBarAction): The component to add.
|
||||
"""
|
||||
if name in self._components:
|
||||
raise ValueError(f"Component with name '{name}' already exists.")
|
||||
self._components[name] = ActionInfo(action=component, toolbar_bundle=None)
|
||||
|
||||
def add_safe(self, name: str, component: ToolBarAction):
|
||||
"""
|
||||
Adds a component to the toolbar, ensuring it does not already exist.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component.
|
||||
component (ToolBarAction): The component to add.
|
||||
"""
|
||||
if self.exists(name):
|
||||
logger.info(f"Component with name '{name}' already exists. Skipping addition.")
|
||||
return
|
||||
self.add(name, component)
|
||||
|
||||
def exists(self, name: str) -> bool:
|
||||
"""
|
||||
Checks if a component exists in the toolbar.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the component exists, False otherwise.
|
||||
"""
|
||||
return name in self._components
|
||||
|
||||
def get_action_reference(self, name: str) -> ReferenceType[ToolBarAction]:
|
||||
"""
|
||||
Retrieves a component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to retrieve.
|
||||
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Component with name '{name}' does not exist.")
|
||||
return louie.saferef.safe_ref(self._components[name].action)
|
||||
|
||||
def get_action(self, name: str) -> ToolBarAction:
|
||||
"""
|
||||
Retrieves a component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to retrieve.
|
||||
|
||||
Returns:
|
||||
ToolBarAction: The action associated with the given name.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(
|
||||
f"Component with name '{name}' does not exist. The following components are available: {list(self._components.keys())}"
|
||||
)
|
||||
return self._components[name].action
|
||||
|
||||
def set_bundle(self, name: str, bundle: ToolbarBundle):
|
||||
"""
|
||||
Sets the bundle for a component.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component.
|
||||
bundle (ToolbarBundle): The bundle to set.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Component with name '{name}' does not exist.")
|
||||
comp = self._components[name]
|
||||
if comp.toolbar_bundle is not None:
|
||||
logger.info(
|
||||
f"Component '{name}' already has a bundle ({comp.toolbar_bundle.name}). Setting it to {bundle.name}."
|
||||
)
|
||||
comp.toolbar_bundle.bundle_actions.pop(name, None)
|
||||
comp.toolbar_bundle = bundle
|
||||
|
||||
def remove_action(self, name: str):
|
||||
"""
|
||||
Removes a component from the toolbar.
|
||||
|
||||
Args:
|
||||
name (str): The name of the component to remove.
|
||||
"""
|
||||
if not self.exists(name):
|
||||
raise KeyError(f"Action with ID '{name}' does not exist.")
|
||||
action_info = self._components.pop(name)
|
||||
if action_info.toolbar_bundle:
|
||||
action_info.toolbar_bundle.bundle_actions.pop(name, None)
|
||||
self.toolbar.refresh()
|
||||
action_info.toolbar_bundle = None
|
||||
if hasattr(action_info.action, "cleanup"):
|
||||
# Call cleanup if the action has a cleanup method
|
||||
action_info.action.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the toolbar components by removing all actions and bundles.
|
||||
"""
|
||||
for action_info in self._components.values():
|
||||
if hasattr(action_info.action, "cleanup"):
|
||||
# Call cleanup if the action has a cleanup method
|
||||
action_info.action.cleanup()
|
||||
self._components.clear()
|
||||
|
||||
|
||||
class ToolbarBundle:
|
||||
def __init__(self, name: str, components: ToolbarComponents):
|
||||
"""
|
||||
Initializes a new bundle component.
|
||||
|
||||
Args:
|
||||
bundle_name (str): Unique identifier for the bundle.
|
||||
"""
|
||||
self.name = name
|
||||
self.components = components
|
||||
self.bundle_actions: DefaultDict[str, ReferenceType[ToolBarAction]] = defaultdict()
|
||||
self._connections: dict[str, BundleConnection] = {}
|
||||
|
||||
def add_action(self, name: str):
|
||||
"""
|
||||
Adds an action to the bundle.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the action.
|
||||
action (ToolBarAction): The action to add.
|
||||
"""
|
||||
if name in self.bundle_actions:
|
||||
raise ValueError(f"Action with name '{name}' already exists in bundle '{self.name}'.")
|
||||
if not self.components.exists(name):
|
||||
raise ValueError(
|
||||
f"Component with name '{name}' does not exist in the toolbar. Please add it first using the `ToolbarComponents.add` method."
|
||||
)
|
||||
self.bundle_actions[name] = self.components.get_action_reference(name)
|
||||
self.components.set_bundle(name, self)
|
||||
|
||||
def remove_action(self, name: str):
|
||||
"""
|
||||
Removes an action from the bundle.
|
||||
|
||||
Args:
|
||||
name (str): The name of the action to remove.
|
||||
"""
|
||||
if name not in self.bundle_actions:
|
||||
raise KeyError(f"Action with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
del self.bundle_actions[name]
|
||||
|
||||
def add_separator(self):
|
||||
"""
|
||||
Adds a separator action to the bundle.
|
||||
"""
|
||||
self.add_action("separator")
|
||||
|
||||
def add_connection(self, name: str, connection):
|
||||
"""
|
||||
Adds a connection to the bundle.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the connection.
|
||||
connection: The connection to add.
|
||||
"""
|
||||
if name in self._connections:
|
||||
raise ValueError(
|
||||
f"Connection with name '{name}' already exists in bundle '{self.name}'."
|
||||
)
|
||||
self._connections[name] = connection
|
||||
|
||||
def remove_connection(self, name: str):
|
||||
"""
|
||||
Removes a connection from the bundle.
|
||||
|
||||
Args:
|
||||
name (str): The name of the connection to remove.
|
||||
"""
|
||||
if name not in self._connections:
|
||||
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
self._connections[name].disconnect()
|
||||
del self._connections[name]
|
||||
|
||||
def get_connection(self, name: str):
|
||||
"""
|
||||
Retrieves a connection by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the connection to retrieve.
|
||||
|
||||
Returns:
|
||||
The connection associated with the given name.
|
||||
"""
|
||||
if name not in self._connections:
|
||||
raise KeyError(f"Connection with name '{name}' does not exist in bundle '{self.name}'.")
|
||||
return self._connections[name]
|
||||
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnects all connections in the bundle.
|
||||
"""
|
||||
for connection in self._connections.values():
|
||||
connection.disconnect()
|
||||
self._connections.clear()
|
||||
23
bec_widgets/utils/toolbars/connections.py
Normal file
23
bec_widgets/utils/toolbars/connections.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
|
||||
class BundleConnection(QObject):
|
||||
bundle_name: str
|
||||
|
||||
@abstractmethod
|
||||
def connect(self):
|
||||
"""
|
||||
Connects the bundle to the target widget or application.
|
||||
This method should be implemented by subclasses to define how the bundle interacts with the target.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self):
|
||||
"""
|
||||
Disconnects the bundle from the target widget or application.
|
||||
This method should be implemented by subclasses to define how to clean up connections.
|
||||
"""
|
||||
64
bec_widgets/utils/toolbars/performance.py
Normal file
64
bec_widgets/utils/toolbars/performance.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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.connections import BundleConnection
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_widgets.utils.toolbars.toolbar import ToolbarComponents
|
||||
|
||||
|
||||
def performance_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a performance toolbar bundle.
|
||||
|
||||
Args:
|
||||
components (ToolbarComponents): The components to be added to the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The performance toolbar bundle.
|
||||
"""
|
||||
components.add_safe(
|
||||
"fps_monitor",
|
||||
MaterialIconAction(
|
||||
icon_name="speed", tooltip="Show FPS Monitor", checkable=True, parent=components.toolbar
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("performance", components)
|
||||
bundle.add_action("fps_monitor")
|
||||
return bundle
|
||||
|
||||
|
||||
class PerformanceConnection(BundleConnection):
|
||||
|
||||
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||
self.bundle_name = "performance"
|
||||
self.components = components
|
||||
self.target_widget = target_widget
|
||||
if not hasattr(self.target_widget, "enable_fps_monitor"):
|
||||
raise AttributeError("Target widget must implement 'enable_fps_monitor'.")
|
||||
super().__init__()
|
||||
self._connected = False
|
||||
|
||||
@SafeSlot(bool)
|
||||
def set_fps_monitor(self, enabled: bool):
|
||||
setattr(self.target_widget, "enable_fps_monitor", enabled)
|
||||
|
||||
def connect(self):
|
||||
self._connected = True
|
||||
# Connect the action to the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.connect(
|
||||
self.set_fps_monitor
|
||||
)
|
||||
|
||||
def disconnect(self):
|
||||
if not self._connected:
|
||||
return
|
||||
# Disconnect the action from the target widget's method
|
||||
self.components.get_action_reference("fps_monitor")().action.toggled.disconnect(
|
||||
self.set_fps_monitor
|
||||
)
|
||||
self._connected = False
|
||||
513
bec_widgets/utils/toolbars/toolbar.py
Normal file
513
bec_widgets/utils/toolbars/toolbar.py
Normal file
@@ -0,0 +1,513 @@
|
||||
# pylint: disable=no-name-in-module
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import DefaultDict, Literal
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QAction, QColor
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_name, set_theme
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# Ensure that icons are shown in menus (especially on macOS)
|
||||
QApplication.setAttribute(Qt.AA_DontShowIconsInMenus, False)
|
||||
|
||||
|
||||
class ModularToolBar(QToolBar):
|
||||
"""Modular toolbar with optional automatic initialization.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget of the toolbar. Defaults to None.
|
||||
actions (dict, optional): A dictionary of action creators to populate the toolbar. Defaults to None.
|
||||
target_widget (QWidget, optional): The widget that the actions will target. Defaults to None.
|
||||
orientation (Literal["horizontal", "vertical"], optional): The initial orientation of the toolbar. Defaults to "horizontal".
|
||||
background_color (str, optional): The background color of the toolbar. Defaults to "rgba(0, 0, 0, 0)".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
orientation: Literal["horizontal", "vertical"] = "horizontal",
|
||||
background_color: str = "rgba(0, 0, 0, 0)",
|
||||
):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
self.background_color = background_color
|
||||
self.set_background_color(self.background_color)
|
||||
|
||||
# Set the initial orientation
|
||||
self.set_orientation(orientation)
|
||||
|
||||
self.components = ToolbarComponents(self)
|
||||
|
||||
# Initialize bundles
|
||||
self.bundles: dict[str, ToolbarBundle] = {}
|
||||
self.shown_bundles: list[str] = []
|
||||
|
||||
#########################
|
||||
# outdated items... remove
|
||||
self.available_widgets: DefaultDict[str, ToolBarAction] = defaultdict()
|
||||
########################
|
||||
|
||||
def new_bundle(self, name: str) -> ToolbarBundle:
|
||||
"""
|
||||
Creates a new bundle component.
|
||||
|
||||
Args:
|
||||
name (str): Unique identifier for the bundle.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The new bundle component.
|
||||
"""
|
||||
if name in self.bundles:
|
||||
raise ValueError(f"Bundle with name '{name}' already exists.")
|
||||
bundle = ToolbarBundle(name=name, components=self.components)
|
||||
self.bundles[name] = bundle
|
||||
return bundle
|
||||
|
||||
def add_bundle(self, bundle: ToolbarBundle):
|
||||
"""
|
||||
Adds a bundle component to the toolbar.
|
||||
|
||||
Args:
|
||||
bundle (ToolbarBundle): The bundle component to add.
|
||||
"""
|
||||
if bundle.name in self.bundles:
|
||||
raise ValueError(f"Bundle with name '{bundle.name}' already exists.")
|
||||
self.bundles[bundle.name] = bundle
|
||||
if not bundle.bundle_actions:
|
||||
logger.warning(f"Bundle '{bundle.name}' has no actions.")
|
||||
|
||||
def remove_bundle(self, name: str):
|
||||
"""
|
||||
Removes a bundle component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the bundle to remove.
|
||||
"""
|
||||
if name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{name}' does not exist.")
|
||||
del self.bundles[name]
|
||||
if name in self.shown_bundles:
|
||||
self.shown_bundles.remove(name)
|
||||
logger.info(f"Bundle '{name}' removed from the toolbar.")
|
||||
|
||||
def get_bundle(self, name: str) -> ToolbarBundle:
|
||||
"""
|
||||
Retrieves a bundle component by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the bundle to retrieve.
|
||||
|
||||
Returns:
|
||||
ToolbarBundle: The bundle component.
|
||||
"""
|
||||
if name not in self.bundles:
|
||||
raise KeyError(
|
||||
f"Bundle with name '{name}' does not exist. Available bundles: {list(self.bundles.keys())}"
|
||||
)
|
||||
return self.bundles[name]
|
||||
|
||||
def show_bundles(self, bundle_names: list[str]):
|
||||
"""
|
||||
Sets the bundles to be shown for the toolbar.
|
||||
|
||||
Args:
|
||||
bundle_names (list[str]): A list of bundle names to show. If a bundle is not in this list, its actions will be hidden.
|
||||
"""
|
||||
self.clear()
|
||||
for requested_bundle in bundle_names:
|
||||
bundle = self.get_bundle(requested_bundle)
|
||||
for bundle_action in bundle.bundle_actions.values():
|
||||
action = bundle_action()
|
||||
if action is None:
|
||||
logger.warning(
|
||||
f"Action for bundle '{requested_bundle}' has been deleted. Skipping."
|
||||
)
|
||||
continue
|
||||
action.add_to_toolbar(self, self.parent())
|
||||
separator = self.components.get_action_reference("separator")()
|
||||
if separator is not None:
|
||||
separator.add_to_toolbar(self, self.parent())
|
||||
self.update_separators() # Ensure separators are updated after showing bundles
|
||||
self.shown_bundles = bundle_names
|
||||
|
||||
def add_action(self, action_name: str, action: ToolBarAction):
|
||||
"""
|
||||
Adds a single action to the toolbar. It will create a new bundle
|
||||
with the same name as the action.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action.
|
||||
action (ToolBarAction): The action to add.
|
||||
"""
|
||||
self.components.add_safe(action_name, action)
|
||||
bundle = ToolbarBundle(name=action_name, components=self.components)
|
||||
bundle.add_action(action_name)
|
||||
self.add_bundle(bundle)
|
||||
|
||||
def hide_action(self, action_name: str):
|
||||
"""
|
||||
Hides a specific action in the toolbar.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action to hide.
|
||||
"""
|
||||
action = self.components.get_action(action_name)
|
||||
if hasattr(action, "action") and action.action is not None:
|
||||
action.action.setVisible(False)
|
||||
self.update_separators()
|
||||
|
||||
def show_action(self, action_name: str):
|
||||
"""
|
||||
Shows a specific action in the toolbar.
|
||||
|
||||
Args:
|
||||
action_name (str): Unique identifier for the action to show.
|
||||
"""
|
||||
action = self.components.get_action(action_name)
|
||||
if hasattr(action, "action") and action.action is not None:
|
||||
action.action.setVisible(True)
|
||||
self.update_separators()
|
||||
|
||||
@property
|
||||
def toolbar_actions(self) -> list[ToolBarAction]:
|
||||
"""
|
||||
Returns a list of all actions currently in the toolbar.
|
||||
|
||||
Returns:
|
||||
list[ToolBarAction]: List of actions in the toolbar.
|
||||
"""
|
||||
actions = []
|
||||
for bundle in self.shown_bundles:
|
||||
if bundle not in self.bundles:
|
||||
continue
|
||||
for action in self.bundles[bundle].bundle_actions.values():
|
||||
action_instance = action()
|
||||
if action_instance is not None:
|
||||
actions.append(action_instance)
|
||||
return actions
|
||||
|
||||
def refresh(self):
|
||||
"""Refreshes the toolbar by clearing and re-populating it."""
|
||||
self.clear()
|
||||
self.show_bundles(self.shown_bundles)
|
||||
|
||||
def connect_bundle(self, connection_name: str, connector: BundleConnection):
|
||||
"""
|
||||
Connects a bundle to a target widget or application.
|
||||
|
||||
Args:
|
||||
bundle_name (str): The name of the bundle to connect.
|
||||
connector (BundleConnection): The connector instance that implements the connection logic.
|
||||
"""
|
||||
bundle_name = connector.bundle_name
|
||||
if bundle_name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
|
||||
connector.connect()
|
||||
self.bundles[bundle_name].add_connection(connection_name, connector)
|
||||
|
||||
def disconnect_bundle(self, bundle_name: str, connection_name: str | None = None):
|
||||
"""
|
||||
Disconnects a bundle connection.
|
||||
|
||||
Args:
|
||||
bundle_name (str): The name of the bundle to disconnect.
|
||||
connection_name (str): The name of the connection to disconnect. If None, disconnects all connections for the bundle.
|
||||
"""
|
||||
if bundle_name not in self.bundles:
|
||||
raise KeyError(f"Bundle with name '{bundle_name}' does not exist.")
|
||||
bundle = self.bundles[bundle_name]
|
||||
if connection_name is None:
|
||||
# Disconnect all connections in the bundle
|
||||
bundle.disconnect()
|
||||
else:
|
||||
bundle.remove_connection(name=connection_name)
|
||||
|
||||
def set_background_color(self, color: str = "rgba(0, 0, 0, 0)"):
|
||||
"""
|
||||
Sets the background color and other appearance settings.
|
||||
|
||||
Args:
|
||||
color (str): The background color of the toolbar.
|
||||
"""
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.setMovable(False)
|
||||
self.setFloatable(False)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self.background_color = color
|
||||
self.setStyleSheet(f"QToolBar {{ background-color: {color}; border: none; }}")
|
||||
|
||||
def set_orientation(self, orientation: Literal["horizontal", "vertical"]):
|
||||
"""Sets the orientation of the toolbar.
|
||||
|
||||
Args:
|
||||
orientation (Literal["horizontal", "vertical"]): The desired orientation of the toolbar.
|
||||
"""
|
||||
if orientation == "horizontal":
|
||||
self.setOrientation(Qt.Horizontal)
|
||||
elif orientation == "vertical":
|
||||
self.setOrientation(Qt.Vertical)
|
||||
else:
|
||||
raise ValueError("Orientation must be 'horizontal' or 'vertical'.")
|
||||
|
||||
def update_material_icon_colors(self, new_color: str | tuple | QColor):
|
||||
"""
|
||||
Updates the color of all MaterialIconAction icons.
|
||||
|
||||
Args:
|
||||
new_color (str | tuple | QColor): The new color.
|
||||
"""
|
||||
for action in self.available_widgets.values():
|
||||
if isinstance(action, MaterialIconAction):
|
||||
action.color = new_color
|
||||
updated_icon = action.get_icon()
|
||||
action.action.setIcon(updated_icon)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
"""
|
||||
Overrides the context menu event to show toolbar actions with checkboxes and icons.
|
||||
|
||||
Args:
|
||||
event (QContextMenuEvent): The context menu event.
|
||||
"""
|
||||
menu = QMenu(self)
|
||||
theme = get_theme_name()
|
||||
if theme == "dark":
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(50, 50, 50, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
"""
|
||||
)
|
||||
else:
|
||||
# Light theme styling
|
||||
menu.setStyleSheet(
|
||||
"""
|
||||
QMenu {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(0, 0, 255, 0.2);
|
||||
}
|
||||
"""
|
||||
)
|
||||
for ii, bundle in enumerate(self.shown_bundles):
|
||||
self.handle_bundle_context_menu(menu, bundle)
|
||||
if ii < len(self.shown_bundles) - 1:
|
||||
menu.addSeparator()
|
||||
menu.triggered.connect(self.handle_menu_triggered)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def handle_bundle_context_menu(self, menu: QMenu, bundle_id: str):
|
||||
"""
|
||||
Adds bundle actions to the context menu.
|
||||
|
||||
Args:
|
||||
menu (QMenu): The context menu.
|
||||
bundle_id (str): The bundle identifier.
|
||||
"""
|
||||
bundle = self.bundles.get(bundle_id)
|
||||
if not bundle:
|
||||
return
|
||||
for act_id in bundle.bundle_actions:
|
||||
toolbar_action = self.components.get_action(act_id)
|
||||
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(
|
||||
toolbar_action, "action"
|
||||
):
|
||||
continue
|
||||
qaction = toolbar_action.action
|
||||
if not isinstance(qaction, QAction):
|
||||
continue
|
||||
self._add_qaction_to_menu(menu, qaction, toolbar_action, act_id)
|
||||
|
||||
def _add_qaction_to_menu(
|
||||
self, menu: QMenu, qaction: QAction, toolbar_action: ToolBarAction, act_id: str
|
||||
):
|
||||
display_name = qaction.text() or toolbar_action.tooltip or act_id
|
||||
menu_action = QAction(display_name, self)
|
||||
menu_action.setCheckable(True)
|
||||
menu_action.setChecked(qaction.isVisible())
|
||||
menu_action.setData(act_id) # Store the action_id
|
||||
|
||||
# Set the icon if available
|
||||
if qaction.icon() and not qaction.icon().isNull():
|
||||
menu_action.setIcon(qaction.icon())
|
||||
menu.addAction(menu_action)
|
||||
|
||||
def handle_action_context_menu(self, menu: QMenu, action_id: str):
|
||||
"""
|
||||
Adds a single toolbar action to the context menu.
|
||||
|
||||
Args:
|
||||
menu (QMenu): The context menu to which the action is added.
|
||||
action_id (str): Unique identifier for the action.
|
||||
"""
|
||||
toolbar_action = self.available_widgets.get(action_id)
|
||||
if not isinstance(toolbar_action, ToolBarAction) or not hasattr(toolbar_action, "action"):
|
||||
return
|
||||
qaction = toolbar_action.action
|
||||
if not isinstance(qaction, QAction):
|
||||
return
|
||||
display_name = qaction.text() or toolbar_action.tooltip or action_id
|
||||
menu_action = QAction(display_name, self)
|
||||
menu_action.setCheckable(True)
|
||||
menu_action.setChecked(qaction.isVisible())
|
||||
menu_action.setIconVisibleInMenu(True)
|
||||
menu_action.setData(action_id) # Store the action_id
|
||||
|
||||
# Set the icon if available
|
||||
if qaction.icon() and not qaction.icon().isNull():
|
||||
menu_action.setIcon(qaction.icon())
|
||||
|
||||
menu.addAction(menu_action)
|
||||
|
||||
def handle_menu_triggered(self, action):
|
||||
"""
|
||||
Handles the triggered signal from the context menu.
|
||||
|
||||
Args:
|
||||
action: Action triggered.
|
||||
"""
|
||||
action_id = action.data()
|
||||
if action_id:
|
||||
self.toggle_action_visibility(action_id)
|
||||
|
||||
def toggle_action_visibility(self, action_id: str, visible: bool | None = None):
|
||||
"""
|
||||
Toggles the visibility of a specific action.
|
||||
|
||||
Args:
|
||||
action_id (str): Unique identifier.
|
||||
visible (bool): Whether the action should be visible. If None, toggles the current visibility.
|
||||
"""
|
||||
if not self.components.exists(action_id):
|
||||
return
|
||||
tool_action = self.components.get_action(action_id)
|
||||
if hasattr(tool_action, "action") and tool_action.action is not None:
|
||||
if visible is None:
|
||||
visible = not tool_action.action.isVisible()
|
||||
tool_action.action.setVisible(visible)
|
||||
self.update_separators()
|
||||
|
||||
def update_separators(self):
|
||||
"""
|
||||
Hide separators that are adjacent to another separator or have no non-separator actions between them.
|
||||
"""
|
||||
toolbar_actions = self.actions()
|
||||
# First pass: set visibility based on surrounding non-separator actions.
|
||||
for i, action in enumerate(toolbar_actions):
|
||||
if not action.isSeparator():
|
||||
continue
|
||||
prev_visible = None
|
||||
for j in range(i - 1, -1, -1):
|
||||
if toolbar_actions[j].isVisible():
|
||||
prev_visible = toolbar_actions[j]
|
||||
break
|
||||
next_visible = None
|
||||
for j in range(i + 1, len(toolbar_actions)):
|
||||
if toolbar_actions[j].isVisible():
|
||||
next_visible = toolbar_actions[j]
|
||||
break
|
||||
if (prev_visible is None or prev_visible.isSeparator()) and (
|
||||
next_visible is None or next_visible.isSeparator()
|
||||
):
|
||||
action.setVisible(False)
|
||||
else:
|
||||
action.setVisible(True)
|
||||
# Second pass: ensure no two visible separators are adjacent.
|
||||
prev = None
|
||||
for action in toolbar_actions:
|
||||
if action.isVisible() and action.isSeparator():
|
||||
if prev and prev.isSeparator():
|
||||
action.setVisible(False)
|
||||
else:
|
||||
prev = action
|
||||
else:
|
||||
if action.isVisible():
|
||||
prev = action
|
||||
|
||||
if not toolbar_actions:
|
||||
return
|
||||
|
||||
# Make sure the first visible action is not a separator
|
||||
for i, action in enumerate(toolbar_actions):
|
||||
if action.isVisible():
|
||||
if action.isSeparator():
|
||||
action.setVisible(False)
|
||||
break
|
||||
|
||||
# Make sure the last visible action is not a separator
|
||||
for i, action in enumerate(reversed(toolbar_actions)):
|
||||
if action.isVisible():
|
||||
if action.isSeparator():
|
||||
action.setVisible(False)
|
||||
break
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleans up the toolbar by removing all actions and bundles.
|
||||
"""
|
||||
# First, disconnect all bundles
|
||||
for bundle_name in list(self.bundles.keys()):
|
||||
self.disconnect_bundle(bundle_name)
|
||||
|
||||
# Clear all components
|
||||
self.components.cleanup()
|
||||
self.bundles.clear()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle
|
||||
from bec_widgets.widgets.plots.toolbar_components.plot_export import plot_export_bundle
|
||||
|
||||
class MainWindow(QMainWindow): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Toolbar / ToolbarBundle Demo")
|
||||
self.central_widget = QWidget()
|
||||
self.setCentralWidget(self.central_widget)
|
||||
self.test_label = QLabel(text="This is a test label.")
|
||||
self.central_widget.layout = QVBoxLayout(self.central_widget)
|
||||
self.central_widget.layout.addWidget(self.test_label)
|
||||
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.addToolBar(self.toolbar)
|
||||
self.toolbar.add_bundle(performance_bundle(self.toolbar.components))
|
||||
self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components))
|
||||
self.toolbar.connect_bundle(
|
||||
"base", PerformanceConnection(self.toolbar.components, self)
|
||||
)
|
||||
self.toolbar.show_bundles(["performance", "plot_export"])
|
||||
self.toolbar.get_bundle("performance").add_action("save")
|
||||
self.toolbar.refresh()
|
||||
|
||||
def enable_fps_monitor(self, enabled: bool):
|
||||
"""
|
||||
Example method to enable or disable FPS monitoring.
|
||||
This method should be implemented in the target widget.
|
||||
"""
|
||||
if enabled:
|
||||
self.test_label.setText("FPS Monitor Enabled")
|
||||
else:
|
||||
self.test_label.setText("FPS Monitor Disabled")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
set_theme("light")
|
||||
main_window = MainWindow()
|
||||
main_window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -2,13 +2,14 @@ from bec_lib.logger import bec_logger
|
||||
from qtpy import PYQT6, PYSIDE6
|
||||
from qtpy.QtCore import QFile, QIODevice
|
||||
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
from qtpy.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict | None = None):
|
||||
@@ -30,9 +31,9 @@ class UILoader:
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
|
||||
widgets = get_custom_classes("bec_widgets").classes
|
||||
|
||||
self.custom_widgets = {widget.__name__: widget for widget in widgets}
|
||||
self.custom_widgets = (
|
||||
get_custom_classes("bec_widgets") + get_all_plugin_widgets()
|
||||
).as_dict()
|
||||
|
||||
if PYSIDE6:
|
||||
self.loader = self.load_ui_pyside6
|
||||
|
||||
@@ -264,6 +264,48 @@ class WidgetIO:
|
||||
return WidgetIO._handlers[base]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_widgets(widget_class: QWidget | str, recursive: bool = True) -> list[QWidget]:
|
||||
"""
|
||||
Return widgets matching the given class (or class-name string).
|
||||
|
||||
Args:
|
||||
widget_class: Either a QWidget subclass or its class-name as a string.
|
||||
recursive: If True (default), traverse all top-level widgets and their children;
|
||||
if False, scan app.allWidgets() for a flat list.
|
||||
|
||||
Returns:
|
||||
List of QWidget instances matching the class or class-name.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
raise RuntimeError("No QApplication instance found.")
|
||||
|
||||
# Match by class-name string
|
||||
if isinstance(widget_class, str):
|
||||
name = widget_class
|
||||
if recursive:
|
||||
result: list[QWidget] = []
|
||||
for top in app.topLevelWidgets():
|
||||
if top.__class__.__name__ == name:
|
||||
result.append(top)
|
||||
result.extend(
|
||||
w for w in top.findChildren(QWidget) if w.__class__.__name__ == name
|
||||
)
|
||||
return result
|
||||
return [w for w in app.allWidgets() if w.__class__.__name__ == name]
|
||||
|
||||
# Match by actual class
|
||||
if recursive:
|
||||
result: list[QWidget] = []
|
||||
for top in app.topLevelWidgets():
|
||||
if isinstance(top, widget_class):
|
||||
result.append(top)
|
||||
result.extend(top.findChildren(widget_class))
|
||||
return result
|
||||
|
||||
return [w for w in app.allWidgets() if isinstance(w, widget_class)]
|
||||
|
||||
|
||||
################## for exporting and importing widget hierarchies ##################
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class AutoUpdates(BECMainWindow):
|
||||
_default_dock: BECDock
|
||||
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
|
||||
RPC = True
|
||||
PLUGIN = False
|
||||
|
||||
# enforce that subclasses have the same rpc widget class
|
||||
rpc_widget_class = "AutoUpdates"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['dock_area.py']}
|
||||
@@ -1,22 +1,19 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
import os
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
import bec_widgets
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECDockArea' name='dock_area'>
|
||||
<widget class='BECDockArea' name='bec_dock_area'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
@@ -24,6 +21,8 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = BECDockArea(parent)
|
||||
return t
|
||||
|
||||
@@ -31,13 +30,13 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Plots"
|
||||
return "BEC Containers"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECDockArea.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "dock_area"
|
||||
return "bec_dock_area"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -52,7 +51,7 @@ class BECDockAreaPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return "BECDockArea"
|
||||
|
||||
def toolTip(self):
|
||||
return "BECDockArea"
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -12,10 +12,11 @@ from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
|
||||
@@ -130,7 +131,6 @@ class BECDock(BECWidget, Dock):
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
parent_dock_area: BECDockArea | None = None,
|
||||
parent_id: str | None = None,
|
||||
config: DockConfig | None = None,
|
||||
name: str | None = None,
|
||||
object_name: str | None = None,
|
||||
@@ -279,6 +279,7 @@ class BECDock(BECWidget, Dock):
|
||||
widgets.extend(dock.elements.keys())
|
||||
return widgets
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def new(
|
||||
self,
|
||||
widget: BECWidget | str,
|
||||
@@ -301,6 +302,9 @@ class BECDock(BECWidget, Dock):
|
||||
colspan(int): The number of columns the widget should span.
|
||||
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
|
||||
"""
|
||||
if name is not None:
|
||||
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
|
||||
|
||||
if row is None:
|
||||
row = self.layout.rowCount()
|
||||
|
||||
@@ -316,11 +320,7 @@ class BECDock(BECWidget, Dock):
|
||||
widget = cast(
|
||||
BECWidget,
|
||||
widget_handler.create_widget(
|
||||
widget_type=widget,
|
||||
object_name=name,
|
||||
parent_dock=self,
|
||||
parent_id=self.gui_id,
|
||||
parent=self,
|
||||
widget_type=widget, object_name=name, parent_dock=self, parent=self
|
||||
),
|
||||
)
|
||||
else:
|
||||
@@ -389,6 +389,7 @@ class BECDock(BECWidget, Dock):
|
||||
if widget in self.widgets:
|
||||
self.widgets.remove(widget)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
|
||||
def delete_all(self):
|
||||
"""
|
||||
@@ -416,6 +417,7 @@ class BECDock(BECWidget, Dock):
|
||||
self.delete_all()
|
||||
self.widgets.clear()
|
||||
super().cleanup()
|
||||
self.deleteLater()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
|
||||
@@ -14,18 +14,21 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbar import (
|
||||
from bec_widgets.utils.name_utils import pascal_to_snake
|
||||
from bec_widgets.utils.toolbars.actions import (
|
||||
ExpandableMenuAction,
|
||||
MaterialIconAction,
|
||||
ModularToolBar,
|
||||
SeparatorAction,
|
||||
WidgetAction,
|
||||
)
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
|
||||
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||
@@ -68,6 +71,7 @@ class BECDockArea(BECWidget, QWidget):
|
||||
"detach_dock",
|
||||
"attach_all",
|
||||
"save_state",
|
||||
"screenshot",
|
||||
"restore_state",
|
||||
]
|
||||
|
||||
@@ -101,148 +105,248 @@ class BECDockArea(BECWidget, QWidget):
|
||||
|
||||
self._instructions_visible = True
|
||||
|
||||
self.dark_mode_button = DarkModeButton(parent=self, parent_id=self.gui_id, toolbar=True)
|
||||
self.dock_area = DockArea()
|
||||
self.toolbar = ModularToolBar(
|
||||
parent=self,
|
||||
actions={
|
||||
"menu_plots": ExpandableMenuAction(
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
|
||||
),
|
||||
"scatter_waveform": MaterialIconAction(
|
||||
icon_name=ScatterWaveform.ICON_NAME,
|
||||
tooltip="Add Scatter Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_0": SeparatorAction(),
|
||||
"menu_devices": ExpandableMenuAction(
|
||||
label="Add Device Control ",
|
||||
actions={
|
||||
"scan_control": MaterialIconAction(
|
||||
icon_name=ScanControl.ICON_NAME, tooltip="Add Scan Control", filled=True
|
||||
),
|
||||
"positioner_box": MaterialIconAction(
|
||||
icon_name=PositionerBox.ICON_NAME, tooltip="Add Device Box", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_1": SeparatorAction(),
|
||||
"menu_utils": ExpandableMenuAction(
|
||||
label="Add Utils ",
|
||||
actions={
|
||||
"queue": MaterialIconAction(
|
||||
icon_name=BECQueue.ICON_NAME, tooltip="Add Scan Queue", filled=True
|
||||
),
|
||||
"vs_code": MaterialIconAction(
|
||||
icon_name=VSCodeEditor.ICON_NAME, tooltip="Add VS Code", filled=True
|
||||
),
|
||||
"status": MaterialIconAction(
|
||||
icon_name=BECStatusBox.ICON_NAME,
|
||||
tooltip="Add BEC Status Box",
|
||||
filled=True,
|
||||
),
|
||||
"progress_bar": MaterialIconAction(
|
||||
icon_name=RingProgressBar.ICON_NAME,
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
),
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME, tooltip="Add LogPanel", filled=True
|
||||
),
|
||||
},
|
||||
),
|
||||
"separator_2": SeparatorAction(),
|
||||
"attach_all": MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks"
|
||||
),
|
||||
"save_state": MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State"),
|
||||
"restore_state": MaterialIconAction(
|
||||
icon_name="frame_reload", tooltip="Restore Dock State"
|
||||
),
|
||||
},
|
||||
target_widget=self,
|
||||
)
|
||||
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||
self.dock_area = DockArea(parent=self)
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self._setup_toolbar()
|
||||
|
||||
self.layout.addWidget(self.toolbar)
|
||||
self.layout.addWidget(self.dock_area)
|
||||
self.spacer = QWidget()
|
||||
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.addWidget(self.spacer)
|
||||
self.toolbar.addWidget(self.dark_mode_button)
|
||||
|
||||
self._hook_toolbar()
|
||||
self.toolbar.show_bundles(
|
||||
["menu_plots", "menu_devices", "menu_utils", "dock_actions", "dark_mode"]
|
||||
)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return QSize(800, 600)
|
||||
|
||||
def _setup_toolbar(self):
|
||||
|
||||
# Add plot menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_plots",
|
||||
ExpandableMenuAction(
|
||||
label="Add Plot ",
|
||||
actions={
|
||||
"waveform": MaterialIconAction(
|
||||
icon_name=Waveform.ICON_NAME,
|
||||
tooltip="Add Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"scatter_waveform": MaterialIconAction(
|
||||
icon_name=ScatterWaveform.ICON_NAME,
|
||||
tooltip="Add Scatter Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"multi_waveform": MaterialIconAction(
|
||||
icon_name=MultiWaveform.ICON_NAME,
|
||||
tooltip="Add Multi Waveform",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"image": MaterialIconAction(
|
||||
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True, parent=self
|
||||
),
|
||||
"motor_map": MaterialIconAction(
|
||||
icon_name=MotorMap.ICON_NAME,
|
||||
tooltip="Add Motor Map",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"heatmap": MaterialIconAction(
|
||||
icon_name=Heatmap.ICON_NAME, tooltip="Add Heatmap", filled=True, parent=self
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("menu_plots", self.toolbar.components)
|
||||
bundle.add_action("menu_plots")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add control menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_devices",
|
||||
ExpandableMenuAction(
|
||||
label="Add Device Control ",
|
||||
actions={
|
||||
"scan_control": MaterialIconAction(
|
||||
icon_name=ScanControl.ICON_NAME,
|
||||
tooltip="Add Scan Control",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"positioner_box": MaterialIconAction(
|
||||
icon_name=PositionerBox.ICON_NAME,
|
||||
tooltip="Add Device Box",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("menu_devices", self.toolbar.components)
|
||||
bundle.add_action("menu_devices")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
# Add utils menu
|
||||
self.toolbar.components.add_safe(
|
||||
"menu_utils",
|
||||
ExpandableMenuAction(
|
||||
label="Add Utils ",
|
||||
actions={
|
||||
"queue": MaterialIconAction(
|
||||
icon_name=BECQueue.ICON_NAME,
|
||||
tooltip="Add Scan Queue",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"vs_code": MaterialIconAction(
|
||||
icon_name=VSCodeEditor.ICON_NAME,
|
||||
tooltip="Add VS Code",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"status": MaterialIconAction(
|
||||
icon_name=BECStatusBox.ICON_NAME,
|
||||
tooltip="Add BEC Status Box",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"progress_bar": MaterialIconAction(
|
||||
icon_name=RingProgressBar.ICON_NAME,
|
||||
tooltip="Add Circular ProgressBar",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
"log_panel": MaterialIconAction(
|
||||
icon_name=LogPanel.ICON_NAME,
|
||||
tooltip="Add LogPanel - Disabled",
|
||||
filled=True,
|
||||
parent=self,
|
||||
),
|
||||
"sbb_monitor": MaterialIconAction(
|
||||
icon_name="train", tooltip="Add SBB Monitor", filled=True, parent=self
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("menu_utils", self.toolbar.components)
|
||||
bundle.add_action("menu_utils")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
########## Dock Actions ##########
|
||||
spacer = QWidget(parent=self)
|
||||
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("dark_mode", self.toolbar.components)
|
||||
bundle.add_action("spacer")
|
||||
bundle.add_action("dark_mode")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"attach_all",
|
||||
MaterialIconAction(
|
||||
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
|
||||
),
|
||||
)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"save_state",
|
||||
MaterialIconAction(icon_name="bookmark", tooltip="Save Dock State", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"restore_state",
|
||||
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"screenshot",
|
||||
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
|
||||
bundle.add_action("attach_all")
|
||||
bundle.add_action("save_state")
|
||||
bundle.add_action("restore_state")
|
||||
bundle.add_action("screenshot")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
def _hook_toolbar(self):
|
||||
# Menu Plot
|
||||
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
|
||||
menu_plots = self.toolbar.components.get_action("menu_plots")
|
||||
menu_devices = self.toolbar.components.get_action("menu_devices")
|
||||
menu_utils = self.toolbar.components.get_action("menu_utils")
|
||||
|
||||
menu_plots.actions["waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
|
||||
|
||||
menu_plots.actions["scatter_waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
|
||||
menu_plots.actions["multi_waveform"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
|
||||
menu_plots.actions["image"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Image")
|
||||
)
|
||||
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
|
||||
menu_plots.actions["motor_map"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
|
||||
)
|
||||
menu_plots.actions["heatmap"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="Heatmap")
|
||||
)
|
||||
|
||||
# Menu Devices
|
||||
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
|
||||
menu_devices.actions["scan_control"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
|
||||
)
|
||||
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
|
||||
menu_devices.actions["positioner_box"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
|
||||
)
|
||||
|
||||
# Menu Utils
|
||||
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
|
||||
menu_utils.actions["queue"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
|
||||
menu_utils.actions["status"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
|
||||
menu_utils.actions["vs_code"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
|
||||
menu_utils.actions["progress_bar"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
|
||||
)
|
||||
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
|
||||
# FIXME temporarily disabled -> issue #644
|
||||
menu_utils.actions["log_panel"].action.setEnabled(False)
|
||||
|
||||
menu_utils.actions["sbb_monitor"].action.triggered.connect(
|
||||
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
|
||||
)
|
||||
|
||||
# Icons
|
||||
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)
|
||||
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
|
||||
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
|
||||
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||
self.toolbar.components.get_action("save_state").action.triggered.connect(self.save_state)
|
||||
self.toolbar.components.get_action("restore_state").action.triggered.connect(
|
||||
self.restore_state
|
||||
)
|
||||
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||
|
||||
@SafeSlot()
|
||||
def _create_widget_from_toolbar(self, widget_name: str) -> None:
|
||||
# Run with RPC broadcast to namespace of all widgets
|
||||
with RPCRegister.delayed_broadcast():
|
||||
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
|
||||
name = pascal_to_snake(widget_name)
|
||||
dock_name = WidgetContainerUtils.generate_unique_name(name, self.panels.keys())
|
||||
self.new(name=dock_name, widget=widget_name)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
|
||||
@@ -306,6 +410,8 @@ class BECDockArea(BECWidget, QWidget):
|
||||
"""
|
||||
if state is None:
|
||||
state = self.config.docks_state
|
||||
if state is None:
|
||||
return
|
||||
self.dock_area.restoreState(state, missing=missing, extra=extra)
|
||||
|
||||
@SafeSlot()
|
||||
@@ -362,6 +468,8 @@ class BECDockArea(BECWidget, QWidget):
|
||||
f"Name {name} must be unique for docks, but already exists in DockArea "
|
||||
f"with name: {self.object_name} and id {self.gui_id}."
|
||||
)
|
||||
WidgetContainerUtils.raise_for_invalid_name(name, container=self)
|
||||
|
||||
else: # Name is not provided
|
||||
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
|
||||
|
||||
@@ -370,7 +478,6 @@ class BECDockArea(BECWidget, QWidget):
|
||||
name=name, # this is dock name pyqtgraph property, this is displayed on label
|
||||
object_name=name, # this is a real qt object name passed to BECConnector
|
||||
parent_dock_area=self,
|
||||
parent_id=self.gui_id,
|
||||
closable=closable,
|
||||
)
|
||||
dock.config.position = position
|
||||
@@ -440,12 +547,8 @@ class BECDockArea(BECWidget, QWidget):
|
||||
Cleanup the dock area.
|
||||
"""
|
||||
self.delete_all()
|
||||
self.toolbar.close()
|
||||
self.toolbar.deleteLater()
|
||||
self.dark_mode_button.close()
|
||||
self.dark_mode_button.deleteLater()
|
||||
self.dock_area.close()
|
||||
self.dock_area.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
def show(self):
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['dock_area.py','dock.py']}
|
||||
@@ -6,7 +6,7 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.containers.dock.dock_area_plugin import BECDockAreaPlugin
|
||||
from bec_widgets.widgets.containers.dock.bec_dock_area_plugin import BECDockAreaPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECDockAreaPlugin())
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, Qt, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
|
||||
class CollapsibleSection(QWidget):
|
||||
"""A widget that combines a header button with any content widget for collapsible sections
|
||||
|
||||
This widget contains a header button with a title and a content widget.
|
||||
The content widget can be any QWidget. The header button can be expanded or collapsed.
|
||||
The header also contains an "Add" button that is only visible when hovering over the section.
|
||||
|
||||
Signals:
|
||||
section_reorder_requested(str, str): Emitted when the section is dragged and dropped
|
||||
onto another section for reordering.
|
||||
Arguments are (source_title, target_title).
|
||||
"""
|
||||
|
||||
section_reorder_requested = Signal(str, str) # (source_title, target_title)
|
||||
|
||||
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
|
||||
super().__init__(parent=parent)
|
||||
self.title = title
|
||||
self.content_widget = None
|
||||
self.setAcceptDrops(True)
|
||||
self._expanded = True
|
||||
|
||||
# Setup layout
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(indentation, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.setContentsMargins(0, 0, 4, 0)
|
||||
header_layout.setSpacing(0)
|
||||
|
||||
# Create header button
|
||||
self.header_button = QPushButton()
|
||||
self.header_button.clicked.connect(self.toggle_expanded)
|
||||
|
||||
# Enable drag and drop for reordering
|
||||
self.header_button.setAcceptDrops(True)
|
||||
self.header_button.mousePressEvent = self._header_mouse_press_event
|
||||
self.header_button.mouseMoveEvent = self._header_mouse_move_event
|
||||
self.header_button.dragEnterEvent = self._header_drag_enter_event
|
||||
self.header_button.dropEvent = self._header_drop_event
|
||||
|
||||
self.drag_start_position = None
|
||||
|
||||
# Add header to layout
|
||||
header_layout.addWidget(self.header_button)
|
||||
header_layout.addStretch()
|
||||
|
||||
self.header_add_button = QPushButton()
|
||||
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.header_add_button.setFixedSize(20, 20)
|
||||
self.header_add_button.setToolTip("Add item")
|
||||
self.header_add_button.setVisible(show_add_button)
|
||||
|
||||
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
|
||||
header_layout.addWidget(self.header_add_button)
|
||||
|
||||
self.main_layout.addLayout(header_layout)
|
||||
|
||||
self._update_expanded_state()
|
||||
|
||||
def set_widget(self, widget):
|
||||
"""Set the content widget for this collapsible section"""
|
||||
# Remove existing content widget if any
|
||||
if self.content_widget and self.content_widget.parent() == self:
|
||||
self.main_layout.removeWidget(self.content_widget)
|
||||
self.content_widget.close()
|
||||
self.content_widget.deleteLater()
|
||||
|
||||
self.content_widget = widget
|
||||
if self.content_widget:
|
||||
self.main_layout.addWidget(self.content_widget)
|
||||
|
||||
self._update_expanded_state()
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update the header button appearance based on expanded state"""
|
||||
# Use material icons with consistent sizing to match tree items
|
||||
icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right"
|
||||
icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False)
|
||||
|
||||
self.header_button.setIcon(icon)
|
||||
self.header_button.setText(self.title)
|
||||
|
||||
# Get theme colors
|
||||
palette = get_theme_palette()
|
||||
text_color = palette.text().color().name()
|
||||
|
||||
self.header_button.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: {text_color};
|
||||
icon-size: 20px 20px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def toggle_expanded(self):
|
||||
"""Toggle the expanded state and update size policy"""
|
||||
self.expanded = not self.expanded
|
||||
self._update_expanded_state()
|
||||
|
||||
def _update_expanded_state(self):
|
||||
"""Update the expanded state based on current state"""
|
||||
self._update_appearance()
|
||||
if self.expanded:
|
||||
if self.content_widget:
|
||||
self.content_widget.show()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
else:
|
||||
if self.content_widget:
|
||||
self.content_widget.hide()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self) -> bool:
|
||||
"""Get the expanded state"""
|
||||
return self._expanded
|
||||
|
||||
@expanded.setter
|
||||
def expanded(self, value: bool):
|
||||
"""Set the expanded state programmatically"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("Expanded state must be a boolean")
|
||||
if self._expanded == value:
|
||||
return
|
||||
self._expanded = value
|
||||
self._update_appearance()
|
||||
|
||||
def connect_add_button(self, slot):
|
||||
"""Connect a slot to the add button's clicked signal.
|
||||
|
||||
Args:
|
||||
slot: The function to call when the add button is clicked.
|
||||
"""
|
||||
self.header_add_button.clicked.connect(slot)
|
||||
|
||||
def _header_mouse_press_event(self, event):
|
||||
"""Handle mouse press on header for drag start"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.drag_start_position = event.pos()
|
||||
QPushButton.mousePressEvent(self.header_button, event)
|
||||
|
||||
def _header_mouse_move_event(self, event):
|
||||
"""Handle mouse move to start drag operation"""
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None:
|
||||
|
||||
# Check if we've moved far enough to start a drag
|
||||
if (event.pos() - self.drag_start_position).manhattanLength() >= 10:
|
||||
|
||||
self._start_drag()
|
||||
QPushButton.mouseMoveEvent(self.header_button, event)
|
||||
|
||||
def _start_drag(self):
|
||||
"""Start the drag operation with a properly aligned widget pixmap"""
|
||||
drag = QDrag(self.header_button)
|
||||
mime_data = QMimeData()
|
||||
mime_data.setText(f"section:{self.title}")
|
||||
drag.setMimeData(mime_data)
|
||||
|
||||
# Grab a pixmap of the widget
|
||||
widget_pixmap = self.header_button.grab()
|
||||
|
||||
drag.setPixmap(widget_pixmap)
|
||||
|
||||
# Set the hotspot to where the mouse was pressed on the widget
|
||||
drag.setHotSpot(self.drag_start_position)
|
||||
|
||||
drag.exec_(Qt.MoveAction)
|
||||
|
||||
def _header_drag_enter_event(self, event):
|
||||
"""Handle drag enter on header"""
|
||||
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def _header_drop_event(self, event):
|
||||
"""Handle drop on header"""
|
||||
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
|
||||
source_title = event.mimeData().text().replace("section:", "")
|
||||
if source_title != self.title:
|
||||
# Emit signal to parent to handle reordering
|
||||
self.section_reorder_requested.emit(source_title, self.title)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
179
bec_widgets/widgets/containers/explorer/explorer.py
Normal file
179
bec_widgets/widgets/containers/explorer/explorer.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
|
||||
|
||||
|
||||
class Explorer(BECWidget, QWidget):
|
||||
"""
|
||||
A widget that combines multiple collapsible sections for an explorer-like interface.
|
||||
Each section can be expanded or collapsed, and sections can be reordered. The explorer
|
||||
can contain also sub-explorers for nested structures.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Main layout
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
# Splitter for sections
|
||||
self.splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
self.main_layout.addWidget(self.splitter)
|
||||
|
||||
# Spacer for when all sections are collapsed
|
||||
self.expander = QSpacerItem(0, 0)
|
||||
self.main_layout.addItem(self.expander)
|
||||
|
||||
# Registry of sections
|
||||
self.sections: list[CollapsibleSection] = []
|
||||
|
||||
# Setup splitter styling
|
||||
self._setup_splitter_styling()
|
||||
|
||||
def add_section(self, section: CollapsibleSection) -> None:
|
||||
"""
|
||||
Add a collapsible section to the explorer
|
||||
|
||||
Args:
|
||||
section (CollapsibleSection): The section to add
|
||||
"""
|
||||
if not isinstance(section, CollapsibleSection):
|
||||
raise TypeError("section must be an instance of CollapsibleSection")
|
||||
|
||||
if section in self.sections:
|
||||
return
|
||||
|
||||
self.sections.append(section)
|
||||
self.splitter.addWidget(section)
|
||||
|
||||
# Connect the section's toggle to update spacer
|
||||
section.header_button.clicked.connect(self._update_spacer)
|
||||
|
||||
# Connect section reordering if supported
|
||||
if hasattr(section, "section_reorder_requested"):
|
||||
section.section_reorder_requested.connect(self._handle_section_reorder)
|
||||
|
||||
self._update_spacer()
|
||||
|
||||
def remove_section(self, section: CollapsibleSection) -> None:
|
||||
"""
|
||||
Remove a collapsible section from the explorer
|
||||
|
||||
Args:
|
||||
section (CollapsibleSection): The section to remove
|
||||
"""
|
||||
if section not in self.sections:
|
||||
return
|
||||
self.sections.remove(section)
|
||||
section.deleteLater()
|
||||
section.close()
|
||||
|
||||
# Disconnect signals
|
||||
try:
|
||||
section.header_button.clicked.disconnect(self._update_spacer)
|
||||
if hasattr(section, "section_reorder_requested"):
|
||||
section.section_reorder_requested.disconnect(self._handle_section_reorder)
|
||||
except RuntimeError:
|
||||
# Signals already disconnected
|
||||
pass
|
||||
|
||||
self._update_spacer()
|
||||
|
||||
def get_section(self, title: str) -> CollapsibleSection | None:
|
||||
"""Get a section by its title"""
|
||||
for section in self.sections:
|
||||
if section.title == title:
|
||||
return section
|
||||
return None
|
||||
|
||||
def _setup_splitter_styling(self) -> None:
|
||||
"""Setup the splitter styling with theme colors"""
|
||||
palette = get_theme_palette()
|
||||
separator_color = palette.mid().color()
|
||||
|
||||
self.splitter.setStyleSheet(
|
||||
f"""
|
||||
QSplitter::handle {{
|
||||
height: 0.1px;
|
||||
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def _update_spacer(self) -> None:
|
||||
"""Update the spacer size based on section states"""
|
||||
any_expanded = any(section.expanded for section in self.sections)
|
||||
|
||||
if any_expanded:
|
||||
self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
else:
|
||||
self.expander.changeSize(
|
||||
0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
|
||||
def _handle_section_reorder(self, source_title: str, target_title: str) -> None:
|
||||
"""Handle reordering of sections"""
|
||||
if source_title == target_title:
|
||||
return
|
||||
|
||||
source_section = self.get_section(source_title)
|
||||
target_section = self.get_section(target_title)
|
||||
|
||||
if not source_section or not target_section:
|
||||
return
|
||||
|
||||
# Get current indices
|
||||
source_index = self.splitter.indexOf(source_section)
|
||||
target_index = self.splitter.indexOf(target_section)
|
||||
|
||||
if source_index == -1 or target_index == -1:
|
||||
return
|
||||
|
||||
# Insert at target position
|
||||
self.splitter.insertWidget(target_index, source_section)
|
||||
|
||||
# Update sections
|
||||
self.sections.remove(source_section)
|
||||
self.sections.insert(target_index, source_section)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLabel
|
||||
|
||||
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
|
||||
|
||||
app = QApplication([])
|
||||
explorer = Explorer()
|
||||
section = CollapsibleSection(title="SCRIPTS", indentation=0)
|
||||
|
||||
script_explorer = Explorer()
|
||||
script_widget = ScriptTreeWidget()
|
||||
local_scripts_section = CollapsibleSection(title="Local")
|
||||
local_scripts_section.set_widget(script_widget)
|
||||
script_widget.set_directory(os.path.abspath("./"))
|
||||
script_explorer.add_section(local_scripts_section)
|
||||
|
||||
section.set_widget(script_explorer)
|
||||
explorer.add_section(section)
|
||||
shared_script_section = CollapsibleSection(title="Shared")
|
||||
shared_script_widget = ScriptTreeWidget()
|
||||
shared_script_widget.set_directory(os.path.abspath("./"))
|
||||
shared_script_section.set_widget(shared_script_widget)
|
||||
script_explorer.add_section(shared_script_section)
|
||||
macros_section = CollapsibleSection(title="MACROS", indentation=0)
|
||||
macros_section.set_widget(QLabel("Macros will be implemented later"))
|
||||
explorer.add_section(macros_section)
|
||||
explorer.show()
|
||||
app.exec()
|
||||
387
bec_widgets/widgets/containers/explorer/script_tree_widget.py
Normal file
387
bec_widgets/widgets/containers/explorer/script_tree_widget.py
Normal file
@@ -0,0 +1,387 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
|
||||
from qtpy.QtGui import QAction, QPainter
|
||||
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FileItemDelegate(QStyledItemDelegate):
|
||||
"""Custom delegate to show action buttons on hover"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.hovered_index = QModelIndex()
|
||||
self.file_actions: list[QAction] = []
|
||||
self.dir_actions: list[QAction] = []
|
||||
self.button_rects: list[QRect] = []
|
||||
self.current_file_path = ""
|
||||
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for files"""
|
||||
self.file_actions.append(action)
|
||||
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directories"""
|
||||
self.dir_actions.append(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions"""
|
||||
self.file_actions.clear()
|
||||
self.dir_actions.clear()
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
"""Paint the item with action buttons on hover"""
|
||||
# Paint the default item
|
||||
super().paint(painter, option, index)
|
||||
|
||||
# Early return if not hovering over this item
|
||||
if index != self.hovered_index:
|
||||
return
|
||||
|
||||
tree_view = self.parent()
|
||||
if not isinstance(tree_view, QTreeView):
|
||||
return
|
||||
|
||||
proxy_model = tree_view.model()
|
||||
if not isinstance(proxy_model, QSortFilterProxyModel):
|
||||
return
|
||||
|
||||
source_index = proxy_model.mapToSource(index)
|
||||
source_model = proxy_model.sourceModel()
|
||||
if not isinstance(source_model, QFileSystemModel):
|
||||
return
|
||||
|
||||
is_dir = source_model.isDir(source_index)
|
||||
file_path = source_model.filePath(source_index)
|
||||
self.current_file_path = file_path
|
||||
|
||||
# Choose appropriate actions based on item type
|
||||
actions = self.dir_actions if is_dir else self.file_actions
|
||||
if actions:
|
||||
self._draw_action_buttons(painter, option, actions)
|
||||
|
||||
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
|
||||
"""Draw action buttons on the right side"""
|
||||
button_size = 18
|
||||
margin = 4
|
||||
spacing = 2
|
||||
|
||||
# Calculate total width needed for all buttons
|
||||
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
|
||||
|
||||
# Clear previous button rects and create new ones
|
||||
self.button_rects.clear()
|
||||
|
||||
# Calculate starting position (right side of the item)
|
||||
start_x = option.rect.right() - total_width - margin
|
||||
current_x = start_x
|
||||
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Get theme colors for better integration
|
||||
palette = get_theme_palette()
|
||||
button_bg = palette.button().color()
|
||||
button_bg.setAlpha(150) # Semi-transparent
|
||||
|
||||
for action in actions:
|
||||
if not action.isVisible():
|
||||
continue
|
||||
|
||||
# Calculate button position
|
||||
button_rect = QRect(
|
||||
current_x,
|
||||
option.rect.top() + (option.rect.height() - button_size) // 2,
|
||||
button_size,
|
||||
button_size,
|
||||
)
|
||||
self.button_rects.append(button_rect)
|
||||
|
||||
# Draw button background
|
||||
painter.setBrush(button_bg)
|
||||
painter.setPen(palette.mid().color())
|
||||
painter.drawRoundedRect(button_rect, 3, 3)
|
||||
|
||||
# Draw action icon
|
||||
icon = action.icon()
|
||||
if not icon.isNull():
|
||||
icon_rect = button_rect.adjusted(2, 2, -2, -2)
|
||||
icon.paint(painter, icon_rect)
|
||||
|
||||
# Move to next button position
|
||||
current_x += button_size + spacing
|
||||
|
||||
painter.restore()
|
||||
|
||||
def editorEvent(self, event, model, option, index):
|
||||
"""Handle mouse events for action buttons"""
|
||||
# Early return if not a left click
|
||||
if not (
|
||||
event.type() == event.Type.MouseButtonPress
|
||||
and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
# Early return if not a proxy model
|
||||
if not isinstance(model, QSortFilterProxyModel):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
source_index = model.mapToSource(index)
|
||||
source_model = model.sourceModel()
|
||||
|
||||
# Early return if not a file system model
|
||||
if not isinstance(source_model, QFileSystemModel):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
is_dir = source_model.isDir(source_index)
|
||||
actions = self.dir_actions if is_dir else self.file_actions
|
||||
|
||||
# Check which button was clicked
|
||||
visible_actions = [action for action in actions if action.isVisible()]
|
||||
for i, button_rect in enumerate(self.button_rects):
|
||||
if button_rect.contains(event.pos()) and i < len(visible_actions):
|
||||
# Trigger the action
|
||||
visible_actions[i].trigger()
|
||||
return True
|
||||
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
def set_hovered_index(self, index):
|
||||
"""Set the currently hovered index"""
|
||||
self.hovered_index = index
|
||||
|
||||
|
||||
class ScriptTreeWidget(QWidget):
|
||||
"""A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection"""
|
||||
|
||||
file_selected = Signal(str) # Script file path selected
|
||||
file_open_requested = Signal(str) # File open button clicked
|
||||
file_renamed = Signal(str, str) # Old path, new path
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Create layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create tree view
|
||||
self.tree = QTreeView()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
|
||||
# Enable mouse tracking for hover effects
|
||||
self.tree.setMouseTracking(True)
|
||||
|
||||
# Create file system model
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setNameFilters(["*.py"])
|
||||
self.model.setNameFilterDisables(False)
|
||||
|
||||
# Create proxy model to filter out underscore directories
|
||||
self.proxy_model = QSortFilterProxyModel()
|
||||
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
|
||||
self.proxy_model.setSourceModel(self.model)
|
||||
self.tree.setModel(self.proxy_model)
|
||||
|
||||
# Create and set custom delegate
|
||||
self.delegate = FileItemDelegate(self.tree)
|
||||
self.tree.setItemDelegate(self.delegate)
|
||||
|
||||
# Add default open button for files
|
||||
action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self)
|
||||
action.action.triggered.connect(self._on_file_open_requested)
|
||||
self.delegate.add_file_action(action.action)
|
||||
|
||||
# Remove unnecessary columns
|
||||
self.tree.setColumnHidden(1, True) # Hide size column
|
||||
self.tree.setColumnHidden(2, True) # Hide type column
|
||||
self.tree.setColumnHidden(3, True) # Hide date modified column
|
||||
|
||||
# Apply BEC styling
|
||||
self._apply_styling()
|
||||
|
||||
# Script specific properties
|
||||
self.directory = None
|
||||
|
||||
# Connect signals
|
||||
self.tree.clicked.connect(self._on_item_clicked)
|
||||
self.tree.doubleClicked.connect(self._on_item_double_clicked)
|
||||
|
||||
# Install event filter for hover tracking
|
||||
self.tree.viewport().installEventFilter(self)
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
def _apply_styling(self):
|
||||
"""Apply styling to the tree widget"""
|
||||
# Get theme colors for subtle tree lines
|
||||
palette = get_theme_palette()
|
||||
subtle_line_color = palette.mid().color()
|
||||
subtle_line_color.setAlpha(80)
|
||||
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
tree_style = f"""
|
||||
QTreeView {{
|
||||
border: none;
|
||||
outline: 0;
|
||||
show-decoration-selected: 0;
|
||||
}}
|
||||
QTreeView::branch {{
|
||||
border-image: none;
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
QTreeView::item {{
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}}
|
||||
QTreeView::item:hover {{
|
||||
background: palette(midlight);
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
text-decoration: none;
|
||||
}}
|
||||
QTreeView::item:selected {{
|
||||
background: palette(highlight);
|
||||
color: palette(highlighted-text);
|
||||
}}
|
||||
QTreeView::item:selected:hover {{
|
||||
background: palette(highlight);
|
||||
}}
|
||||
"""
|
||||
|
||||
self.tree.setStyleSheet(tree_style)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle mouse move events for hover tracking"""
|
||||
# Early return if not the tree viewport
|
||||
if obj != self.tree.viewport():
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.MouseMove:
|
||||
index = self.tree.indexAt(event.pos())
|
||||
if index.isValid():
|
||||
self.delegate.set_hovered_index(index)
|
||||
else:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.Leave:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def set_directory(self, directory):
|
||||
"""Set the scripts directory"""
|
||||
self.directory = directory
|
||||
|
||||
# Early return if directory doesn't exist
|
||||
if not directory or not os.path.exists(directory):
|
||||
return
|
||||
|
||||
root_index = self.model.setRootPath(directory)
|
||||
# Map the source model index to proxy model index
|
||||
proxy_root_index = self.proxy_model.mapFromSource(root_index)
|
||||
self.tree.setRootIndex(proxy_root_index)
|
||||
self.tree.expandAll()
|
||||
|
||||
def _on_item_clicked(self, index: QModelIndex):
|
||||
"""Handle item clicks"""
|
||||
# Map proxy index back to source index
|
||||
source_index = self.proxy_model.mapToSource(index)
|
||||
|
||||
# Early return for directories
|
||||
if self.model.isDir(source_index):
|
||||
return
|
||||
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
path_obj = Path(file_path)
|
||||
|
||||
# Only emit signal for Python files
|
||||
if path_obj.suffix.lower() == ".py":
|
||||
logger.info(f"Script selected: {file_path}")
|
||||
self.file_selected.emit(file_path)
|
||||
|
||||
def _on_item_double_clicked(self, index: QModelIndex):
|
||||
"""Handle item double-clicks"""
|
||||
# Map proxy index back to source index
|
||||
source_index = self.proxy_model.mapToSource(index)
|
||||
|
||||
# Early return for directories
|
||||
if self.model.isDir(source_index):
|
||||
return
|
||||
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
# Emit signal to open the file
|
||||
logger.info(f"File open requested via double-click: {file_path}")
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def _on_file_open_requested(self):
|
||||
"""Handle file open action triggered"""
|
||||
logger.info("File open requested")
|
||||
# Early return if no hovered item
|
||||
if not self.delegate.hovered_index.isValid():
|
||||
return
|
||||
|
||||
source_index = self.proxy_model.mapToSource(self.delegate.hovered_index)
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for file items"""
|
||||
self.delegate.add_file_action(action)
|
||||
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directory items"""
|
||||
self.delegate.add_dir_action(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions from items"""
|
||||
self.delegate.clear_actions()
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the tree view"""
|
||||
if self.directory is None:
|
||||
return
|
||||
self.model.setRootPath("") # Reset
|
||||
root_index = self.model.setRootPath(self.directory)
|
||||
proxy_root_index = self.proxy_model.mapFromSource(root_index)
|
||||
self.tree.setRootIndex(proxy_root_index)
|
||||
|
||||
def expand_all(self):
|
||||
"""Expand all items in the tree"""
|
||||
self.tree.expandAll()
|
||||
|
||||
def collapse_all(self):
|
||||
"""Collapse all items in the tree"""
|
||||
self.tree.collapseAll()
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from typing import Dict, Literal, Optional, Set, Tuple, Union
|
||||
@@ -51,7 +53,7 @@ class LayoutManagerWidget(QWidget):
|
||||
self,
|
||||
widget: QWidget | str,
|
||||
row: int | None = None,
|
||||
col: Optional[int] = None,
|
||||
col: int | None = None,
|
||||
rowspan: int = 1,
|
||||
colspan: int = 1,
|
||||
shift_existing: bool = True,
|
||||
@@ -136,6 +138,39 @@ class LayoutManagerWidget(QWidget):
|
||||
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
|
||||
|
||||
# Determine new widget position based on the specified relative position
|
||||
|
||||
# If adding to the left or right with shifting, shift the entire column
|
||||
if (
|
||||
position in ("left", "right")
|
||||
and shift_existing
|
||||
and shift_direction in ("left", "right")
|
||||
):
|
||||
column = ref_col
|
||||
# Collect all rows in this column and sort for safe shifting
|
||||
rows = sorted(
|
||||
{row for (row, col) in self.position_widgets.keys() if col == column},
|
||||
reverse=(shift_direction == "right"),
|
||||
)
|
||||
# Shift each widget in the column
|
||||
for r in rows:
|
||||
self.shift_widgets(direction=shift_direction, start_row=r, start_col=column)
|
||||
# Update reference widget's position after the column shift
|
||||
ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget]
|
||||
new_row = ref_row
|
||||
# Compute insertion column based on relative position
|
||||
if position == "left":
|
||||
new_col = ref_col - ref_colspan
|
||||
else:
|
||||
new_col = ref_col + ref_colspan
|
||||
# Add the new widget without triggering another shift
|
||||
return self.add_widget(
|
||||
widget=widget,
|
||||
row=new_row,
|
||||
col=new_col,
|
||||
rowspan=rowspan,
|
||||
colspan=colspan,
|
||||
shift_existing=False,
|
||||
)
|
||||
if position == "left":
|
||||
new_row = ref_row
|
||||
new_col = ref_col - 1
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import sys
|
||||
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class WidgetTooltip(QWidget):
|
||||
"""Frameless, always-on-top window that behaves like a tooltip."""
|
||||
|
||||
def __init__(self, content: QWidget) -> None:
|
||||
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
self.setMouseTracking(True)
|
||||
self.content = content
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(6, 6, 6, 6)
|
||||
layout.addWidget(self.content)
|
||||
self.adjustSize()
|
||||
|
||||
def leaveEvent(self, _event) -> None:
|
||||
self.hide()
|
||||
|
||||
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
|
||||
self.adjustSize()
|
||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||
screen_geo = screen.availableGeometry()
|
||||
geom = self.geometry()
|
||||
|
||||
x = global_pos.x() - geom.width() // 2
|
||||
y = global_pos.y() - geom.height() - offset
|
||||
|
||||
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
|
||||
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
|
||||
|
||||
self.move(x, y)
|
||||
self.show()
|
||||
|
||||
|
||||
class HoverWidget(QWidget):
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, *, simple: QWidget, full: QWidget):
|
||||
super().__init__(parent)
|
||||
self._simple = simple
|
||||
self._full = full
|
||||
self._full.setVisible(False)
|
||||
self._tooltip = None
|
||||
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(simple)
|
||||
|
||||
def enterEvent(self, event):
|
||||
# suppress empty-label tooltips for labels
|
||||
if isinstance(self._full, QLabel) and not self._full.text():
|
||||
return
|
||||
|
||||
if self._tooltip is None: # first time only
|
||||
self._tooltip = WidgetTooltip(self._full)
|
||||
self._full.setVisible(True)
|
||||
|
||||
centre = self.mapToGlobal(self.rect().center())
|
||||
self._tooltip.show_above(centre)
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
if self._tooltip and self._tooltip.isVisible():
|
||||
self._tooltip.hide()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def close(self):
|
||||
if self._tooltip:
|
||||
self._tooltip.close()
|
||||
self._tooltip.deleteLater()
|
||||
self._tooltip = None
|
||||
super().close()
|
||||
|
||||
|
||||
################################################################################
|
||||
# Demo
|
||||
# Just a simple example to show how the HoverWidget can be used to display
|
||||
# a tooltip with a full widget inside (two different widgets are used
|
||||
# for the simple and full versions).
|
||||
################################################################################
|
||||
|
||||
|
||||
class DemoSimpleWidget(QLabel): # pragma: no cover
|
||||
"""A simple widget to be used as a trigger for the tooltip."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setText("Hover me for a preview!")
|
||||
|
||||
|
||||
class DemoFullWidget(QProgressBar): # pragma: no cover
|
||||
"""A full widget to be shown in the tooltip."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setRange(0, 100)
|
||||
self.setValue(75)
|
||||
self.setFixedWidth(320)
|
||||
self.setFixedHeight(30)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
window = QWidget()
|
||||
window.layout = QHBoxLayout(window)
|
||||
hover_widget = HoverWidget(simple=DemoSimpleWidget(), full=DemoFullWidget())
|
||||
window.layout.addWidget(hover_widget)
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user